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
151 lines
4.6 KiB
TypeScript
151 lines
4.6 KiB
TypeScript
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>
|
|
);
|
|
}
|