Allowing to quickly select the output speaker device
parent
805f248a50
commit
4c90237a36
|
@ -1,4 +1,8 @@
|
|||
# Changelog:
|
||||
* **25.03.21**
|
||||
- Allowing to directly select the speaker output device
|
||||
- Saving the speaker output device
|
||||
|
||||
* **24.03.21**
|
||||
- Improved the avatar upload modal (now way more intuitive)
|
||||
- Fixed a bug which cause client avatars to be stuck within the loading screen
|
||||
|
|
|
@ -209,6 +209,13 @@ html:root {
|
|||
margin-right: .5em;
|
||||
}
|
||||
|
||||
&.title {
|
||||
color: #557edc;
|
||||
|
||||
.entryName {
|
||||
margin-left: .25em;
|
||||
}
|
||||
}
|
||||
|
||||
&:first-of-type {
|
||||
border-radius: .1em .1em 0 0;
|
||||
|
|
|
@ -22,6 +22,7 @@ import {bookmarks} from "tc-shared/Bookmarks";
|
|||
import {connectionHistory} from "tc-shared/connectionlog/History";
|
||||
import {RemoteIconInfo} from "tc-shared/file/Icons";
|
||||
import {spawnModalAddCurrentServerToBookmarks} from "tc-shared/ui/modal/bookmarks-add-server/Controller";
|
||||
import {getAudioBackend, OutputDevice} from "tc-shared/audio/Player";
|
||||
|
||||
class InfoController {
|
||||
private readonly mode: ControlBarMode;
|
||||
|
@ -67,6 +68,9 @@ class InfoController {
|
|||
this.registerDefaultRecorderEvents();
|
||||
this.sendMicrophoneList();
|
||||
}));
|
||||
events.push(settings.globalChangeListener(Settings.KEY_SPEAKER_DEVICE_ID, () => this.sendSpeakerList()));
|
||||
getAudioBackend().executeWhenInitialized(() => this.sendSpeakerList());
|
||||
|
||||
if(this.mode === "main") {
|
||||
events.push(server_connections.events().on("notify_active_handler_changed", event => this.setConnectionHandler(event.newHandler)));
|
||||
}
|
||||
|
@ -298,6 +302,37 @@ class InfoController {
|
|||
});
|
||||
}
|
||||
|
||||
public async sendSpeakerList() {
|
||||
const backend = getAudioBackend();
|
||||
if(!backend.isInitialized()) {
|
||||
this.events.fire_react("notify_speaker_list", { state: "uninitialized" });
|
||||
return;
|
||||
}
|
||||
|
||||
const devices = await backend.getAvailableDevices();
|
||||
const selectedDeviceId = backend.getCurrentDevice()?.device_id;
|
||||
const defaultDeviceId = backend.getDefaultDeviceId();
|
||||
this.events.fire_react("notify_speaker_list", {
|
||||
state: "initialized",
|
||||
devices: devices.map(device => {
|
||||
let selected = false;
|
||||
if(selectedDeviceId === OutputDevice.DefaultDeviceId && device.device_id === defaultDeviceId) {
|
||||
selected = true;
|
||||
} else if(selectedDeviceId === device.device_id) {
|
||||
selected = true;
|
||||
}
|
||||
|
||||
return {
|
||||
name: device.name,
|
||||
driver: device.driver,
|
||||
|
||||
id: device.device_id,
|
||||
selected: selected
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
public sendSubscribeState() {
|
||||
this.events.fire_react("notify_subscribe_state", {
|
||||
subscribe: !!this.currentHandler?.isSubscribeToAllChannels()
|
||||
|
@ -389,6 +424,7 @@ export function initializeControlBarController(events: Registry<ControlBarEvents
|
|||
events.on("query_microphone_state", () => infoHandler.sendMicrophoneState());
|
||||
events.on("query_microphone_list", () => infoHandler.sendMicrophoneList());
|
||||
events.on("query_speaker_state", () => infoHandler.sendSpeakerState());
|
||||
events.on("query_speaker_list", () => infoHandler.sendSpeakerList());
|
||||
events.on("query_subscribe_state", () => infoHandler.sendSubscribeState());
|
||||
events.on("query_host_button", () => infoHandler.sendHostButton());
|
||||
events.on("query_video_state", event => infoHandler.sendVideoState(event.broadcastType));
|
||||
|
@ -470,10 +506,26 @@ export function initializeControlBarController(events: Registry<ControlBarEvents
|
|||
global_client_actions.fire("action_open_window_settings", { defaultCategory: "audio-microphone" });
|
||||
});
|
||||
|
||||
events.on("action_toggle_speaker", event => {
|
||||
events.on("action_toggle_speaker", async event => {
|
||||
/* change the default global setting */
|
||||
settings.setValue(Settings.KEY_CLIENT_STATE_SPEAKER_MUTED, !event.enabled);
|
||||
|
||||
if(typeof event.targetDeviceId === "string") {
|
||||
try {
|
||||
const devices = await getAudioBackend().getAvailableDevices();
|
||||
const device = devices.find(device => device.device_id === event.targetDeviceId);
|
||||
if(!device) {
|
||||
throw tr("Target device could not be found.");
|
||||
}
|
||||
|
||||
await getAudioBackend().setCurrentDevice(device.device_id);
|
||||
settings.setValue(Settings.KEY_SPEAKER_DEVICE_ID, device.device_id);
|
||||
} catch (error) {
|
||||
createErrorModal(tr("Failed to change speaker"), tr("Failed to change speaker.\nTarget device could not be found.")).open();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
infoHandler.getCurrentHandler()?.setSpeakerMuted(!event.enabled);
|
||||
});
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ export type MicrophoneState = "enabled" | "disabled" | "muted";
|
|||
export type VideoState = "enabled" | "disabled" | "unavailable" | "unsupported" | "disconnected";
|
||||
export type HostButtonInfo = { title?: string, target?: string, url: string };
|
||||
export type VideoDeviceInfo = { name: string, id: string };
|
||||
export type MicrophoneDeviceInfo = { name: string, id: string, driver: string, selected: boolean };
|
||||
export type AudioDeviceInfo = { name: string, id: string, driver: string, selected: boolean };
|
||||
|
||||
export interface ControlBarEvents {
|
||||
action_connection_connect: { newTab: boolean },
|
||||
|
@ -19,13 +19,14 @@ export interface ControlBarEvents {
|
|||
action_bookmark_add_current_server: {},
|
||||
action_toggle_away: { away: boolean, globally: boolean, promptMessage?: boolean },
|
||||
action_toggle_microphone: { enabled: boolean, targetDeviceId?: string },
|
||||
action_toggle_speaker: { enabled: boolean },
|
||||
action_toggle_speaker: { enabled: boolean, targetDeviceId?: string },
|
||||
action_toggle_subscribe: { subscribe: boolean },
|
||||
action_toggle_query: { show: boolean },
|
||||
action_query_manage: {},
|
||||
action_toggle_video: { broadcastType: VideoBroadcastType, enable: boolean, quickStart?: boolean, deviceId?: string },
|
||||
action_manage_video: { broadcastType: VideoBroadcastType },
|
||||
action_open_microphone_settings: {},
|
||||
action_open_speaker_settings: {},
|
||||
|
||||
query_mode: {},
|
||||
query_connection_state: {},
|
||||
|
@ -34,6 +35,7 @@ export interface ControlBarEvents {
|
|||
query_microphone_state: {},
|
||||
query_microphone_list: {},
|
||||
query_speaker_state: {},
|
||||
query_speaker_list: {},
|
||||
query_subscribe_state: {},
|
||||
query_query_state: {},
|
||||
query_host_button: {},
|
||||
|
@ -45,8 +47,9 @@ export interface ControlBarEvents {
|
|||
notify_bookmarks: { marks: Bookmark[] },
|
||||
notify_away_state: { state: AwayState },
|
||||
notify_microphone_state: { state: MicrophoneState },
|
||||
notify_microphone_list: { devices: MicrophoneDeviceInfo[] },
|
||||
notify_microphone_list: { devices: AudioDeviceInfo[] },
|
||||
notify_speaker_state: { enabled: boolean },
|
||||
notify_speaker_list: { state: "initialized", devices: AudioDeviceInfo[] } | { state: "uninitialized" },
|
||||
notify_subscribe_state: { subscribe: boolean },
|
||||
notify_query_state: { shown: boolean },
|
||||
notify_host_button: { button: HostButtonInfo | undefined },
|
||||
|
|
|
@ -2,6 +2,7 @@ import * as React from "react";
|
|||
import {ReactComponentBase} from "tc-shared/ui/react-elements/ReactComponentBase";
|
||||
import {IconRenderer, RemoteIconRenderer} from "tc-shared/ui/react-elements/Icon";
|
||||
import {getIconManager, RemoteIconInfo} from "tc-shared/file/Icons";
|
||||
import {joinClassList} from "tc-shared/ui/react-elements/Helper";
|
||||
|
||||
const cssStyle = require("./Button.scss");
|
||||
|
||||
|
@ -13,7 +14,7 @@ export interface DropdownEntryProperties {
|
|||
onAuxClick?: (event: React.MouseEvent) => void;
|
||||
onContextMenu?: (event: React.MouseEvent) => void;
|
||||
|
||||
children?: React.ReactElement<DropdownEntry>[]
|
||||
children?: React.ReactElement<DropdownEntry | DropdownTitleEntry>[]
|
||||
}
|
||||
|
||||
const LocalIconRenderer = (props: { icon?: string | RemoteIconInfo, className?: string }) => {
|
||||
|
@ -24,9 +25,7 @@ const LocalIconRenderer = (props: { icon?: string | RemoteIconInfo, className?:
|
|||
}
|
||||
}
|
||||
|
||||
export class DropdownEntry extends ReactComponentBase<DropdownEntryProperties, {}> {
|
||||
protected defaultState() { return {}; }
|
||||
|
||||
export class DropdownEntry extends React.PureComponent<DropdownEntryProperties> {
|
||||
render() {
|
||||
if(this.props.children) {
|
||||
return (
|
||||
|
@ -50,6 +49,18 @@ export class DropdownEntry extends ReactComponentBase<DropdownEntryProperties, {
|
|||
}
|
||||
}
|
||||
|
||||
export class DropdownTitleEntry extends React.PureComponent<{
|
||||
children
|
||||
}> {
|
||||
render() {
|
||||
return (
|
||||
<div className={joinClassList(cssStyle.dropdownEntry, cssStyle.title)}>
|
||||
<a className={cssStyle.entryName}>{this.props.children}</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const DropdownContainer = (props: { children: any }) => (
|
||||
<div className={cssStyle.dropdown}>
|
||||
{props.children}
|
||||
|
|
|
@ -5,14 +5,14 @@ import {
|
|||
ConnectionState,
|
||||
ControlBarEvents,
|
||||
ControlBarMode,
|
||||
HostButtonInfo, MicrophoneDeviceInfo,
|
||||
HostButtonInfo, AudioDeviceInfo,
|
||||
MicrophoneState,
|
||||
VideoDeviceInfo,
|
||||
VideoState
|
||||
} from "tc-shared/ui/frames/control-bar/Definitions";
|
||||
import * as React from "react";
|
||||
import {useContext, useRef, useState} from "react";
|
||||
import {DropdownEntry} from "tc-shared/ui/frames/control-bar/DropDown";
|
||||
import {DropdownEntry, DropdownTitleEntry} from "tc-shared/ui/frames/control-bar/DropDown";
|
||||
import {Translatable} from "tc-shared/ui/react-elements/i18n";
|
||||
import {Button} from "tc-shared/ui/frames/control-bar/Button";
|
||||
import {spawnContextMenu} from "tc-shared/ui/ContextMenu";
|
||||
|
@ -377,21 +377,14 @@ const kDriverWeights = {
|
|||
"Windows WASAPI": 50
|
||||
};
|
||||
|
||||
const MicrophoneDeviceList = React.memo(() => {
|
||||
const events = useContext(Events);
|
||||
const [ deviceList, setDeviceList ] = useState<MicrophoneDeviceInfo[]>(() => {
|
||||
events.fire("query_microphone_list");
|
||||
return [];
|
||||
});
|
||||
events.reactUse("notify_microphone_list", event => setDeviceList(event.devices));
|
||||
|
||||
if(deviceList.length <= 1) {
|
||||
const AudioIODeviceList = React.memo((props: { deviceList: AudioDeviceInfo[], onClick: (target: AudioDeviceInfo) => void }) => {
|
||||
if(props.deviceList.length <= 1) {
|
||||
/* we don't need a select here */
|
||||
return null;
|
||||
}
|
||||
|
||||
const devices: {[key: string]: { weight: number, device: MicrophoneDeviceInfo }} = {};
|
||||
for(const entry of deviceList) {
|
||||
const devices: {[key: string]: { weight: number, device: AudioDeviceInfo }} = {};
|
||||
for(const entry of props.deviceList) {
|
||||
const weight = entry.selected ? kDriverWeightSelected : (kDriverWeights[entry.driver] | 0);
|
||||
if(typeof devices[entry.name] !== "undefined" && devices[entry.name].weight >= weight) {
|
||||
continue;
|
||||
|
@ -403,21 +396,74 @@ const MicrophoneDeviceList = React.memo(() => {
|
|||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<hr key={"hr"} />
|
||||
{Object.values(devices).map(({ device }) => (
|
||||
const orderedDevices = Object.values(devices);
|
||||
const groupDevices = orderedDevices.length > 6;
|
||||
let currentGroup = undefined;
|
||||
|
||||
let deviceElements = [];
|
||||
for(const { device } of orderedDevices) {
|
||||
if(groupDevices) {
|
||||
if(currentGroup !== device.driver) {
|
||||
currentGroup = device.driver;
|
||||
deviceElements.push(
|
||||
<DropdownTitleEntry key={"g-" + currentGroup}>
|
||||
{currentGroup} <Translatable>Driver</Translatable>
|
||||
</DropdownTitleEntry>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
deviceElements.push(
|
||||
<DropdownEntry
|
||||
text={device.name || tr("Unknown device name")}
|
||||
key={"m-" + device.id}
|
||||
icon={device.selected ? ClientIcon.Apply : undefined}
|
||||
onClick={() => events.fire("action_toggle_microphone", { enabled: true, targetDeviceId: device.id })}
|
||||
onClick={() => props.onClick(device)}
|
||||
/>
|
||||
))}
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<hr key={"hr"} />
|
||||
{deviceElements}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
const MicrophoneDeviceList = React.memo(() => {
|
||||
const events = useContext(Events);
|
||||
const [ deviceList, setDeviceList ] = useState<AudioDeviceInfo[]>(() => {
|
||||
events.fire("query_microphone_list");
|
||||
return [];
|
||||
});
|
||||
events.reactUse("notify_microphone_list", event => setDeviceList(event.devices));
|
||||
|
||||
return (
|
||||
<AudioIODeviceList deviceList={deviceList} onClick={device => events.fire("action_toggle_microphone", { enabled: true, targetDeviceId: device.id })} />
|
||||
);
|
||||
});
|
||||
|
||||
const SpeakerDeviceList = React.memo(() => {
|
||||
const events = useContext(Events);
|
||||
const [ deviceList, setDeviceList ] = useState<AudioDeviceInfo[]>(() => {
|
||||
events.fire("query_speaker_list");
|
||||
return [];
|
||||
});
|
||||
events.reactUse("notify_speaker_list", event => {
|
||||
if(event.state === "uninitialized") {
|
||||
setDeviceList([]);
|
||||
} else {
|
||||
setDeviceList(event.devices);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<AudioIODeviceList deviceList={deviceList} onClick={device => events.fire("action_toggle_speaker", { enabled: true, targetDeviceId: device.id })} />
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
const SpeakerButton = () => {
|
||||
const events = useContext(Events);
|
||||
|
||||
|
@ -429,11 +475,52 @@ const SpeakerButton = () => {
|
|||
events.on("notify_speaker_state", event => setEnabled(event.enabled));
|
||||
|
||||
if(enabled) {
|
||||
return <Button colorTheme={"red"} autoSwitch={false} iconNormal={ClientIcon.OutputMuted} tooltip={tr("Mute headphones")}
|
||||
onToggle={() => events.fire("action_toggle_speaker", { enabled: false })} key={"enabled"} />;
|
||||
return (
|
||||
<Button
|
||||
colorTheme={"red"}
|
||||
autoSwitch={false}
|
||||
iconNormal={ClientIcon.OutputMuted}
|
||||
tooltip={tr("Mute headphones")}
|
||||
onToggle={() => events.fire("action_toggle_speaker", { enabled: false })}
|
||||
key={"enabled"}
|
||||
>
|
||||
<DropdownEntry
|
||||
icon={ClientIcon.OutputMuted}
|
||||
text={<Translatable>Mute headphones</Translatable>}
|
||||
onClick={() => events.fire("action_toggle_speaker", { enabled: false })}
|
||||
/>
|
||||
<DropdownEntry
|
||||
icon={ClientIcon.Settings}
|
||||
text={<Translatable>Open speaker settings</Translatable>}
|
||||
onClick={() => events.fire("action_open_speaker_settings", {})}
|
||||
/>
|
||||
<SpeakerDeviceList />
|
||||
</Button>
|
||||
);
|
||||
} else {
|
||||
return <Button switched={true} colorTheme={"red"} autoSwitch={false} iconNormal={ClientIcon.OutputMuted} tooltip={tr("Unmute headphones")}
|
||||
onToggle={() => events.fire("action_toggle_speaker", { enabled: true })} key={"disabled"} />;
|
||||
return (
|
||||
<Button
|
||||
switched={true}
|
||||
colorTheme={"red"}
|
||||
autoSwitch={false}
|
||||
iconNormal={ClientIcon.OutputMuted}
|
||||
tooltip={tr("Unmute headphones")}
|
||||
onToggle={() => events.fire("action_toggle_speaker", { enabled: true })}
|
||||
key={"disabled"}
|
||||
>
|
||||
<DropdownEntry
|
||||
icon={ClientIcon.OutputMuted}
|
||||
text={<Translatable>Unmute headphones</Translatable>}
|
||||
onClick={() => events.fire("action_toggle_speaker", { enabled: true })}
|
||||
/>
|
||||
<DropdownEntry
|
||||
icon={ClientIcon.Settings}
|
||||
text={<Translatable>Open speaker settings</Translatable>}
|
||||
onClick={() => events.fire("action_open_speaker_settings", {})}
|
||||
/>
|
||||
<SpeakerDeviceList />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue