From ef7690135db0080aae5200b8e393443737ab6619 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 17 Oct 2026 19:48:47 -0400 Subject: [PATCH] fix(calls): the call-bar output menu uses the call frame's devices (#310) deviceIds are hashed per frame/origin, so the ids the menu got from the chat page's enumerateDevices() were unknown inside the call iframe: the fork fell back to the default output while the menu showed a tick on the chosen device. Handle io.lotus.audio_output_state (also silencing the fork's "host did not ack" log) and build the menu from the fork's list and effective selection; older forks fall back to the old list. Fixes #310 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- .../call-status/AudioOutputButton.tsx | 29 +++++++++++--- src/app/plugins/call/CallControl.ts | 40 +++++++++++++++++++ src/app/plugins/call/CallEmbed.ts | 6 +++ src/app/plugins/call/audioOutputState.test.ts | 24 +++++++++++ 4 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 src/app/plugins/call/audioOutputState.test.ts diff --git a/src/app/features/call-status/AudioOutputButton.tsx b/src/app/features/call-status/AudioOutputButton.tsx index 6f687b6b9..0fee8875d 100644 --- a/src/app/features/call-status/AudioOutputButton.tsx +++ b/src/app/features/call-status/AudioOutputButton.tsx @@ -16,6 +16,7 @@ import { toRem, } from 'folds'; import { CallEmbed } from '../../plugins/call'; +import { AUDIO_OUTPUT_STATE } from '../../plugins/call/CallControl'; import { MobileTouchTarget } from '../../styles/mobile.css'; import { stopPropagation } from '../../utils/keyboard'; @@ -39,6 +40,21 @@ export function AudioOutputButton({ embed, disabled }: { embed: CallEmbed; disab const [devices, setDevices] = useState([]); const [selected, setSelected] = useState(embed.control.audioOutputId); + // [Gitea #310] Prefer the fork's list: its ids are the ones valid inside + // the call iframe. Older forks don't report one; fall back to our own. + const [forkDevices, setForkDevices] = useState(embed.control.audioOutputs); + useEffect(() => { + const onState = () => { + setForkDevices(embed.control.audioOutputs); + setSelected(embed.control.audioOutputId); + }; + onState(); + embed.control.on(AUDIO_OUTPUT_STATE, onState); + return () => { + embed.control.off(AUDIO_OUTPUT_STATE, onState); + }; + }, [embed]); + const refresh = useCallback(async () => { try { const all = await navigator.mediaDevices.enumerateDevices(); @@ -53,11 +69,13 @@ export function AudioOutputButton({ embed, disabled }: { embed: CallEmbed; disab }, []); useEffect(() => { - if (!anchor) return undefined; + if (!anchor || forkDevices) return undefined; refresh(); navigator.mediaDevices.addEventListener('devicechange', refresh); return () => navigator.mediaDevices.removeEventListener('devicechange', refresh); - }, [anchor, refresh]); + }, [anchor, refresh, forkDevices]); + + const shown = forkDevices ?? devices; const choose = (id: string) => { embed.control.setAudioOutput(id); @@ -90,13 +108,14 @@ export function AudioOutputButton({ embed, disabled }: { embed: CallEmbed; disab aria-label="Audio output" > - {devices.length === 0 && ( + {shown.length === 0 && ( No audio outputs found. )} - {devices.map((d) => { - const isSelected = selected ? d.id === selected : d.id === 'default'; + {shown.map((d) => { + const isSelected = + selected !== undefined ? d.id === selected : d.id === 'default' || d.id === ''; return ( + typeof o === 'object' && + o !== null && + typeof (o as AudioOutputOption).id === 'string' && + typeof (o as AudioOutputOption).label === 'string', + ) + : undefined; + return { deviceId: typeof deviceId === 'string' ? deviceId : undefined, available: list }; +} + export class CallControl extends EventEmitter implements CallControlState { private state: CallControlState; @@ -210,6 +231,25 @@ export class CallControl extends EventEmitter implements CallControlState { this.sendAudioOutput(deviceId); } + // [Gitea #310] deviceIds are hashed per frame/origin, so ids from the + // host's own enumerateDevices() mean nothing inside the call iframe. The + // fork reports its outputs (ids valid in the frame) and its effective + // selection; the call bar's menu uses those when present. + private _audioOutputs: AudioOutputOption[] | undefined; + + public get audioOutputs(): AudioOutputOption[] | undefined { + return this._audioOutputs; + } + + /** The fork's `io.lotus.audio_output_state` report. */ + public onAudioOutputState(data: unknown) { + const report = parseAudioOutputState(data); + if (!report) return; + this._audioOutputs = report.available; + if (report.deviceId !== undefined) this._audioOutputId = report.deviceId; + this.emit(AUDIO_OUTPUT_STATE); + } + private sendAudioOutput(deviceId: string): void { if (!this.joined) return; this.call.transport.send('io.lotus.set_audio_output', { deviceId }).catch(() => undefined); diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index 039b024b1..34f54beba 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -439,6 +439,12 @@ export class CallEmbed { this.control.onControlsState((evt.detail as { data?: unknown } | undefined)?.data); }), ); + // [Gitea #310] The fork's own output list + selection (frame-valid ids). + this.disposables.push( + this.listenAction('io.lotus.audio_output_state', (evt) => { + this.control.onAudioOutputState((evt.detail as { data?: unknown } | undefined)?.data); + }), + ); // [Gitea #43] PTT / deafen keys pressed while focus is inside the frame. this.disposables.push( this.listenAction('io.lotus.hotkey', (evt) => { diff --git a/src/app/plugins/call/audioOutputState.test.ts b/src/app/plugins/call/audioOutputState.test.ts new file mode 100644 index 000000000..7dd2f059b --- /dev/null +++ b/src/app/plugins/call/audioOutputState.test.ts @@ -0,0 +1,24 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseAudioOutputState } from './CallControl'; + +test('parseAudioOutputState: keeps valid options, tolerates garbage (#310)', () => { + assert.deepEqual( + parseAudioOutputState({ + deviceId: 'f2', + available: [{ id: '', label: 'Default' }, { id: 'f2', label: 'Headset' }, { id: 3 }, null], + }), + { + deviceId: 'f2', + available: [ + { id: '', label: 'Default' }, + { id: 'f2', label: 'Headset' }, + ], + }, + ); + assert.deepEqual(parseAudioOutputState({ deviceId: null }), { + deviceId: undefined, + available: undefined, + }); + assert.equal(parseAudioOutputState('x'), undefined); +});