deviceIds are hashed per frame/origin, so the ids the menu got from the chat page's enumerateDevices() were unknown inside the call iframe: the fork fell back to the default output while the menu showed a tick on the chosen device. Handle io.lotus.audio_output_state (also silencing the fork's "host did not ack" log) and build the menu from the fork's list and effective selection; older forks fall back to the old list. Fixes #310 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
170 lines
5.3 KiB
TypeScript
170 lines
5.3 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 { AUDIO_OUTPUT_STATE } from '../../plugins/call/CallControl';
|
|
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);
|
|
|
|
// [Gitea #310] Prefer the fork's list: its ids are the ones valid inside
|
|
// the call iframe. Older forks don't report one; fall back to our own.
|
|
const [forkDevices, setForkDevices] = useState(embed.control.audioOutputs);
|
|
useEffect(() => {
|
|
const onState = () => {
|
|
setForkDevices(embed.control.audioOutputs);
|
|
setSelected(embed.control.audioOutputId);
|
|
};
|
|
onState();
|
|
embed.control.on(AUDIO_OUTPUT_STATE, onState);
|
|
return () => {
|
|
embed.control.off(AUDIO_OUTPUT_STATE, onState);
|
|
};
|
|
}, [embed]);
|
|
|
|
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 || forkDevices) return undefined;
|
|
refresh();
|
|
navigator.mediaDevices.addEventListener('devicechange', refresh);
|
|
return () => navigator.mediaDevices.removeEventListener('devicechange', refresh);
|
|
}, [anchor, refresh, forkDevices]);
|
|
|
|
const shown = forkDevices ?? devices;
|
|
|
|
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 }}>
|
|
{shown.length === 0 && (
|
|
<Text size="T200" priority="300" style={{ padding: config.space.S200 }}>
|
|
No audio outputs found.
|
|
</Text>
|
|
)}
|
|
{shown.map((d) => {
|
|
const isSelected =
|
|
selected !== undefined ? d.id === selected : d.id === 'default' || d.id === '';
|
|
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>
|
|
);
|
|
}
|