diff --git a/src/lotus/lotusActions.test.ts b/src/lotus/lotusActions.test.ts index 787acce8..ce56bedb 100644 --- a/src/lotus/lotusActions.test.ts +++ b/src/lotus/lotusActions.test.ts @@ -26,6 +26,7 @@ describe("LotusWidgetActions", () => { LotusWidgetActions.SetQuality, LotusWidgetActions.Decorations, LotusWidgetActions.SetDeafen, + LotusWidgetActions.SetAudioOutput, ]; expect(new Set(LOTUS_TO_WIDGET_ACTIONS)).toEqual(new Set(expectedToWidget)); diff --git a/src/lotus/lotusActions.ts b/src/lotus/lotusActions.ts index 3e504664..7dd85df2 100644 --- a/src/lotus/lotusActions.ts +++ b/src/lotus/lotusActions.ts @@ -52,6 +52,10 @@ export enum LotusWidgetActions { * disconnect or in-call teardown, whichever comes first. */ CallSummary = "io.lotus.call_summary", + /** toWidget: select the audio output device `{ deviceId }` (#119). */ + SetAudioOutput = "io.lotus.set_audio_output", + /** fromWidget: the currently selected output `{ deviceId }` (#119). */ + AudioOutputState = "io.lotus.audio_output_state", } /** toWidget Lotus actions that must be allow-listed in `initializeWidget`. */ @@ -61,4 +65,5 @@ export const LOTUS_TO_WIDGET_ACTIONS: LotusWidgetActions[] = [ LotusWidgetActions.SetQuality, LotusWidgetActions.Decorations, LotusWidgetActions.SetDeafen, + LotusWidgetActions.SetAudioOutput, ]; diff --git a/src/lotus/lotusAudioOutput.ts b/src/lotus/lotusAudioOutput.ts new file mode 100644 index 00000000..c114c9a9 --- /dev/null +++ b/src/lotus/lotusAudioOutput.ts @@ -0,0 +1,46 @@ +/* +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 { type IWidgetApiRequest } from "matrix-widget-api"; +import { logger } from "matrix-js-sdk/lib/logger"; + +import { type MediaDevices } from "../state/MediaDevices"; +import { widget } from "../widget"; +import { LotusWidgetActions } from "./lotusActions"; +import { lotusSendToHost } from "./lotusWidget"; + +/** + * [Gitea #119] Let the host switch the audio output device (headset ↔ + * speakers) from its own call bar: `io.lotus.set_audio_output { deviceId }` + * 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. + */ +export function startLotusAudioOutput(mediaDevices: MediaDevices): () => void { + const w = widget; + if (!w) return () => undefined; + + const handler = (ev: CustomEvent): void => { + w.api.transport.reply(ev.detail, {}); + const data = ev.detail.data as { deviceId?: string } | undefined; + if (typeof data?.deviceId !== "string") return; + logger.debug(`[lotus] set_audio_output: ${data.deviceId}`); + mediaDevices.audioOutput.select(data.deviceId); + }; + w.lazyActions.on(LotusWidgetActions.SetAudioOutput, handler); + + const sub = mediaDevices.audioOutput.selected$.subscribe((selected) => { + lotusSendToHost(LotusWidgetActions.AudioOutputState, { + deviceId: selected?.id ?? null, + }); + }); + + return () => { + sub.unsubscribe(); + w.lazyActions.off(LotusWidgetActions.SetAudioOutput, handler); + }; +} diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 9d0d96c3..eef6f987 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -36,6 +36,7 @@ import { startLotusQuality } from "../lotus/lotusQuality"; import { startLotusDecorations } from "../lotus/lotusDecorations"; import { startLotusDenoise } from "../lotus/lotusDenoise"; import { startLotusCallSummary } from "../lotus/lotusCallSummary"; +import { startLotusAudioOutput } from "../lotus/lotusAudioOutput"; import { startLotusDeafen } from "../lotus/lotusDeafen"; import styles from "./InCallView.module.css"; import { GridTile } from "../tile/GridTile"; @@ -315,6 +316,12 @@ export const InCallView: FC = ({ // audio (and optionally screenshare audio) at the LiveKit source. No-op // unless the host sends the action. useEffect(() => startLotusDeafen(), []); + // [lotus #119] Let the host pick the audio output device from its call bar. + const lotusMediaDevices = useMediaDevices(); + useEffect( + () => startLotusAudioOutput(lotusMediaDevices), + [lotusMediaDevices], + ); const fatalCallError = useBehavior(vm.fatalError$); // Stop the rendering and throw for the error boundary