Allowing to quickly select the output speaker device

master
WolverinDEV 2021-03-25 12:28:54 +01:00
parent 805f248a50
commit 4c90237a36
6 changed files with 197 additions and 33 deletions

View File

@ -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

View File

@ -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;

View File

@ -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);
});

View File

@ -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 },

View File

@ -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}

View File

@ -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>
);
}
}