diff --git a/package-lock.json b/package-lock.json index 67205ea29..a89d1508e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,7 +81,7 @@ }, "devDependencies": { "@axe-core/playwright": "4.13.0", - "@lotusguild/element-call-embedded": "0.25.0-lotus.23", + "@lotusguild/element-call-embedded": "0.25.0-lotus.24", "@playwright/test": "1.63.0", "@rollup/plugin-inject": "5.0.5", "@rollup/plugin-wasm": "6.2.2", @@ -2695,9 +2695,9 @@ "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==" }, "node_modules/@lotusguild/element-call-embedded": { - "version": "0.25.0-lotus.23", - "resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.23/element-call-embedded-0.25.0-lotus.23.tgz", - "integrity": "sha512-vRmcWeJcU0Accyl2x3mqNEzrIe8djE5BekyYbYOSCsMflC98rZgD1mO7KOe7bA1cX7gparRf6sAt1fotHoU+bA==", + "version": "0.25.0-lotus.24", + "resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.24/element-call-embedded-0.25.0-lotus.24.tgz", + "integrity": "sha512-6cMjJ0WCGvZJridoF0YslvF3giYar/uLgc/JAlgLqBg+Irv+Mm/xWuC2bme60BT+f/wmEX30gr7WDeIc4JUL7w==", "dev": true }, "node_modules/@matrix-org/matrix-sdk-crypto-wasm": { diff --git a/package.json b/package.json index 4bfab9a41..e14e9e2c5 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ }, "devDependencies": { "@axe-core/playwright": "4.13.0", - "@lotusguild/element-call-embedded": "0.25.0-lotus.23", + "@lotusguild/element-call-embedded": "0.25.0-lotus.24", "@playwright/test": "1.63.0", "@rollup/plugin-inject": "5.0.5", "@rollup/plugin-wasm": "6.2.2", 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; @@ -217,6 +238,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 fafe05773..a5532a87b 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -453,6 +453,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); +});