diff --git a/ChangeLog.md b/ChangeLog.md index 50ffe044..5c4751cc 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -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 diff --git a/shared/js/ui/frames/control-bar/Button.scss b/shared/js/ui/frames/control-bar/Button.scss index 5c27723f..861f3ba4 100644 --- a/shared/js/ui/frames/control-bar/Button.scss +++ b/shared/js/ui/frames/control-bar/Button.scss @@ -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; diff --git a/shared/js/ui/frames/control-bar/Controller.ts b/shared/js/ui/frames/control-bar/Controller.ts index ab753d53..f6993617 100644 --- a/shared/js/ui/frames/control-bar/Controller.ts +++ b/shared/js/ui/frames/control-bar/Controller.ts @@ -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 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 { + 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); }); diff --git a/shared/js/ui/frames/control-bar/Definitions.ts b/shared/js/ui/frames/control-bar/Definitions.ts index 5ee05a73..652c3896 100644 --- a/shared/js/ui/frames/control-bar/Definitions.ts +++ b/shared/js/ui/frames/control-bar/Definitions.ts @@ -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 }, diff --git a/shared/js/ui/frames/control-bar/DropDown.tsx b/shared/js/ui/frames/control-bar/DropDown.tsx index 796ed945..cc606451 100644 --- a/shared/js/ui/frames/control-bar/DropDown.tsx +++ b/shared/js/ui/frames/control-bar/DropDown.tsx @@ -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[] + children?: React.ReactElement[] } const LocalIconRenderer = (props: { icon?: string | RemoteIconInfo, className?: string }) => { @@ -24,9 +25,7 @@ const LocalIconRenderer = (props: { icon?: string | RemoteIconInfo, className?: } } -export class DropdownEntry extends ReactComponentBase { - protected defaultState() { return {}; } - +export class DropdownEntry extends React.PureComponent { render() { if(this.props.children) { return ( @@ -50,6 +49,18 @@ export class DropdownEntry extends ReactComponentBase { + render() { + return ( + + ); + } +} + export const DropdownContainer = (props: { children: any }) => (
{props.children} diff --git a/shared/js/ui/frames/control-bar/Renderer.tsx b/shared/js/ui/frames/control-bar/Renderer.tsx index 06798ac6..f3e3718c 100644 --- a/shared/js/ui/frames/control-bar/Renderer.tsx +++ b/shared/js/ui/frames/control-bar/Renderer.tsx @@ -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(() => { - 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(() => { } } + 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( + + {currentGroup} Driver + + ); + } + } + + deviceElements.push( + props.onClick(device)} + /> + ); + } + return ( <>
- {Object.values(devices).map(({ device }) => ( - events.fire("action_toggle_microphone", { enabled: true, targetDeviceId: device.id })} - /> - ))} + {deviceElements} ); }); +const MicrophoneDeviceList = React.memo(() => { + const events = useContext(Events); + const [ deviceList, setDeviceList ] = useState(() => { + events.fire("query_microphone_list"); + return []; + }); + events.reactUse("notify_microphone_list", event => setDeviceList(event.devices)); + + return ( + events.fire("action_toggle_microphone", { enabled: true, targetDeviceId: device.id })} /> + ); +}); + +const SpeakerDeviceList = React.memo(() => { + const events = useContext(Events); + const [ deviceList, setDeviceList ] = useState(() => { + events.fire("query_speaker_list"); + return []; + }); + events.reactUse("notify_speaker_list", event => { + if(event.state === "uninitialized") { + setDeviceList([]); + } else { + setDeviceList(event.devices); + } + }); + + return ( + 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 + ); } else { - return + ); } }