From 98c80f36cb73bdc63da47bb0d76e27d2c3c0dc57 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sun, 20 Sep 2026 15:31:47 -0400 Subject: [PATCH] feat(calls): audio output quick-switch in the call bar (#119) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified first: the embed hides Element Call's footer, so its in-call settings sheet (which has the output picker) is unreachable from Lotus; the cinny call bar had no output control. Now a speaker button next to Deafen (desktop bar only; hidden where setSinkId is unavailable — Firefox, Safari, Android Chrome) opens a menu of enumerateDevices() audio outputs with the current one checked; picking one sends io.lotus.set_audio_output to the fork (≥ 0.25.0-lotus.11), which selects it, and the choice is re-sent with the rest of the sticky fork state after an EC remount. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- .../call-status/AudioOutputButton.tsx | 150 ++++++++++++++++++ src/app/features/call-status/CallControl.tsx | 4 + src/app/plugins/call/CallControl.ts | 19 +++ 3 files changed, 173 insertions(+) create mode 100644 src/app/features/call-status/AudioOutputButton.tsx diff --git a/src/app/features/call-status/AudioOutputButton.tsx b/src/app/features/call-status/AudioOutputButton.tsx new file mode 100644 index 000000000..6f687b6b9 --- /dev/null +++ b/src/app/features/call-status/AudioOutputButton.tsx @@ -0,0 +1,150 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import FocusTrap from 'focus-trap-react'; +import { + Box, + Icon, + IconButton, + Icons, + Menu, + MenuItem, + PopOut, + RectCords, + Text, + Tooltip, + TooltipProvider, + config, + toRem, +} from 'folds'; +import { CallEmbed } from '../../plugins/call'; +import { MobileTouchTarget } from '../../styles/mobile.css'; +import { stopPropagation } from '../../utils/keyboard'; + +/** Output selection needs setSinkId; Firefox/Safari/Android Chrome lack it. */ +export const audioOutputSelectable = (): boolean => + typeof HTMLMediaElement !== 'undefined' && + 'setSinkId' in HTMLMediaElement.prototype && + typeof navigator !== 'undefined' && + !!navigator.mediaDevices?.enumerateDevices; + +type OutputDevice = { id: string; label: string }; + +/** + * [Gitea #119] Speaker button in the call bar: a small menu of audio outputs + * (headset ↔ speakers) without opening Settings. The choice goes to the fork + * as io.lotus.set_audio_output; the fork's own picker is unreachable here + * because the embed hides Element Call's footer. + */ +export function AudioOutputButton({ embed, disabled }: { embed: CallEmbed; disabled?: boolean }) { + const [anchor, setAnchor] = useState(); + const [devices, setDevices] = useState([]); + const [selected, setSelected] = useState(embed.control.audioOutputId); + + const refresh = useCallback(async () => { + try { + const all = await navigator.mediaDevices.enumerateDevices(); + setDevices( + all + .filter((d) => d.kind === 'audiooutput') + .map((d, i) => ({ id: d.deviceId, label: d.label || `Output ${i + 1}` })), + ); + } catch { + setDevices([]); + } + }, []); + + useEffect(() => { + if (!anchor) return undefined; + refresh(); + navigator.mediaDevices.addEventListener('devicechange', refresh); + return () => navigator.mediaDevices.removeEventListener('devicechange', refresh); + }, [anchor, refresh]); + + const choose = (id: string) => { + embed.control.setAudioOutput(id); + setSelected(id); + setAnchor(undefined); + }; + + return ( + setAnchor(undefined), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + + + {devices.length === 0 && ( + + No audio outputs found. + + )} + {devices.map((d) => { + const isSelected = selected ? d.id === selected : d.id === 'default'; + return ( + : undefined} + onClick={() => choose(d.id)} + > + + {d.label} + + + ); + })} + + + + } + > + + Audio output + + } + > + {(tipRef) => ( + setAnchor(e.currentTarget.getBoundingClientRect())} + > + + + )} + + + ); +} diff --git a/src/app/features/call-status/CallControl.tsx b/src/app/features/call-status/CallControl.tsx index 1eb9f15c6..c1667a6ac 100644 --- a/src/app/features/call-status/CallControl.tsx +++ b/src/app/features/call-status/CallControl.tsx @@ -8,6 +8,7 @@ import { callEmbedAtom } from '../../state/callEmbed'; import { MobileTouchTarget } from '../../styles/mobile.css'; import { useRoomCallPolicy } from '../../hooks/useRoomCallPolicy'; import { ScreenshareConfirm } from '../call/ScreenshareConfirm'; +import { AudioOutputButton, audioOutputSelectable } from './AudioOutputButton'; type MicrophoneButtonProps = { enabled: boolean; @@ -230,6 +231,9 @@ export function CallControl({ onToggle={() => callEmbed.control.toggleSound()} disabled={!callJoined} /> + {!compact && audioOutputSelectable() && ( + + )} {!compact && (showCamera || showScreenshare) && } {showCamera && ( diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts index e1f01e113..3098c4261 100644 --- a/src/app/plugins/call/CallControl.ts +++ b/src/app/plugins/call/CallControl.ts @@ -191,6 +191,7 @@ export class CallControl extends EventEmitter implements CallControlState { public resendForkState(): void { this.sendDeafenState(); this.sendQuality(); + if (this._audioOutputId !== undefined) this.sendAudioOutput(this._audioOutputId); // [Gitea #17] The pin lives fork-side and is dropped on a handler remount. if (this._focusedUserId !== null) this.sendFocus(this._focusedUserId, this._focusedMediaId); } @@ -277,6 +278,24 @@ export class CallControl extends EventEmitter implements CallControlState { // P6-2: send deafen state to the fork (io.lotus.set_deafen). Join-gated: the // fork's handler only exists once joined; onCallJoined() re-sends the current // state so a pre-join deafen is not lost. + // [Gitea #119] Output device (headset ↔ speakers) chosen from the host's + // call bar; the fork applies it with mediaDevices.audioOutput.select(). + private _audioOutputId: string | undefined; + + public get audioOutputId(): string | undefined { + return this._audioOutputId; + } + + public setAudioOutput(deviceId: string): void { + this._audioOutputId = deviceId; + this.sendAudioOutput(deviceId); + } + + private sendAudioOutput(deviceId: string): void { + if (!this.joined) return; + this.call.transport.send('io.lotus.set_audio_output', { deviceId }).catch(() => undefined); + } + private sendDeafenState(): void { if (!this.joined) return; this.call.transport