diff --git a/src/lotus/lotusAudioOutput.test.ts b/src/lotus/lotusAudioOutput.test.ts new file mode 100644 index 00000000..fba2f50c --- /dev/null +++ b/src/lotus/lotusAudioOutput.test.ts @@ -0,0 +1,24 @@ +/* +Copyright 2026 Lotus Guild + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { expect, test } from "vitest"; + +import { audioOutputList } from "./lotusAudioOutput"; +import { type AudioOutputDeviceLabel } from "../state/MediaDevices"; + +test("audioOutputList labels every kind of output (cinny #310)", () => { + const m = new Map([ + ["", { type: "default", name: "Speakers" }], + ["a1", { type: "name", name: "Headset" }], + ["a2", { type: "number", number: 3 }], + ]); + expect(audioOutputList(m)).toEqual([ + { id: "", label: "Default (Speakers)" }, + { id: "a1", label: "Headset" }, + { id: "a2", label: "Output 3" }, + ]); +}); diff --git a/src/lotus/lotusAudioOutput.ts b/src/lotus/lotusAudioOutput.ts index c114c9a9..17cea15e 100644 --- a/src/lotus/lotusAudioOutput.ts +++ b/src/lotus/lotusAudioOutput.ts @@ -8,7 +8,12 @@ Please see LICENSE in the repository root for full details. import { type IWidgetApiRequest } from "matrix-widget-api"; import { logger } from "matrix-js-sdk/lib/logger"; -import { type MediaDevices } from "../state/MediaDevices"; +import { combineLatest } from "rxjs"; + +import { + type AudioOutputDeviceLabel, + type MediaDevices, +} from "../state/MediaDevices"; import { widget } from "../widget"; import { LotusWidgetActions } from "./lotusActions"; import { lotusSendToHost } from "./lotusWidget"; @@ -19,6 +24,10 @@ import { lotusSendToHost } from "./lotusWidget"; * selects the output; the fork answers each change (and the initial state) * with `io.lotus.audio_output_state { deviceId, available: [{id,label}] }`. * No effect unless the host sends the action. Returns a teardown function. + * + * [cinny #310] Browsers hash deviceIds per frame/origin, so the host can't + * use ids from its own enumerateDevices(); it builds its menu from + * `available` here, whose ids are valid in this frame. */ export function startLotusAudioOutput(mediaDevices: MediaDevices): () => void { const w = widget; @@ -33,9 +42,13 @@ export function startLotusAudioOutput(mediaDevices: MediaDevices): () => void { }; w.lazyActions.on(LotusWidgetActions.SetAudioOutput, handler); - const sub = mediaDevices.audioOutput.selected$.subscribe((selected) => { + const sub = combineLatest([ + mediaDevices.audioOutput.available$, + mediaDevices.audioOutput.selected$, + ]).subscribe(([available, selected]) => { lotusSendToHost(LotusWidgetActions.AudioOutputState, { deviceId: selected?.id ?? null, + available: audioOutputList(available), }); }); @@ -44,3 +57,29 @@ export function startLotusAudioOutput(mediaDevices: MediaDevices): () => void { w.lazyActions.off(LotusWidgetActions.SetAudioOutput, handler); }; } + +/** The output list as `{id, label}` for the host's menu (#310). */ +export function audioOutputList( + available: Map, +): { id: string; label: string }[] { + return [...available].map(([id, l]) => { + let label: string; + switch (l.type) { + case "name": + label = l.name; + break; + case "number": + label = `Output ${l.number}`; + break; + case "default": + label = l.name ? `Default (${l.name})` : "Default"; + break; + case "speaker": + label = "Speaker"; + break; + default: + label = "Earpiece"; + } + return { id, label }; + }); +}