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:
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user