Compare commits

...
1 Commits
Author SHA1 Message Date
jaredandClaude Opus 5.5 01254291e5 feat(calls): in-game voice overlay for the desktop app (cinny-desktop #25)
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 23s
CI / Build & Quality Checks (pull_request) Successful in 5m9s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 23m21s
Settings → Calls → "In-game overlay (beta)", desktop only, off by
default, device-local. While a call is joined it sends the call
snapshot to the desktop's new `set_overlay_state` command, which shows
a small always-on-top, click-through list while Lotus isn't focused.

- utils/callOverlay: pure snapshot builder. Everyone in the call, you
  first, one row per person across devices (speaking if any device is,
  muted only if all are), your own mute/deafen/PTT. In PTT mode you're
  not shown as Muted while the key is up.
- useTauriOverlay: live io.lotus.call_state participants plus control
  state, coalesced to at most one send per 100 ms (latest always
  lands), unchanged snapshots skipped, cleared on unmount.
- Overlay position setting (four corners).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-11-09 17:25:36 -05:00
7 changed files with 388 additions and 0 deletions
@@ -7,6 +7,7 @@ import { useTauriNetwork } from '../hooks/useTauriNetwork';
import { useTauriToastActions } from '../hooks/useTauriToastActions';
import { useTauriFocusAssist } from '../hooks/useTauriFocusAssist';
import { useTauriDnd } from '../hooks/useTauriDnd';
import { useTauriOverlay } from '../hooks/useTauriOverlay';
/**
* Mounts the client-scoped native desktop feature hooks (call/room aware). Each
@@ -25,5 +26,6 @@ export function TauriDesktopFeatures(): null {
useTauriToastActions(); // P5-41/35 rich toast click → open room, quick reply → send
useTauriFocusAssist(); // P5-56 Windows Focus Assist → DND suppression atom
useTauriDnd(); // P6-1 tray "Do Not Disturb" → notification suppression atom
useTauriOverlay(); // cinny-desktop #25 in-game voice overlay
return null;
}
@@ -134,6 +134,7 @@ import { playCallJoinSound } from '../../../utils/callSounds';
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
import { DenoiseTester } from './DenoiseTester';
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
import type { OverlayCorner } from '../../../utils/callOverlay';
import { isBindableCallKey } from '../../../utils/callKeybind';
import { StorageUsage } from './StorageUsage';
import { hapticsSupported } from '../../../utils/haptics';
@@ -1894,6 +1895,8 @@ export function Calls() {
const [deafenKey, setDeafenKey] = useSetting(settingsAtom, 'deafenKey');
const [deafenHotkey, setDeafenHotkey] = useSetting(settingsAtom, 'deafenHotkey');
const [globalCallHotkeys, setGlobalCallHotkeys] = useSetting(settingsAtom, 'globalCallHotkeys');
const [callOverlay, setCallOverlay] = useSetting(settingsAtom, 'callOverlay');
const [callOverlayCorner, setCallOverlayCorner] = useSetting(settingsAtom, 'callOverlayCorner');
const [afkAutoMute, setAfkAutoMute] = useSetting(settingsAtom, 'afkAutoMute');
const [mutedTalkWarning, setMutedTalkWarning] = useSetting(settingsAtom, 'mutedTalkWarning');
const [afkTimeoutMinutes, setAfkTimeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
@@ -2345,6 +2348,31 @@ export function Calls() {
}
/>
)}
{isTauriEnv() && (
<SettingTile
title="In-game overlay (beta)"
description="While you're in a call and another app or game is in front, show a small list of who's in the call and who's talking, plus your own mute, deafen and push-to-talk state. It never takes focus and clicks pass through it. Uses no game hooks. Windows only; games in exclusive fullscreen draw over it, so use borderless or windowed mode."
after={<Switch variant="Primary" value={callOverlay} onChange={setCallOverlay} />}
/>
)}
{isTauriEnv() && callOverlay && (
<SettingTile
title="Overlay position"
description="Which corner of the screen the overlay sits in."
after={
<SettingsSelect<OverlayCorner>
value={callOverlayCorner}
onChange={setCallOverlayCorner}
options={[
{ value: 'top-left', label: 'Top left' },
{ value: 'top-right', label: 'Top right' },
{ value: 'bottom-left', label: 'Bottom left' },
{ value: 'bottom-right', label: 'Bottom right' },
]}
/>
}
/>
)}
{hapticsSupported() && (
<SettingTile
title="Haptic feedback"
+101
View File
@@ -0,0 +1,101 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useAtomValue } from 'jotai';
import { callEmbedAtom } from '../state/callEmbed';
import { useCallControlState } from '../plugins/call';
import type { LotusCallParticipant } from '../plugins/call/CallEmbed';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import { useMatrixClient } from './useMatrixClient';
import { useCallJoined } from './useCallEmbed';
import { invokeTauri, isTauri } from './useTauri';
import { getMemberName } from '../utils/room';
import {
buildOverlaySnapshot,
inactiveOverlay,
OverlaySnapshot,
overlaySnapshotKey,
} from '../utils/callOverlay';
/** At most this often (ms) while speaking flips; the last state always lands. */
const SEND_INTERVAL_MS = 100;
/**
* [cinny-desktop #25] Feeds the desktop app's in-game voice overlay: while a
* call is joined and Settings → Calls → In-game overlay is on, sends who's in
* the call, who's speaking and your own mute / deafen / PTT state to the native
* `set_overlay_state` command. The desktop decides when to show it (Lotus not
* focused). No-op in the browser.
*/
export function useTauriOverlay(): void {
const mx = useMatrixClient();
const callEmbed = useAtomValue(callEmbedAtom);
const joined = useCallJoined(callEmbed);
const { microphone, sound } = useCallControlState(callEmbed?.control);
const [enabled] = useSetting(settingsAtom, 'callOverlay');
const [corner] = useSetting(settingsAtom, 'callOverlayCorner');
const [pttMode] = useSetting(settingsAtom, 'pttMode');
// Live per-participant state from the fork's io.lotus.call_state, only
// tracked while the overlay could show.
const [participants, setParticipants] = useState<readonly LotusCallParticipant[]>([]);
const tracking = isTauri() && enabled && joined && callEmbed !== undefined;
useEffect(() => {
if (!tracking || !callEmbed) {
setParticipants([]);
return undefined;
}
const sync = (): void => setParticipants(callEmbed.getLotusParticipants() ?? []);
sync();
return callEmbed.onLotusCallState(sync);
}, [tracking, callEmbed]);
const snapshot: OverlaySnapshot = useMemo(() => {
if (!tracking || !callEmbed) return inactiveOverlay(corner);
const { room } = callEmbed;
return buildOverlaySnapshot({
enabled,
joined,
corner,
myUserId: mx.getUserId(),
participants,
nameOf: (userId) => getMemberName(room, userId),
muted: !microphone,
deafened: !sound,
pttMode,
});
}, [tracking, callEmbed, enabled, joined, corner, mx, participants, microphone, sound, pttMode]);
// Coalesced send: skip unchanged snapshots, send at most every
// SEND_INTERVAL_MS, and always deliver the latest one.
const key = overlaySnapshotKey(snapshot);
const latest = useRef(snapshot);
latest.current = snapshot;
const sentKey = useRef<string | null>(null);
const lastSentAt = useRef(0);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => {
if (!isTauri() || key === sentKey.current) return;
const send = (): void => {
timer.current = undefined;
const s = latest.current;
sentKey.current = overlaySnapshotKey(s);
lastSentAt.current = Date.now();
invokeTauri('set_overlay_state', { state: s });
};
if (timer.current !== undefined) return;
const wait = SEND_INTERVAL_MS - (Date.now() - lastSentAt.current);
if (wait <= 0) send();
else timer.current = setTimeout(send, wait);
}, [key]);
// Hide the overlay when this unmounts (logout, client teardown).
useEffect(
() => () => {
if (timer.current !== undefined) clearTimeout(timer.current);
if (isTauri()) {
invokeTauri('set_overlay_state', { state: inactiveOverlay(latest.current.corner) });
}
},
[],
);
}
+10
View File
@@ -1,5 +1,6 @@
import { atom } from 'jotai';
import { isSupportedTargetLang } from '../utils/translation/langUtils';
import type { OverlayCorner } from '../utils/callOverlay';
const STORAGE_KEY = 'settings';
export type DateFormat =
@@ -287,6 +288,12 @@ export interface Settings {
// (a game) has focus, via a non-consuming key poll. Device-local.
globalCallHotkeys: boolean;
// [cinny-desktop #25] Desktop (Windows) only: the in-game voice overlay, a
// small always-on-top list of who's in the call and who's talking, shown
// while Lotus isn't focused. Off by default while in beta. Device-local.
callOverlay: boolean;
callOverlayCorner: OverlayCorner;
pauseAnimations: boolean;
composerToolbarButtons: ComposerToolbarSettings;
@@ -413,6 +420,9 @@ const defaultSettings: Settings = {
globalCallHotkeys: true,
callOverlay: false,
callOverlayCorner: 'top-left',
pauseAnimations: false,
composerToolbarButtons: DEFAULT_COMPOSER_TOOLBAR,
+124
View File
@@ -0,0 +1,124 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildOverlaySnapshot, inactiveOverlay, OverlayInput } from './callOverlay';
const names: Record<string, string> = { '@me:x': 'Me', '@a:x': 'Alice', '@b:x': 'Bob' };
const base = (over: Partial<OverlayInput> = {}): OverlayInput => ({
enabled: true,
joined: true,
corner: 'top-left',
myUserId: '@me:x',
participants: [
{ userId: '@me:x', speaking: false, audioEnabled: true },
{ userId: '@a:x', speaking: true, audioEnabled: true },
{ userId: '@b:x', speaking: false, audioEnabled: false },
],
nameOf: (id) => names[id] ?? id,
muted: false,
deafened: false,
pttMode: false,
...over,
});
test('inactive when the setting is off or there is no joined call', () => {
assert.deepEqual(buildOverlaySnapshot(base({ enabled: false })), inactiveOverlay('top-left'));
assert.deepEqual(
buildOverlaySnapshot(base({ joined: false, corner: 'bottom-right' })),
inactiveOverlay('bottom-right'),
);
});
test('everyone is listed, you first, speakers and muted people marked', () => {
const s = buildOverlaySnapshot(base());
assert.equal(s.active, true);
assert.deepEqual(
s.people.map((p) => [p.name, p.speaking, p.muted, p.isSelf]),
[
['Me', false, false, true],
['Alice', true, false, false],
['Bob', false, true, false],
],
);
});
test('you are listed first even when the fork reports you later', () => {
const s = buildOverlaySnapshot(
base({
participants: [
{ userId: '@a:x', speaking: false, audioEnabled: true },
{ userId: '@me:x', speaking: true, audioEnabled: true },
],
}),
);
assert.deepEqual(
s.people.map((p) => p.userId),
['@me:x', '@a:x'],
);
assert.equal(s.people[0].speaking, true);
});
test('several devices of one person become one row', () => {
const s = buildOverlaySnapshot(
base({
participants: [
{ userId: '@a:x', speaking: false, audioEnabled: false },
{ userId: '@a:x', speaking: true, audioEnabled: true },
{ userId: '@b:x', speaking: false, audioEnabled: false },
{ userId: '@b:x', speaking: false, audioEnabled: false },
],
}),
);
const alice = s.people.find((p) => p.userId === '@a:x');
const bob = s.people.find((p) => p.userId === '@b:x');
assert.equal(s.people.filter((p) => p.userId === '@a:x').length, 1);
assert.deepEqual([alice?.speaking, alice?.muted], [true, false]);
assert.deepEqual([bob?.speaking, bob?.muted], [false, true]);
});
test('your own mute and deafen state', () => {
const muted = buildOverlaySnapshot(base({ muted: true }));
assert.equal(muted.muted, true);
assert.equal(muted.people[0].muted, true);
const deaf = buildOverlaySnapshot(
base({
deafened: true,
participants: [{ userId: '@me:x', speaking: true, audioEnabled: true }],
}),
);
assert.equal(deaf.deafened, true);
assert.equal(deaf.people[0].muted, true);
assert.equal(deaf.people[0].speaking, false);
});
test('push-to-talk: not shown as muted while the key is up', () => {
const s = buildOverlaySnapshot(base({ pttMode: true, muted: true }));
assert.equal(s.ptt, true);
assert.equal(s.muted, false);
assert.equal(s.people[0].muted, false);
});
test('you are still listed before the fork reports you', () => {
const s = buildOverlaySnapshot(base({ participants: [] }));
assert.deepEqual(
s.people.map((p) => [p.name, p.isSelf]),
[['Me', true]],
);
});
test('non-user ids from the fork are ignored', () => {
const s = buildOverlaySnapshot(
base({
myUserId: null,
participants: [
{ userId: 'screen:1', speaking: true, audioEnabled: true },
{ userId: '@a:x', speaking: false, audioEnabled: true },
],
}),
);
assert.deepEqual(
s.people.map((p) => p.userId),
['@a:x'],
);
});
+121
View File
@@ -0,0 +1,121 @@
/**
* [cinny-desktop #25] In-game voice overlay: the call snapshot the desktop app
* draws in its always-on-top overlay window. Pure, so it can be tested without
* a call; `useTauriOverlay` feeds it live state and sends the result to the
* native `set_overlay_state` command.
*/
export type OverlayCorner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
export const OVERLAY_CORNERS: readonly OverlayCorner[] = [
'top-left',
'top-right',
'bottom-left',
'bottom-right',
];
export type OverlayPerson = {
userId: string;
name: string;
speaking: boolean;
muted: boolean;
isSelf: boolean;
};
/** Mirrors `OverlayState` in cinny-desktop `src-tauri/src/native/overlay.rs`. */
export type OverlaySnapshot = {
active: boolean;
corner: OverlayCorner;
muted: boolean;
deafened: boolean;
ptt: boolean;
people: OverlayPerson[];
};
/** The subset of an `io.lotus.call_state` participant the overlay needs. */
export type OverlayParticipant = {
userId: string;
speaking: boolean;
audioEnabled: boolean;
};
export type OverlayInput = {
/** Settings → Calls → In-game overlay. */
enabled: boolean;
/** In a joined call. */
joined: boolean;
corner: OverlayCorner;
myUserId: string | null;
/** Per device, in the fork's order (stable while people stay). */
participants: readonly OverlayParticipant[];
nameOf: (userId: string) => string;
/** Own mic is off (`microphone === false`). */
muted: boolean;
/** Own call audio is off (`sound === false`). */
deafened: boolean;
pttMode: boolean;
};
export const inactiveOverlay = (corner: OverlayCorner): OverlaySnapshot => ({
active: false,
corner,
muted: false,
deafened: false,
ptt: false,
people: [],
});
/**
* Build the snapshot: everyone in the call (you first, then everyone else in
* call order), one row per person even with several devices (speaking if any
* device is, muted only if every device is), and your own mute / deafen / PTT
* state for the header.
*
* In push-to-talk mode the mic is "off" whenever the key isn't held, so you
* are not shown as Muted then: the header says PTT, and your row lights up
* while you transmit.
*/
export function buildOverlaySnapshot(input: OverlayInput): OverlaySnapshot {
const { corner } = input;
if (!input.enabled || !input.joined) return inactiveOverlay(corner);
const byUser = new Map<string, { speaking: boolean; muted: boolean }>();
input.participants.forEach((p) => {
if (!p.userId.startsWith('@')) return;
const prev = byUser.get(p.userId);
byUser.set(p.userId, {
speaking: (prev?.speaking ?? false) || p.speaking,
muted: (prev?.muted ?? true) && !p.audioEnabled,
});
});
const selfMuted = input.pttMode ? false : input.muted;
const people: OverlayPerson[] = [];
const me = input.myUserId;
if (me) {
const mine = byUser.get(me);
people.push({
userId: me,
name: input.nameOf(me),
speaking: (mine?.speaking ?? false) && !selfMuted && !input.deafened,
muted: selfMuted || input.deafened,
isSelf: true,
});
}
byUser.forEach((state, userId) => {
if (userId === me) return;
people.push({ userId, name: input.nameOf(userId), ...state, isSelf: false });
});
return {
active: true,
corner,
muted: selfMuted,
deafened: input.deafened,
ptt: input.pttMode,
people,
};
}
/** Key for skipping sends when nothing visible changed. */
export const overlaySnapshotKey = (s: OverlaySnapshot): string => JSON.stringify(s);
+2
View File
@@ -28,6 +28,8 @@ export type SyncedSettingsContent = {
export const DEVICE_LOCAL_KEYS: ReadonlySet<keyof Settings> = new Set<keyof Settings>([
'settingsSync',
'globalCallHotkeys',
'callOverlay',
'callOverlayCorner',
'pageZoom',
'mediaAutoLoad',
'pauseAnimations',