feat(calls): audio output quick-switch in the call bar (#119)

Verified first: the embed hides Element Call's footer, so its in-call
settings sheet (which has the output picker) is unreachable from Lotus;
the cinny call bar had no output control.

Now a speaker button next to Deafen (desktop bar only; hidden where
setSinkId is unavailable — Firefox, Safari, Android Chrome) opens a menu
of enumerateDevices() audio outputs with the current one checked; picking
one sends io.lotus.set_audio_output to the fork (≥ 0.25.0-lotus.11),
which selects it, and the choice is re-sent with the rest of the sticky
fork state after an EC remount.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-20 15:31:47 -04:00
co-authored by Claude Opus 5
parent ad1cbcf792
commit 98c80f36cb
3 changed files with 173 additions and 0 deletions
@@ -0,0 +1,150 @@
import React, { useCallback, useEffect, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Box,
Icon,
IconButton,
Icons,
Menu,
MenuItem,
PopOut,
RectCords,
Text,
Tooltip,
TooltipProvider,
config,
toRem,
} from 'folds';
import { CallEmbed } from '../../plugins/call';
import { MobileTouchTarget } from '../../styles/mobile.css';
import { stopPropagation } from '../../utils/keyboard';
/** Output selection needs setSinkId; Firefox/Safari/Android Chrome lack it. */
export const audioOutputSelectable = (): boolean =>
typeof HTMLMediaElement !== 'undefined' &&
'setSinkId' in HTMLMediaElement.prototype &&
typeof navigator !== 'undefined' &&
!!navigator.mediaDevices?.enumerateDevices;
type OutputDevice = { id: string; label: string };
/**
* [Gitea #119] Speaker button in the call bar: a small menu of audio outputs
* (headset ↔ speakers) without opening Settings. The choice goes to the fork
* as io.lotus.set_audio_output; the fork's own picker is unreachable here
* because the embed hides Element Call's footer.
*/
export function AudioOutputButton({ embed, disabled }: { embed: CallEmbed; disabled?: boolean }) {
const [anchor, setAnchor] = useState<RectCords>();
const [devices, setDevices] = useState<OutputDevice[]>([]);
const [selected, setSelected] = useState<string | undefined>(embed.control.audioOutputId);
const refresh = useCallback(async () => {
try {
const all = await navigator.mediaDevices.enumerateDevices();
setDevices(
all
.filter((d) => d.kind === 'audiooutput')
.map((d, i) => ({ id: d.deviceId, label: d.label || `Output ${i + 1}` })),
);
} catch {
setDevices([]);
}
}, []);
useEffect(() => {
if (!anchor) return undefined;
refresh();
navigator.mediaDevices.addEventListener('devicechange', refresh);
return () => navigator.mediaDevices.removeEventListener('devicechange', refresh);
}, [anchor, refresh]);
const choose = (id: string) => {
embed.control.setAudioOutput(id);
setSelected(id);
setAnchor(undefined);
};
return (
<PopOut
anchor={anchor}
position="Top"
align="Center"
offset={6}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
// The list is enumerated after the menu opens; until then the only
// tabbable node is the menu itself.
fallbackFocus: '#call-audio-output-menu',
onDeactivate: () => setAnchor(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu
id="call-audio-output-menu"
tabIndex={-1}
style={{ maxWidth: toRem(280), width: '100vw' }}
aria-label="Audio output"
>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{devices.length === 0 && (
<Text size="T200" priority="300" style={{ padding: config.space.S200 }}>
No audio outputs found.
</Text>
)}
{devices.map((d) => {
const isSelected = selected ? d.id === selected : d.id === 'default';
return (
<MenuItem
key={d.id}
size="300"
radii="300"
role="menuitemradio"
aria-checked={isSelected}
after={isSelected ? <Icon size="100" src={Icons.Check} /> : undefined}
onClick={() => choose(d.id)}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{d.label}
</Text>
</MenuItem>
);
})}
</Box>
</Menu>
</FocusTrap>
}
>
<TooltipProvider
position="Top"
tooltip={
<Tooltip>
<Text size="T200">Audio output</Text>
</Tooltip>
}
>
{(tipRef) => (
<IconButton
ref={tipRef}
variant="Surface"
fill="Soft"
radii="300"
size="300"
className={MobileTouchTarget}
outlined
disabled={disabled}
aria-label="Audio output"
aria-haspopup="menu"
aria-expanded={!!anchor}
onClick={(e) => setAnchor(e.currentTarget.getBoundingClientRect())}
>
<Icon size="100" src={Icons.VolumeHigh} />
</IconButton>
)}
</TooltipProvider>
</PopOut>
);
}
@@ -8,6 +8,7 @@ import { callEmbedAtom } from '../../state/callEmbed';
import { MobileTouchTarget } from '../../styles/mobile.css';
import { useRoomCallPolicy } from '../../hooks/useRoomCallPolicy';
import { ScreenshareConfirm } from '../call/ScreenshareConfirm';
import { AudioOutputButton, audioOutputSelectable } from './AudioOutputButton';
type MicrophoneButtonProps = {
enabled: boolean;
@@ -230,6 +231,9 @@ export function CallControl({
onToggle={() => callEmbed.control.toggleSound()}
disabled={!callJoined}
/>
{!compact && audioOutputSelectable() && (
<AudioOutputButton embed={callEmbed} disabled={!callJoined} />
)}
{!compact && (showCamera || showScreenshare) && <StatusDivider />}
{showCamera && (
<VideoButton enabled={video} onToggle={handleVideoToggle} disabled={!callJoined} />
+19
View File
@@ -191,6 +191,7 @@ export class CallControl extends EventEmitter implements CallControlState {
public resendForkState(): void {
this.sendDeafenState();
this.sendQuality();
if (this._audioOutputId !== undefined) this.sendAudioOutput(this._audioOutputId);
// [Gitea #17] The pin lives fork-side and is dropped on a handler remount.
if (this._focusedUserId !== null) this.sendFocus(this._focusedUserId, this._focusedMediaId);
}
@@ -277,6 +278,24 @@ export class CallControl extends EventEmitter implements CallControlState {
// P6-2: send deafen state to the fork (io.lotus.set_deafen). Join-gated: the
// fork's handler only exists once joined; onCallJoined() re-sends the current
// state so a pre-join deafen is not lost.
// [Gitea #119] Output device (headset ↔ speakers) chosen from the host's
// call bar; the fork applies it with mediaDevices.audioOutput.select().
private _audioOutputId: string | undefined;
public get audioOutputId(): string | undefined {
return this._audioOutputId;
}
public setAudioOutput(deviceId: string): void {
this._audioOutputId = deviceId;
this.sendAudioOutput(deviceId);
}
private sendAudioOutput(deviceId: string): void {
if (!this.joined) return;
this.call.transport.send('io.lotus.set_audio_output', { deviceId }).catch(() => undefined);
}
private sendDeafenState(): void {
if (!this.joined) return;
this.call.transport