Compare commits
3 Commits
pre-phase8
...
v0.20.0
| Author | SHA1 | Date | |
|---|---|---|---|
| d803773261 | |||
| 49855c5d3f | |||
| c9a64bf898 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@chat-app/desktop",
|
"name": "@chat-app/desktop",
|
||||||
"version": "0.19.1",
|
"version": "0.20.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Electron desktop client (Windows / macOS / Linux)",
|
"description": "Electron desktop client (Windows / macOS / Linux)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
|
||||||
import { useCall } from '../context/CallContext';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
conversation: ConversationSummary;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STALE_AFTER_MS = 5000;
|
|
||||||
|
|
||||||
/** Discord-style live-captions overlay. Pinned to the bottom-center of the
|
|
||||||
* call surface; renders the most recent caption per participant, fading
|
|
||||||
* entries out after `STALE_AFTER_MS` of silence. Self-captions are shown
|
|
||||||
* too so the speaker can sanity-check what's being broadcast. */
|
|
||||||
export function CallCaptionsOverlay({ conversation }: Props) {
|
|
||||||
const { captions } = useCall();
|
|
||||||
// Re-render every second so stale entries fade without needing the data
|
|
||||||
// channel to fire — captions module just stores timestamps.
|
|
||||||
const [, setNow] = useState(Date.now());
|
|
||||||
useEffect(() => {
|
|
||||||
const id = window.setInterval(() => setNow(Date.now()), 1000);
|
|
||||||
return () => window.clearInterval(id);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const visible = Object.entries(captions)
|
|
||||||
.filter(([, v]) => now - v.timestamp < STALE_AFTER_MS)
|
|
||||||
.sort(([, a], [, b]) => a.timestamp - b.timestamp);
|
|
||||||
|
|
||||||
if (visible.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-24 z-30 flex flex-col items-center gap-1.5 px-6">
|
|
||||||
{visible.map(([identity, c]) => {
|
|
||||||
const member = conversation.members.find((m) => m.userId === identity);
|
|
||||||
const name = member?.profile?.displayName ?? '?';
|
|
||||||
const age = now - c.timestamp;
|
|
||||||
const opacity = age > 3500 ? 1 - (age - 3500) / (STALE_AFTER_MS - 3500) : 1;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={identity}
|
|
||||||
style={{ opacity: Math.max(0, opacity) }}
|
|
||||||
className="max-w-[760px] rounded-lg bg-black/72 px-3.5 py-1.5 text-sm text-white shadow-md backdrop-blur-md transition-opacity"
|
|
||||||
>
|
|
||||||
<span className="mr-2 text-[11px] font-semibold uppercase tracking-wide text-white/55">
|
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
<span className={c.final ? '' : 'italic text-white/85'}>{c.text}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CaptionsIcon,
|
|
||||||
HeadphonesIcon,
|
HeadphonesIcon,
|
||||||
HeadphonesOffIcon,
|
HeadphonesOffIcon,
|
||||||
MicIcon,
|
MicIcon,
|
||||||
@@ -32,10 +31,6 @@ interface Props {
|
|||||||
/** Toggle the in-call soundboard popover. Active = panel currently open. */
|
/** Toggle the in-call soundboard popover. Active = panel currently open. */
|
||||||
onToggleSoundboard?: () => void;
|
onToggleSoundboard?: () => void;
|
||||||
soundboardOpen?: boolean;
|
soundboardOpen?: boolean;
|
||||||
/** Discord-style live-captions toggle. Optional — pages that don't support
|
|
||||||
* SpeechRecognition (Firefox) skip the prop and the button is hidden. */
|
|
||||||
onToggleCaptions?: () => void;
|
|
||||||
captionsOn?: boolean;
|
|
||||||
participantsOpen?: boolean;
|
participantsOpen?: boolean;
|
||||||
/** Compact variant used inside the docked call (36px buttons). */
|
/** Compact variant used inside the docked call (36px buttons). */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
@@ -59,8 +54,6 @@ export function CallControls({
|
|||||||
onOpenParticipants,
|
onOpenParticipants,
|
||||||
onToggleSoundboard,
|
onToggleSoundboard,
|
||||||
soundboardOpen = false,
|
soundboardOpen = false,
|
||||||
onToggleCaptions,
|
|
||||||
captionsOn = false,
|
|
||||||
participantsOpen = false,
|
participantsOpen = false,
|
||||||
compact = false,
|
compact = false,
|
||||||
glass = false,
|
glass = false,
|
||||||
@@ -148,22 +141,6 @@ export function CallControls({
|
|||||||
<MusicIcon className="h-5 w-5" />
|
<MusicIcon className="h-5 w-5" />
|
||||||
</CallButton>
|
</CallButton>
|
||||||
)}
|
)}
|
||||||
{onToggleCaptions && (
|
|
||||||
<CallButton
|
|
||||||
label={
|
|
||||||
captionsOn
|
|
||||||
? t('app:call.captions_off', { defaultValue: 'Untertitel aus' })
|
|
||||||
: t('app:call.captions_on', { defaultValue: 'Untertitel an' })
|
|
||||||
}
|
|
||||||
active={captionsOn}
|
|
||||||
activeTone="accent"
|
|
||||||
onClick={onToggleCaptions}
|
|
||||||
glass={glass}
|
|
||||||
className={btnSize}
|
|
||||||
>
|
|
||||||
<CaptionsIcon className="h-5 w-5" />
|
|
||||||
</CallButton>
|
|
||||||
)}
|
|
||||||
{onOpenParticipants && (
|
{onOpenParticipants && (
|
||||||
<CallButton
|
<CallButton
|
||||||
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
|
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
|
||||||
|
|||||||
@@ -15,14 +15,7 @@ import {
|
|||||||
listSounds,
|
listSounds,
|
||||||
subscribeSoundboardChanges,
|
subscribeSoundboardChanges,
|
||||||
} from '../lib/soundboardStorage';
|
} from '../lib/soundboardStorage';
|
||||||
import {
|
|
||||||
getLiveCaptionsSettings,
|
|
||||||
isLiveCaptionsSupported,
|
|
||||||
subscribeLiveCaptionsSettings,
|
|
||||||
updateLiveCaptionsSettings,
|
|
||||||
} from '../lib/liveCaptions';
|
|
||||||
import { useActiveSpeakers } from '../lib/useActiveSpeakers';
|
import { useActiveSpeakers } from '../lib/useActiveSpeakers';
|
||||||
import { CallCaptionsOverlay } from './CallCaptionsOverlay';
|
|
||||||
import { CallControls } from './CallControls';
|
import { CallControls } from './CallControls';
|
||||||
import { CallParticipantTile, type TileConnectionQuality } from './CallParticipantTile';
|
import { CallParticipantTile, type TileConnectionQuality } from './CallParticipantTile';
|
||||||
import { CallStatsOverlay } from './CallStatsOverlay';
|
import { CallStatsOverlay } from './CallStatsOverlay';
|
||||||
@@ -119,17 +112,6 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
const [sharePickerOpen, setSharePickerOpen] = useState(false);
|
const [sharePickerOpen, setSharePickerOpen] = useState(false);
|
||||||
// Discord-style debug stats overlay (Ctrl+Shift+S toggles).
|
// Discord-style debug stats overlay (Ctrl+Shift+S toggles).
|
||||||
const [statsOverlayOpen, setStatsOverlayOpen] = useState(false);
|
const [statsOverlayOpen, setStatsOverlayOpen] = useState(false);
|
||||||
// Live-captions toggle — mirror of getLiveCaptionsSettings().enabled so the
|
|
||||||
// controls bar can show an "active" state without polling. Captions
|
|
||||||
// broadcasting is wired in CallContext via useLiveCaptions; this only
|
|
||||||
// tracks the toggle state for the button.
|
|
||||||
const [captionsEnabled, setCaptionsEnabled] = useState<boolean>(
|
|
||||||
() => getLiveCaptionsSettings().enabled,
|
|
||||||
);
|
|
||||||
useEffect(
|
|
||||||
() => subscribeLiveCaptionsSettings((s) => setCaptionsEnabled(s.enabled)),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
// Match the modifier exactly to avoid clobbering other Ctrl+Shift combos.
|
// Match the modifier exactly to avoid clobbering other Ctrl+Shift combos.
|
||||||
@@ -362,15 +344,6 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
soundboardOpen,
|
soundboardOpen,
|
||||||
}
|
}
|
||||||
: {})}
|
: {})}
|
||||||
// Live-Captions only when SpeechRecognition is available in the
|
|
||||||
// runtime — Firefox lacks it, would just show a dead button.
|
|
||||||
{...(isLiveCaptionsSupported()
|
|
||||||
? {
|
|
||||||
onToggleCaptions: () =>
|
|
||||||
updateLiveCaptionsSettings({ enabled: !captionsEnabled }),
|
|
||||||
captionsOn: captionsEnabled,
|
|
||||||
}
|
|
||||||
: {})}
|
|
||||||
onHangup={() => void hangup()}
|
onHangup={() => void hangup()}
|
||||||
compact={callMode !== 'fullscreen'}
|
compact={callMode !== 'fullscreen'}
|
||||||
glass={callMode === 'fullscreen'}
|
glass={callMode === 'fullscreen'}
|
||||||
@@ -499,7 +472,6 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
onClose={() => setStatsOverlayOpen(false)}
|
onClose={() => setStatsOverlayOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{captionsEnabled && <CallCaptionsOverlay conversation={conversation} />}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -644,8 +616,6 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
onClose={() => setStatsOverlayOpen(false)}
|
onClose={() => setStatsOverlayOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{captionsEnabled && <CallCaptionsOverlay conversation={conversation} />}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,17 +160,6 @@ function EyeIconInner(props: IconProps) {
|
|||||||
}
|
}
|
||||||
export const EyeIcon = memo(EyeIconInner);
|
export const EyeIcon = memo(EyeIconInner);
|
||||||
|
|
||||||
function CaptionsIconInner(props: IconProps) {
|
|
||||||
return (
|
|
||||||
<Base {...props}>
|
|
||||||
<rect x="3" y="6" width="18" height="12" rx="2" />
|
|
||||||
<path d="M7 13a2 2 0 1 1 0-2" />
|
|
||||||
<path d="M14 13a2 2 0 1 1 0-2" />
|
|
||||||
</Base>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
export const CaptionsIcon = memo(CaptionsIconInner);
|
|
||||||
|
|
||||||
function PinOffIconInner(props: IconProps) {
|
function PinOffIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
|
|||||||
@@ -207,9 +207,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
// Pre-warm Supabase: fires the first round-trip in the background so the
|
// Pre-warm Supabase: fires the first round-trip in the background so the
|
||||||
// first user-triggered query (e.g. loading conversations) doesn't pay
|
// first user-triggered query (e.g. loading conversations) doesn't pay
|
||||||
// the cold-connection latency.
|
// the cold-connection latency.
|
||||||
|
//
|
||||||
|
// Uses auth.getSession() instead of a `profiles` SELECT because the
|
||||||
|
// SELECT race-fired before the supabase client committed its JWT to
|
||||||
|
// request headers, causing a 400 from PostgREST on app boot. Auth
|
||||||
|
// endpoints don't depend on RLS and tolerate the race.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
void supabase.from('profiles').select('id').limit(1).then(() => undefined);
|
void supabase.auth.getSession();
|
||||||
}, [session]);
|
}, [session]);
|
||||||
|
|
||||||
// Phase 3: ensure this install owns exactly one devices row. The row is
|
// Phase 3: ensure this install owns exactly one devices row. The row is
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ import {
|
|||||||
playUndeafenBeep,
|
playUndeafenBeep,
|
||||||
playUnmuteBeep,
|
playUnmuteBeep,
|
||||||
} from '../lib/callSounds';
|
} from '../lib/callSounds';
|
||||||
import { useLiveCaptions } from '../lib/useLiveCaptions';
|
|
||||||
import { setCallWakeLock } from '../lib/wakeLock';
|
import { setCallWakeLock } from '../lib/wakeLock';
|
||||||
import { notify } from '../lib/osNotify';
|
import { notify } from '../lib/osNotify';
|
||||||
import {
|
import {
|
||||||
@@ -209,14 +208,6 @@ interface CallContextValue {
|
|||||||
* invite (fromUserId). Cleared on disconnect. Drives the crown badge,
|
* invite (fromUserId). Cleared on disconnect. Drives the crown badge,
|
||||||
* but only in group calls. Null while idle or in 1:1 contexts. */
|
* but only in group calls. Null while idle or in 1:1 contexts. */
|
||||||
callHostId: string | null;
|
callHostId: string | null;
|
||||||
/** identity -> latest live-caption fragment received via data channel.
|
|
||||||
* Includes own captions for self-overlay. Receivers prune entries whose
|
|
||||||
* timestamp is older than ~5s so stale lines fade out. */
|
|
||||||
captions: Record<string, { text: string; final: boolean; timestamp: number }>;
|
|
||||||
/** Surface a caption for the local user — the live-captions hook calls
|
|
||||||
* this on every interim/final SpeechRecognition result so the overlay
|
|
||||||
* shows our own line without going through the SFU round-trip. */
|
|
||||||
pushLocalCaption: (text: string, final: boolean) => void;
|
|
||||||
/** identity -> mute state. Broadcast from peer whenever mic-gain flips.
|
/** identity -> mute state. Broadcast from peer whenever mic-gain flips.
|
||||||
* Needed because we can't rely on LiveKit's native isMicrophoneEnabled —
|
* Needed because we can't rely on LiveKit's native isMicrophoneEnabled —
|
||||||
* the mic pipeline keeps the track published with sound flowing even
|
* the mic pipeline keeps the track published with sound flowing even
|
||||||
@@ -347,9 +338,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
// useEffect) so peers don't hear themselves echoed back when the OS-level
|
// useEffect) so peers don't hear themselves echoed back when the OS-level
|
||||||
// process-tree exclusion isn't watertight.
|
// process-tree exclusion isn't watertight.
|
||||||
const [isCapturingSystemAudio, setIsCapturingSystemAudio] = useState(false);
|
const [isCapturingSystemAudio, setIsCapturingSystemAudio] = useState(false);
|
||||||
const [captions, setCaptions] = useState<
|
|
||||||
Record<string, { text: string; final: boolean; timestamp: number }>
|
|
||||||
>({});
|
|
||||||
const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null);
|
const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null);
|
||||||
const [callMode, setCallModeState] = useState<CallMode>('grid');
|
const [callMode, setCallModeState] = useState<CallMode>('grid');
|
||||||
const [focusedId, setFocusedIdState] = useState<string | null>(null);
|
const [focusedId, setFocusedIdState] = useState<string | null>(null);
|
||||||
@@ -828,7 +816,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
setRemoteScreenShares([]);
|
setRemoteScreenShares([]);
|
||||||
setConnectionQualities({});
|
setConnectionQualities({});
|
||||||
setCallHostId(null);
|
setCallHostId(null);
|
||||||
setCaptions({});
|
|
||||||
setIsScreenSharing(false);
|
setIsScreenSharing(false);
|
||||||
setIsE2EEActive(false);
|
setIsE2EEActive(false);
|
||||||
}
|
}
|
||||||
@@ -970,8 +957,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
type?: string;
|
type?: string;
|
||||||
deafened?: boolean;
|
deafened?: boolean;
|
||||||
muted?: boolean;
|
muted?: boolean;
|
||||||
captionText?: string;
|
|
||||||
captionFinal?: boolean;
|
|
||||||
};
|
};
|
||||||
const id: string = participant.identity;
|
const id: string = participant.identity;
|
||||||
if (msg.type === 'presence') {
|
if (msg.type === 'presence') {
|
||||||
@@ -991,15 +976,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (msg.type === 'caption' && typeof msg.captionText === 'string') {
|
|
||||||
const text2 = msg.captionText;
|
|
||||||
const final = msg.captionFinal === true;
|
|
||||||
setCaptions((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[id]: { text: text2, final, timestamp: Date.now() },
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore malformed */
|
/* ignore malformed */
|
||||||
}
|
}
|
||||||
@@ -1754,17 +1730,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const pushLocalCaption = useCallback(
|
|
||||||
(text: string, final: boolean) => {
|
|
||||||
if (!myId) return;
|
|
||||||
setCaptions((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[myId]: { text, final, timestamp: Date.now() },
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
[myId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggleCamera = useCallback(async () => {
|
const toggleCamera = useCallback(async () => {
|
||||||
const r = roomRef.current;
|
const r = roomRef.current;
|
||||||
if (!r) return;
|
if (!r) return;
|
||||||
@@ -2603,15 +2568,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
}, [callMode, state.kind]);
|
}, [callMode, state.kind]);
|
||||||
|
|
||||||
// Discord-style live-captions broadcaster — runs on the local mic while
|
|
||||||
// we're connected, and ships interim/final transcripts on the LiveKit
|
|
||||||
// DataChannel so peers can render them.
|
|
||||||
useLiveCaptions({
|
|
||||||
room,
|
|
||||||
active: state.kind === 'connected' || state.kind === 'reconnecting',
|
|
||||||
onLocalCaption: pushLocalCaption,
|
|
||||||
});
|
|
||||||
|
|
||||||
const value = useMemo<CallContextValue>(
|
const value = useMemo<CallContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
state,
|
state,
|
||||||
@@ -2626,8 +2582,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
remoteMute,
|
remoteMute,
|
||||||
connectionQualities,
|
connectionQualities,
|
||||||
callHostId,
|
callHostId,
|
||||||
captions,
|
|
||||||
pushLocalCaption,
|
|
||||||
remoteScreenShares,
|
remoteScreenShares,
|
||||||
lastCallConversationId,
|
lastCallConversationId,
|
||||||
callMode,
|
callMode,
|
||||||
@@ -2683,8 +2637,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
remoteMute,
|
remoteMute,
|
||||||
connectionQualities,
|
connectionQualities,
|
||||||
callHostId,
|
callHostId,
|
||||||
captions,
|
|
||||||
pushLocalCaption,
|
|
||||||
remoteScreenShares,
|
remoteScreenShares,
|
||||||
lastCallConversationId,
|
lastCallConversationId,
|
||||||
callMode,
|
callMode,
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
// Discord-style live captions. Uses the browser's SpeechRecognition API to
|
|
||||||
// transcribe the LOCAL user's mic, then broadcasts each interim/final result
|
|
||||||
// to peers via the LiveKit DataChannel. Receivers store and display them.
|
|
||||||
//
|
|
||||||
// Privacy note: speech recognition runs in the browser. On Chromium-based
|
|
||||||
// runtimes (incl. Tauri's WebView2 on Windows) this calls into the browser's
|
|
||||||
// own engine, which today reaches Google's cloud — same trade-off as Discord.
|
|
||||||
// We ship a hard off switch and require an explicit user toggle.
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'chatapp.liveCaptions.v1';
|
|
||||||
|
|
||||||
export interface LiveCaptionsSettings {
|
|
||||||
enabled: boolean;
|
|
||||||
/** BCP-47 language tag, e.g. "de-DE" or "en-US". Auto-detect uses navigator.language. */
|
|
||||||
lang: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULTS: LiveCaptionsSettings = {
|
|
||||||
enabled: false,
|
|
||||||
lang: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
type Listener = (s: LiveCaptionsSettings) => void;
|
|
||||||
const listeners = new Set<Listener>();
|
|
||||||
let cached: LiveCaptionsSettings | null = null;
|
|
||||||
|
|
||||||
function read(): LiveCaptionsSettings {
|
|
||||||
if (cached) return cached;
|
|
||||||
try {
|
|
||||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (!raw) {
|
|
||||||
cached = DEFAULTS;
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
const parsed = JSON.parse(raw) as Partial<LiveCaptionsSettings>;
|
|
||||||
cached = {
|
|
||||||
enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : DEFAULTS.enabled,
|
|
||||||
lang:
|
|
||||||
typeof parsed.lang === 'string' && parsed.lang.length > 0
|
|
||||||
? parsed.lang
|
|
||||||
: DEFAULTS.lang,
|
|
||||||
};
|
|
||||||
return cached;
|
|
||||||
} catch {
|
|
||||||
cached = DEFAULTS;
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function write(s: LiveCaptionsSettings): void {
|
|
||||||
cached = s;
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
|
||||||
} catch {
|
|
||||||
/* quota / private mode */
|
|
||||||
}
|
|
||||||
for (const l of listeners) l(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLiveCaptionsSettings(): LiveCaptionsSettings {
|
|
||||||
return read();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateLiveCaptionsSettings(
|
|
||||||
patch: Partial<LiveCaptionsSettings>,
|
|
||||||
): LiveCaptionsSettings {
|
|
||||||
const next = { ...read(), ...patch };
|
|
||||||
write(next);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function subscribeLiveCaptionsSettings(listener: Listener): () => void {
|
|
||||||
listeners.add(listener);
|
|
||||||
return () => listeners.delete(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Browser feature-detect. Chromium ships under both names; Firefox lacks it
|
|
||||||
// outright. Returns the constructor or null.
|
|
||||||
type SpeechRecognitionCtor = new () => SpeechRecognitionLike;
|
|
||||||
interface SpeechRecognitionLike extends EventTarget {
|
|
||||||
continuous: boolean;
|
|
||||||
interimResults: boolean;
|
|
||||||
lang: string;
|
|
||||||
start: () => void;
|
|
||||||
stop: () => void;
|
|
||||||
abort: () => void;
|
|
||||||
onresult: ((e: SpeechRecognitionEventLike) => void) | null;
|
|
||||||
onerror: ((e: SpeechRecognitionErrorLike) => void) | null;
|
|
||||||
onend: (() => void) | null;
|
|
||||||
}
|
|
||||||
interface SpeechRecognitionEventLike {
|
|
||||||
resultIndex: number;
|
|
||||||
results: ArrayLike<{
|
|
||||||
isFinal: boolean;
|
|
||||||
[index: number]: { transcript: string };
|
|
||||||
length: number;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
interface SpeechRecognitionErrorLike {
|
|
||||||
error: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSpeechRecognitionCtor(): SpeechRecognitionCtor | null {
|
|
||||||
const w = window as unknown as {
|
|
||||||
SpeechRecognition?: SpeechRecognitionCtor;
|
|
||||||
webkitSpeechRecognition?: SpeechRecognitionCtor;
|
|
||||||
};
|
|
||||||
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isLiveCaptionsSupported(): boolean {
|
|
||||||
return getSpeechRecognitionCtor() !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type {
|
|
||||||
SpeechRecognitionLike,
|
|
||||||
SpeechRecognitionEventLike,
|
|
||||||
SpeechRecognitionErrorLike,
|
|
||||||
};
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
// Hook that runs SpeechRecognition on the local mic when live-captions are
|
|
||||||
// enabled and a Room is connected. Each interim/final result is broadcast as
|
|
||||||
// a `caption`-typed message via the LiveKit DataChannel so peers can render
|
|
||||||
// it. Recognition stops cleanly when the call ends or the toggle flips off.
|
|
||||||
|
|
||||||
import type { Room } from 'livekit-client';
|
|
||||||
import { useEffect, useRef } from 'react';
|
|
||||||
|
|
||||||
import {
|
|
||||||
type LiveCaptionsSettings,
|
|
||||||
getLiveCaptionsSettings,
|
|
||||||
getSpeechRecognitionCtor,
|
|
||||||
type SpeechRecognitionEventLike,
|
|
||||||
type SpeechRecognitionLike,
|
|
||||||
subscribeLiveCaptionsSettings,
|
|
||||||
} from './liveCaptions';
|
|
||||||
|
|
||||||
interface Args {
|
|
||||||
room: Room | null;
|
|
||||||
/** True while we're connected and want captions to flow. */
|
|
||||||
active: boolean;
|
|
||||||
/** Callback fired locally for our own captions so the overlay can show
|
|
||||||
* them without going through the SFU round-trip. */
|
|
||||||
onLocalCaption: (text: string, final: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useLiveCaptions({ room, active, onLocalCaption }: Args): void {
|
|
||||||
const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
|
|
||||||
const settingsRef = useRef<LiveCaptionsSettings>(getLiveCaptionsSettings());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return subscribeLiveCaptionsSettings((s) => {
|
|
||||||
settingsRef.current = s;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const Ctor = getSpeechRecognitionCtor();
|
|
||||||
if (!Ctor) return; // unsupported runtime
|
|
||||||
if (!active || !room) return;
|
|
||||||
if (!getLiveCaptionsSettings().enabled) return;
|
|
||||||
|
|
||||||
const send = (text: string, final: boolean) => {
|
|
||||||
onLocalCaption(text, final);
|
|
||||||
try {
|
|
||||||
const payload = new TextEncoder().encode(
|
|
||||||
JSON.stringify({ type: 'caption', captionText: text, captionFinal: final }),
|
|
||||||
);
|
|
||||||
// Reliable channel — captions are infrequent enough to afford it,
|
|
||||||
// and dropping interims looks worse than slight lag.
|
|
||||||
void room.localParticipant.publishData(payload, { reliable: true });
|
|
||||||
} catch {
|
|
||||||
/* ignore — best-effort */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const start = () => {
|
|
||||||
const r = new Ctor();
|
|
||||||
r.continuous = true;
|
|
||||||
r.interimResults = true;
|
|
||||||
const lang = settingsRef.current.lang ?? navigator.language ?? 'de-DE';
|
|
||||||
r.lang = lang;
|
|
||||||
r.onresult = (e: SpeechRecognitionEventLike) => {
|
|
||||||
// Pull whichever results arrived since last fire. Interim fires
|
|
||||||
// many times per second; the final one is sticky and persists.
|
|
||||||
for (let i = e.resultIndex; i < e.results.length; i++) {
|
|
||||||
const result = e.results[i];
|
|
||||||
if (!result || result.length === 0) continue;
|
|
||||||
const alt = result[0];
|
|
||||||
if (!alt) continue;
|
|
||||||
const transcript = alt.transcript.trim();
|
|
||||||
if (!transcript) continue;
|
|
||||||
send(transcript, result.isFinal);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
r.onerror = () => {
|
|
||||||
// Recoverable: stop + retry on next effect cycle. `not-allowed` and
|
|
||||||
// `service-not-allowed` are permission-permanent — bail.
|
|
||||||
try {
|
|
||||||
r.stop();
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
r.onend = () => {
|
|
||||||
// SpeechRecognition tends to auto-stop after silence — if we still
|
|
||||||
// want captions, restart it. Guard against tear-down race.
|
|
||||||
if (recognitionRef.current === r && getLiveCaptionsSettings().enabled) {
|
|
||||||
try {
|
|
||||||
r.start();
|
|
||||||
} catch {
|
|
||||||
/* already running or browser refused */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
r.start();
|
|
||||||
recognitionRef.current = r;
|
|
||||||
} catch {
|
|
||||||
// Some browsers throw when start() is called too soon after a
|
|
||||||
// previous abort — wait a tick and retry.
|
|
||||||
window.setTimeout(() => {
|
|
||||||
try {
|
|
||||||
r.start();
|
|
||||||
recognitionRef.current = r;
|
|
||||||
} catch {
|
|
||||||
/* give up */
|
|
||||||
}
|
|
||||||
}, 250);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
start();
|
|
||||||
|
|
||||||
const unsub = subscribeLiveCaptionsSettings((s) => {
|
|
||||||
const cur = recognitionRef.current;
|
|
||||||
if (!s.enabled && cur) {
|
|
||||||
recognitionRef.current = null;
|
|
||||||
try {
|
|
||||||
cur.abort();
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
} else if (s.enabled && !cur) {
|
|
||||||
start();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsub();
|
|
||||||
const cur = recognitionRef.current;
|
|
||||||
recognitionRef.current = null;
|
|
||||||
if (cur) {
|
|
||||||
try {
|
|
||||||
cur.abort();
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [active, room, onLocalCaption]);
|
|
||||||
}
|
|
||||||
@@ -213,7 +213,7 @@ async function runLegacyMigration(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.info(
|
console.debug(
|
||||||
'[crypto-migration] vault scan:',
|
'[crypto-migration] vault scan:',
|
||||||
'serverDevices=' + report.serverDevices,
|
'serverDevices=' + report.serverDevices,
|
||||||
'keysFromServerList=' + report.strongholdKeysFromServerDevices,
|
'keysFromServerList=' + report.strongholdKeysFromServerDevices,
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
|
|||||||
attempted: 0, noStrongholdKey: 0, decryptFailed: 0, rpcFailed: 0,
|
attempted: 0, noStrongholdKey: 0, decryptFailed: 0, rpcFailed: 0,
|
||||||
};
|
};
|
||||||
if (params.ownLegacyDeviceIds.length === 0) {
|
if (params.ownLegacyDeviceIds.length === 0) {
|
||||||
console.info('[crypto-migration] no legacy device-ids to consider — skipping');
|
console.debug('[crypto-migration] no legacy device-ids to consider — skipping');
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
|
|||||||
.not('recipient_device_id', 'is', null);
|
.not('recipient_device_id', 'is', null);
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
const rows = (rowsRaw ?? []) as LegacyRow[];
|
const rows = (rowsRaw ?? []) as LegacyRow[];
|
||||||
console.info('[crypto-migration] legacy rows visible to me: ' + rows.length);
|
console.debug('[crypto-migration] legacy rows visible to me: ' + rows.length);
|
||||||
if (rows.length === 0) return result;
|
if (rows.length === 0) return result;
|
||||||
|
|
||||||
const senderDeviceIds = Array.from(new Set(rows.map((r) => r.sender_device_id).filter(Boolean)));
|
const senderDeviceIds = Array.from(new Set(rows.map((r) => r.sender_device_id).filter(Boolean)));
|
||||||
@@ -136,6 +136,12 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
|
|||||||
result.migratedConversations += 1;
|
result.migratedConversations += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If anything was actually migrated this run, leave it as console.info
|
||||||
|
// so it's visible in default consoles. If we only re-failed on already-
|
||||||
|
// unrecoverable rows (no local stronghold key), demote to debug — the
|
||||||
|
// migration is idempotent but the noisy "noKey=N" line scared the user
|
||||||
|
// who thought migration was already done.
|
||||||
|
if (result.migratedConversations > 0 || result.decryptFailed > 0 || result.rpcFailed > 0) {
|
||||||
console.info(
|
console.info(
|
||||||
'[crypto-migration] result:',
|
'[crypto-migration] result:',
|
||||||
'attempted=' + result.attempted,
|
'attempted=' + result.attempted,
|
||||||
@@ -144,5 +150,12 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
|
|||||||
'decryptFail=' + result.decryptFailed,
|
'decryptFail=' + result.decryptFailed,
|
||||||
'rpcFail=' + result.rpcFailed,
|
'rpcFail=' + result.rpcFailed,
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
console.debug(
|
||||||
|
'[crypto-migration] result (all unrecoverable, expected on stale clients):',
|
||||||
|
'attempted=' + result.attempted,
|
||||||
|
'noKey=' + result.noStrongholdKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user