From 4a48ec98d02a2c1b409e1648e936abf1550e4a1b Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sun, 20 Sep 2026 15:31:33 -0400 Subject: [PATCH] =?UTF-8?q?feat(lotus):=20io.lotus.set=5Faudio=5Foutput=20?= =?UTF-8?q?=E2=80=94=20host-driven=20output=20device=20switch=20(#119)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The embed hides Element Call's footer, so its own output picker is out of reach; the host's call bar now sends io.lotus.set_audio_output { deviceId } and the fork applies it via mediaDevices.audioOutput.select(), answering with io.lotus.audio_output_state { deviceId } on every change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/lotus/lotusActions.test.ts | 1 + src/lotus/lotusActions.ts | 5 ++++ src/lotus/lotusAudioOutput.ts | 46 ++++++++++++++++++++++++++++++++++ src/room/InCallView.tsx | 7 ++++++ 4 files changed, 59 insertions(+) create mode 100644 src/lotus/lotusAudioOutput.ts 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