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 { MobileTouchTarget } from '../../styles/mobile.css';
|
||||||
import { useRoomCallPolicy } from '../../hooks/useRoomCallPolicy';
|
import { useRoomCallPolicy } from '../../hooks/useRoomCallPolicy';
|
||||||
import { ScreenshareConfirm } from '../call/ScreenshareConfirm';
|
import { ScreenshareConfirm } from '../call/ScreenshareConfirm';
|
||||||
|
import { AudioOutputButton, audioOutputSelectable } from './AudioOutputButton';
|
||||||
|
|
||||||
type MicrophoneButtonProps = {
|
type MicrophoneButtonProps = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -230,6 +231,9 @@ export function CallControl({
|
|||||||
onToggle={() => callEmbed.control.toggleSound()}
|
onToggle={() => callEmbed.control.toggleSound()}
|
||||||
disabled={!callJoined}
|
disabled={!callJoined}
|
||||||
/>
|
/>
|
||||||
|
{!compact && audioOutputSelectable() && (
|
||||||
|
<AudioOutputButton embed={callEmbed} disabled={!callJoined} />
|
||||||
|
)}
|
||||||
{!compact && (showCamera || showScreenshare) && <StatusDivider />}
|
{!compact && (showCamera || showScreenshare) && <StatusDivider />}
|
||||||
{showCamera && (
|
{showCamera && (
|
||||||
<VideoButton enabled={video} onToggle={handleVideoToggle} disabled={!callJoined} />
|
<VideoButton enabled={video} onToggle={handleVideoToggle} disabled={!callJoined} />
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
public resendForkState(): void {
|
public resendForkState(): void {
|
||||||
this.sendDeafenState();
|
this.sendDeafenState();
|
||||||
this.sendQuality();
|
this.sendQuality();
|
||||||
|
if (this._audioOutputId !== undefined) this.sendAudioOutput(this._audioOutputId);
|
||||||
// [Gitea #17] The pin lives fork-side and is dropped on a handler remount.
|
// [Gitea #17] The pin lives fork-side and is dropped on a handler remount.
|
||||||
if (this._focusedUserId !== null) this.sendFocus(this._focusedUserId, this._focusedMediaId);
|
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
|
// 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
|
// fork's handler only exists once joined; onCallJoined() re-sends the current
|
||||||
// state so a pre-join deafen is not lost.
|
// 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 {
|
private sendDeafenState(): void {
|
||||||
if (!this.joined) return;
|
if (!this.joined) return;
|
||||||
this.call.transport
|
this.call.transport
|
||||||
|
|||||||
Reference in New Issue
Block a user