diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index a0ef936b5..05365483f 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -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 ( + { + setCallEmbed(undefined); + }} + /> + ); } /** diff --git a/src/app/features/call/ExpectedDevicesPrompt.tsx b/src/app/features/call/ExpectedDevicesPrompt.tsx new file mode 100644 index 000000000..b80ecb5b3 --- /dev/null +++ b/src/app/features/call/ExpectedDevicesPrompt.tsx @@ -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([]); + 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 ( + }> + + + +
+ + {missing.length === 1 + ? describeMissing(missing[0]).title + : "Your usual audio devices aren't connected"} + +
+ + {missing.map((d) => { + const t = describeMissing(d); + return ( + + {t.expected} isn't connected. Join with {t.fallback} instead? + + ); + })} + + Plug it in and choose Cancel to start over with it. + + + + + + +
+
+
+
+ ); +} diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts index f316e6d69..b151a76f9 100644 --- a/src/app/plugins/call/CallControl.ts +++ b/src/app/plugins/call/CallControl.ts @@ -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); diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index 7eb023032..7d87b4109 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -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') { diff --git a/src/app/plugins/call/expectedDevices.test.ts b/src/app/plugins/call/expectedDevices.test.ts new file mode 100644 index 000000000..5d3db4e29 --- /dev/null +++ b/src/app/plugins/call/expectedDevices.test.ts @@ -0,0 +1,60 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +const store = new Map(); +(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'); +}); diff --git a/src/app/plugins/call/expectedDevices.ts b/src/app/plugins/call/expectedDevices.ts new file mode 100644 index 000000000..136062a37 --- /dev/null +++ b/src/app/plugins/call/expectedDevices.ts @@ -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; + 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 + } +};