The fork now checks, before joining (lotusDeviceCheck=1), that the mic /
output the user last picked are plugged in. When one is missing it asks
us (io.lotus.device_missing): "Your usual microphone isn't connected.
Arctis 7 isn't connected. Join with Laptop Mic instead?" [Join anyway]
[Cancel]. Join anyway isn't asked again this session for the same
device(s); Cancel closes the call before it starts. A device lost during
a call is a toast ("Arctis 7 disconnected — now using Laptop Mic").
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
138 lines
4.1 KiB
TypeScript
138 lines
4.1 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import FocusTrap from 'focus-trap-react';
|
|
import { useSetAtom } from 'jotai';
|
|
import {
|
|
Box,
|
|
Button,
|
|
Dialog,
|
|
Header,
|
|
Icons,
|
|
Overlay,
|
|
OverlayBackdrop,
|
|
OverlayCenter,
|
|
Text,
|
|
config,
|
|
} from 'folds';
|
|
import { CallEmbed } from '../../plugins/call';
|
|
import {
|
|
MissingDevice,
|
|
acceptForSession,
|
|
describeLost,
|
|
describeMissing,
|
|
isAcceptedThisSession,
|
|
parseDeviceLost,
|
|
parseDeviceMissing,
|
|
} from '../../plugins/call/expectedDevices';
|
|
import { toastQueueAtom } from '../../state/toast';
|
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
|
|
|
/**
|
|
* [Gitea #121] "Your usual headset isn't connected": the fork holds the join
|
|
* until we answer. Join anyway (not asked again this session for the same
|
|
* device) or Cancel (closes the call before it starts). A device lost mid-call
|
|
* is only a toast.
|
|
*/
|
|
export function ExpectedDevicesPrompt({
|
|
embed,
|
|
onCancel,
|
|
}: {
|
|
embed: CallEmbed;
|
|
onCancel: () => void;
|
|
}) {
|
|
const [missing, setMissing] = useState<MissingDevice[]>([]);
|
|
const setToast = useSetAtom(toastQueueAtom);
|
|
const modalStyle = useModalStyle(440);
|
|
|
|
useEffect(() => {
|
|
const offMissing = embed.listenAction<{ data?: unknown }>('io.lotus.device_missing', (evt) => {
|
|
const list = parseDeviceMissing(evt.detail?.data);
|
|
if (list.length === 0) return;
|
|
if (isAcceptedThisSession(list)) {
|
|
embed.control.sendDeviceDecision(true);
|
|
return;
|
|
}
|
|
setMissing(list);
|
|
});
|
|
const offLost = embed.listenAction<{ data?: unknown }>('io.lotus.device_lost', (evt) => {
|
|
const lost = parseDeviceLost(evt.detail?.data);
|
|
if (!lost) return;
|
|
setToast({
|
|
id: `device-lost-${lost.kind}-${Date.now()}`,
|
|
iconSrc: lost.kind === 'audioinput' ? Icons.Mic : Icons.VolumeHigh,
|
|
displayName: 'Audio device',
|
|
body: describeLost(lost),
|
|
roomName: embed.room.name ?? 'Voice call',
|
|
roomId: embed.roomId,
|
|
});
|
|
});
|
|
return () => {
|
|
offMissing();
|
|
offLost();
|
|
};
|
|
}, [embed, setToast]);
|
|
|
|
if (missing.length === 0) return null;
|
|
|
|
const joinAnyway = () => {
|
|
acceptForSession(missing);
|
|
embed.control.sendDeviceDecision(true);
|
|
setMissing([]);
|
|
};
|
|
const cancel = () => {
|
|
embed.control.sendDeviceDecision(false);
|
|
setMissing([]);
|
|
onCancel();
|
|
};
|
|
|
|
return (
|
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
|
<OverlayCenter>
|
|
<FocusTrap focusTrapOptions={{ initialFocus: false, escapeDeactivates: false }}>
|
|
<Dialog
|
|
variant="Surface"
|
|
role="alertdialog"
|
|
aria-labelledby="expected-devices-title"
|
|
style={modalStyle}
|
|
>
|
|
<Header
|
|
variant="Surface"
|
|
size="500"
|
|
style={{
|
|
padding: `0 ${config.space.S400}`,
|
|
borderBottomWidth: config.borderWidth.B300,
|
|
}}
|
|
>
|
|
<Text id="expected-devices-title" as="h2" size="H4">
|
|
{missing.length === 1
|
|
? describeMissing(missing[0]).title
|
|
: "Your usual audio devices aren't connected"}
|
|
</Text>
|
|
</Header>
|
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
|
{missing.map((d) => {
|
|
const t = describeMissing(d);
|
|
return (
|
|
<Text key={d.kind} size="T300">
|
|
<b>{t.expected}</b> isn't connected. Join with <b>{t.fallback}</b> instead?
|
|
</Text>
|
|
);
|
|
})}
|
|
<Text size="T200" priority="300">
|
|
Plug it in and choose Cancel to start over with it.
|
|
</Text>
|
|
<Box direction="Column" gap="200">
|
|
<Button variant="Primary" onClick={joinAnyway}>
|
|
<Text size="B400">Join anyway</Text>
|
|
</Button>
|
|
<Button variant="Secondary" fill="Soft" onClick={cancel}>
|
|
<Text size="B400">Cancel</Text>
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
</Dialog>
|
|
</FocusTrap>
|
|
</OverlayCenter>
|
|
</Overlay>
|
|
);
|
|
}
|