feat(call): ask before joining when your usual mic / headphones are missing (#121)

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
This commit is contained in:
Lotus CI
2026-10-24 19:49:50 -04:00
co-authored by Claude Opus 5.5
parent a58684958c
commit 7bd1c14a3c
6 changed files with 288 additions and 1 deletions
+9 -1
View File
@@ -52,6 +52,7 @@ import { useCallEndedToast } from '../hooks/useCallEndedToast';
import { useCallRejoin } from '../hooks/useCallRejoin';
import { usePttHaptics } from '../hooks/usePttHaptics';
import { useScreenshareNotices } from '../hooks/useScreenshareNotices';
import { ExpectedDevicesPrompt } from '../features/call/ExpectedDevicesPrompt';
import { useCallAnnouncements } from '../hooks/useCallAnnouncements';
import { useMutedTalkWarning } from '../hooks/useMutedTalkWarning';
import { callAnnouncementAtom } from '../state/callAnnouncement';
@@ -858,7 +859,14 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
};
}, [joined]);
return null;
return (
<ExpectedDevicesPrompt
embed={embed}
onCancel={() => {
setCallEmbed(undefined);
}}
/>
);
}
/**
@@ -0,0 +1,137 @@
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&apos;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>
);
}
+5
View File
@@ -261,6 +261,11 @@ export class CallControl extends EventEmitter implements CallControlState {
this.emit(CALL_ERROR_CHANGED);
}
/** [Gitea #121] Answer the fork's expected-device check: join or not. */
public sendDeviceDecision(join: boolean): void {
this.call.transport.send('io.lotus.device_decision', { join }).catch(() => undefined);
}
/** Press the fork's Reconnect / Try again. */
public reconnect(): void {
this.call.transport.send('io.lotus.reconnect', {}).catch(() => undefined);
+3
View File
@@ -257,6 +257,9 @@ export class CallEmbed {
// sends io.lotus.inject_audio (only on an explicit user click), so
// arming it for every call is safe.
lotusAudioInject: 'true',
// [Gitea #121] Before joining, the fork checks that the mic / output the
// user last picked are plugged in, and asks us (ExpectedDevicesPrompt).
lotusDeviceCheck: 'true',
});
if (denoiseMode === 'ml') {
@@ -0,0 +1,60 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
const store = new Map<string, string>();
(globalThis as { sessionStorage?: unknown }).sessionStorage = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => {
store.set(k, v);
},
removeItem: (k: string) => {
store.delete(k);
},
};
const m = await import('./expectedDevices');
test('[#121] parses the fork messages defensively', () => {
assert.deepEqual(
m.parseDeviceMissing({
missing: [
{ kind: 'audioinput', expected: 'Arctis 7', fallback: 'Laptop Mic' },
{ kind: 'audiooutput', expected: 'Arctis 7', fallback: null },
{ kind: 'videoinput', expected: 'Cam' },
{ kind: 'audioinput' },
],
}),
[
{ kind: 'audioinput', expected: 'Arctis 7', fallback: 'Laptop Mic' },
{ kind: 'audiooutput', expected: 'Arctis 7', fallback: null },
],
);
assert.deepEqual(m.parseDeviceMissing(undefined), []);
assert.deepEqual(m.parseDeviceMissing({ missing: 'x' }), []);
assert.equal(m.parseDeviceLost({ kind: 'nope' }), undefined);
});
test('[#121] wording', () => {
assert.deepEqual(
m.describeMissing({ kind: 'audioinput', expected: 'Arctis 7', fallback: 'Laptop Mic' }),
{
title: "Your usual microphone isn't connected",
expected: 'Arctis 7',
fallback: 'Laptop Mic',
},
);
assert.equal(
m.describeLost({ kind: 'audiooutput', expected: 'Arctis 7', fallback: null }),
'Arctis 7 disconnected — now using the default output.',
);
});
test('[#121] "Join anyway" is remembered for the session, per device set', () => {
store.clear();
const a = [{ kind: 'audioinput' as const, expected: 'Arctis 7', fallback: 'Laptop Mic' }];
const b = [{ kind: 'audiooutput' as const, expected: 'Arctis 7', fallback: null }];
assert.equal(m.isAcceptedThisSession(a), false);
m.acceptForSession(a);
assert.equal(m.isAcceptedThisSession(a), true);
assert.equal(m.isAcceptedThisSession(b), false, 'a different device still asks');
assert.equal(m.isAcceptedThisSession([...a, ...b]), false, 'more missing still asks');
});
+74
View File
@@ -0,0 +1,74 @@
/**
* [Gitea #121] The fork's expected-device check. Before joining, EC reports
* the user's usual mic / output (the one they last picked) as missing; we ask
* "Join anyway" or "Cancel". In a call, losing it is a toast. Pure helpers.
*/
export type DeviceKind = 'audioinput' | 'audiooutput';
export type MissingDevice = { kind: DeviceKind; expected: string; fallback: string | null };
const isKind = (k: unknown): k is DeviceKind => k === 'audioinput' || k === 'audiooutput';
const parseOne = (v: unknown): MissingDevice | undefined => {
if (!v || typeof v !== 'object') return undefined;
const { kind, expected, fallback } = v as Record<string, unknown>;
if (!isKind(kind) || typeof expected !== 'string' || !expected) return undefined;
return { kind, expected, fallback: typeof fallback === 'string' && fallback ? fallback : null };
};
/** `io.lotus.device_missing` → the missing devices (empty if malformed). */
export const parseDeviceMissing = (data: unknown): MissingDevice[] => {
const list = (data as { missing?: unknown } | undefined)?.missing;
if (!Array.isArray(list)) return [];
return list.map(parseOne).filter((d): d is MissingDevice => !!d);
};
/** `io.lotus.device_lost` → the device that went away. */
export const parseDeviceLost = (data: unknown): MissingDevice | undefined => parseOne(data);
const what = (kind: DeviceKind): string => (kind === 'audioinput' ? 'microphone' : 'audio output');
/** "Your usual microphone **Arctis 7** isn't connected." lines + the fallback. */
export const describeMissing = (
d: MissingDevice,
): { title: string; expected: string; fallback: string } => ({
title: `Your usual ${what(d.kind)} isn't connected`,
expected: d.expected,
fallback:
d.fallback ?? (d.kind === 'audioinput' ? 'the default microphone' : 'the default output'),
});
export const describeLost = (d: MissingDevice): string =>
`${d.expected} disconnected — now using ${
d.fallback ?? (d.kind === 'audioinput' ? 'the default microphone' : 'the default output')
}.`;
// "Join anyway" holds for the rest of the session for the same device(s).
const ACCEPTED_KEY = 'lotus-device-check-accepted';
const keyOf = (missing: MissingDevice[]): string =>
missing
.map((d) => `${d.kind}:${d.expected}`)
.sort()
.join('|');
const readAccepted = (): string[] => {
try {
const v: unknown = JSON.parse(sessionStorage.getItem(ACCEPTED_KEY) ?? '[]');
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
} catch {
return [];
}
};
export const isAcceptedThisSession = (missing: MissingDevice[]): boolean =>
readAccepted().includes(keyOf(missing));
export const acceptForSession = (missing: MissingDevice[]): void => {
try {
const list = readAccepted();
const k = keyOf(missing);
if (!list.includes(k)) sessionStorage.setItem(ACCEPTED_KEY, JSON.stringify([...list, k]));
} catch {
// storage unavailable: ask again next time
}
};