diff --git a/nobilis b/nobilis index 9690905..e0f6169 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit 96909059701170c36f8597f198a5d49c8f3b6054 +Subproject commit e0f616907368f55c536ecd520f540db8bc53889e diff --git a/scripts/ui-shots.mjs b/scripts/ui-shots.mjs index 8ac95d5..4b2fb70 100644 --- a/scripts/ui-shots.mjs +++ b/scripts/ui-shots.mjs @@ -334,7 +334,12 @@ const SCENES = [ { name: 'discord-guild', setup: discord(false) }, { name: 'discord-voice-panel', - setup: discord(true), + setup: async (p) => { + await discord(true)(p) + // Somebody speaking, and a healthy connection. + await p.evaluate(`window.__shotsLevels = { micPeak: 0.05, rttMs: 42 }, true`) + await sleep(500) + }, clip: { x: 0, y: 480, width: 300, height: 320 } }, { @@ -385,6 +390,15 @@ const SCENES = [ await sleep(400) } }, + { + name: 'discord-voice-panel-poor', + setup: async (p) => { + await discord(true)(p) + await p.evaluate(`window.__shotsLevels = { micPeak: 0.13, rttMs: 210 }, true`) + await sleep(500) + }, + clip: { x: 0, y: 480, width: 300, height: 320 } + }, // Motion, recorded rather than photographed. { name: 'motion-hover-rows', diff --git a/src/renderer/src/app.css b/src/renderer/src/app.css index 04d9634..0d921e4 100644 --- a/src/renderer/src/app.css +++ b/src/renderer/src/app.css @@ -5189,7 +5189,7 @@ video.lightbox-media { .voice-panel-head { display: flex; align-items: center; - gap: var(--spacing-s); + gap: var(--spacing-xxs); } .voice-panel-state { @@ -5198,39 +5198,54 @@ video.lightbox-media { gap: var(--spacing-s); flex: 1; min-width: 0; + white-space: nowrap; color: var(--success); font-weight: var(--weight-semibold); font-size: var(--font-size-small); } -/* Rising bars, so a glance says whether the microphone is hearing anything - rather than only that a connection exists. */ -.voice-level { - display: flex; - align-items: flex-end; - gap: 2px; - height: 12px; - flex: none; -} - -.voice-level-bar { - width: 3px; - background: var(--outline); - border-radius: 1px; -} - -.voice-level-bar:nth-child(1) { +/* What the microphone is hearing, as a bar along the panel: full width so it + cannot be mistaken for a connection indicator, which is what three little + rising strokes beside the title looked like. It fills as somebody speaks. */ +.voice-panel .voice-level { height: 4px; -} -.voice-level-bar:nth-child(2) { - height: 8px; -} -.voice-level-bar:nth-child(3) { - height: 12px; + margin: var(--spacing-xs) 0 var(--spacing-xs); + border-radius: var(--radius-pill); + background: var(--surface-container-highest); + overflow: hidden; } -.voice-level-bar.lit { - background: var(--success); +.voice-panel .voice-level-fill { + display: block; + height: 100%; + border-radius: inherit; + background: var(--speaking); + transition: width 140ms linear; +} + +.voice-panel .voice-level.hot .voice-level-fill { + background: var(--warning); +} + +/* The round trip to the voice server, next to the title it qualifies. */ +.voice-ping { + margin-left: auto; + padding-right: var(--spacing-xs); + font-size: var(--font-size-xs); + font-weight: var(--weight-regular); + font-variant-numeric: tabular-nums; +} + +.voice-ping.good { + color: var(--success); +} + +.voice-ping.fair { + color: var(--warning); +} + +.voice-ping.poor { + color: var(--danger-text); } .voice-panel-where { @@ -8663,3 +8678,9 @@ video.lightbox-media { display: none; } } + +/* A toggle that is on: sharing a screen, for one. */ +.icon-button.active { + background: var(--primary-hover); + color: var(--primary); +} diff --git a/src/renderer/src/components/VoicePanel.tsx b/src/renderer/src/components/VoicePanel.tsx index 9a7b3ba..ea3634d 100644 --- a/src/renderer/src/components/VoicePanel.tsx +++ b/src/renderer/src/components/VoicePanel.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { Icon, IconButton } from './Icon' import { useChat, useStore } from '../state/hooks' import { DM_GROUP_ID } from '../lib/groups' +import { levelFill } from '../lib/voiceLevel' /** * The call you are in, shown above the account plaque. @@ -19,7 +20,9 @@ export function VoicePanel(): JSX.Element | null { const store = useStore() const sessions = useChat((s) => s.voiceSessions) const groups = useChat((s) => s.groups) + const sharing = useChat((s) => s.discordSharing) const [level, setLevel] = useState(0) + const [rtt, setRtt] = useState(null) const session = sessions[0] @@ -29,15 +32,33 @@ export function VoicePanel(): JSX.Element | null { // whether this machine is actually hearing anything, which no amount of // connection state answers. const tick = async (): Promise => { + // The screenshot harness has no call to listen to, and says what the + // panel should show (scripts/ui-shots.mjs). Only in a window it started. + const staged = window.moho.uiShots + ? (window as unknown as { __shotsLevels?: { micPeak: number; rttMs: number | null } }).__shotsLevels + : undefined + if (staged) { + setLevel(staged.micPeak) + setRtt(staged.rttMs) + return + } try { - const levels = await window.moho.rpc<{ accountId: string; micPeak: number }[]>('getVoiceLevels') - setLevel(levels.find((l) => l.accountId === session.accountId)?.micPeak ?? 0) + const levels = await window.moho.rpc<{ accountId: string; micPeak: number; rttMs?: number | null }[]>( + 'getVoiceLevels' + ) + const mine = levels.find((l) => l.accountId === session.accountId) + setLevel(mine?.micPeak ?? 0) + setRtt(mine?.rttMs ?? null) } catch { // A daemon that cannot answer is not worth a toast every second. } } void tick() - const timer = setInterval(() => void tick(), 1000) + // Often enough that the bar follows speech rather than lagging it; not + // while the window is hidden, where nobody is looking at it. + const timer = setInterval(() => { + if (document.visibilityState === 'visible') void tick() + }, 150) return () => clearInterval(timer) }, [session]) @@ -47,23 +68,37 @@ export function VoicePanel(): JSX.Element | null { // conversation itself, which the DM page holds. const groupId = session.guildId ? `${session.accountId}|guild:${session.guildId}` : DM_GROUP_ID const guildName = session.isDirect ? '' : (groups.find((g) => g.id === groupId)?.name ?? '') - // Thresholds, not a linear scale of full scale. Measured on this hardware, - // a quiet room sits around 0.008 and ordinary speech peaks between 0.02 and - // 0.05 - so anything scaled against 1.0 stays dark while somebody is - // talking, which is exactly the failure a meter exists to rule out. - const bars = level > 0.05 ? 3 : level > 0.03 ? 2 : level > 0.015 ? 1 : 0 + const fill = levelFill(level) + const quality = rtt === null ? null : rtt < 80 ? 'good' : rtt < 160 ? 'fair' : 'poor' return (
- - {[0, 1, 2].map((i) => ( - - ))} - Voice Connected + {/* How the connection to the voice server is, in the one unit that + means anything: how long it takes to answer. Absent until the + first answer has come back, and where the service has no such + number. */} + {rtt !== null && quality && ( + + {rtt} ms + + )} + {/* Sharing a screen into the call you are in, from where the call is + shown rather than only from inside it. Discord's calls only: those + are the ones that live in the daemon and have no stage of their + own to hold the control. */} + {session.accountId.startsWith('discord:') && ( + void store.toggleScreenShare()} + /> + )}
+ {/* What the microphone is hearing, which is how somebody knows it is + the one they think it is before they say something into silence. */} +
0.85 ? ' hot' : ''}`} + role="meter" + aria-label="Microphone level" + aria-valuemin={0} + aria-valuemax={100} + aria-valuenow={Math.round(fill * 100)} + title="What your microphone is hearing" + > + +
+