From 8e67f1d553d20d629c9c5c5770073a13fe2087f4 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 3 May 2026 01:43:01 +0200 Subject: [PATCH] fix(web): mic meter reacts to AudioContext lifecycle; extract picker primitives - AudioInputSection now subscribes to AudioManager.onResumed and bumps an audioCtxGen state on each 'running' transition. Mic-level meter and resolved-default hint effects depend on it, so opening Settings before joining voice and then joining voice activates the meter without needing to remount the panel. Footer copy updated to match the new behavior. - SectionShell and DropdownItem extracted to settingsPanels/_shared/SettingsPickerPrimitives.tsx so Task 6 (AudioOutputSection) can import them instead of triplicating the markup. The _shared/ folder keeps these settings-internal primitives out of the broader ui/ namespace. - Deliberate scope choice: VideoSection.tsx still has its own DropdownItem copy. Unifying all three is left to a follow-up; touching VideoSection here would expand scope beyond the audio-device-ux branch. --- .../settingsPanels/AudioInputSection.tsx | 67 ++++++++----------- .../_shared/SettingsPickerPrimitives.tsx | 46 +++++++++++++ 2 files changed, 73 insertions(+), 40 deletions(-) create mode 100644 packages/web/src/components/modals/settingsPanels/_shared/SettingsPickerPrimitives.tsx diff --git a/packages/web/src/components/modals/settingsPanels/AudioInputSection.tsx b/packages/web/src/components/modals/settingsPanels/AudioInputSection.tsx index 3bedaeb3..2bc2931c 100644 --- a/packages/web/src/components/modals/settingsPanels/AudioInputSection.tsx +++ b/packages/web/src/components/modals/settingsPanels/AudioInputSection.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { useVoiceStore } from '../../../stores/voiceStore'; import { AudioManager } from '../../../audio/AudioManager'; import { useAudioDevices } from '../../../hooks/useAudioDevices'; +import { SectionShell, DropdownItem } from './_shared/SettingsPickerPrimitives'; export function AudioInputSection() { const inputDeviceId = useVoiceStore((s) => s.inputDeviceId); @@ -13,6 +14,12 @@ export function AudioInputSection() { const [listOpen, setListOpen] = useState(false); const [micLevel, setMicLevel] = useState(0); const [activeUpstreamId, setActiveUpstreamId] = useState(null); + // Bumped whenever AudioManager's AudioContext transitions to 'running'. + // Used as a dep on effects that need to re-run once the context exists — + // the user may open Settings before joining voice (no AudioContext yet), + // then join voice and expect the meter / resolved-default hint to come + // alive without reopening the panel. + const [audioCtxGen, setAudioCtxGen] = useState(0); const dropdownRef = useRef(null); const animFrameRef = useRef(0); @@ -28,9 +35,21 @@ export function AudioInputSection() { return () => document.removeEventListener('mousedown', onMouseDown); }, [listOpen]); + // Listen for AudioContext resume events so dependent effects re-trigger + // when the context first becomes available (e.g. user joins voice after + // opening Settings). onResumed fires on every 'running' state transition; + // we only need an opaque generation bump to re-run downstream effects. + useEffect(() => { + if (permState !== 'granted') return; + const am = AudioManager.getInstance(); + const unsubscribe = am.onResumed(() => setAudioCtxGen((g) => g + 1)); + return () => { unsubscribe(); }; + }, [permState]); + // Live mic-level meter. Reuses AudioManager's analyser node, which is part - // of the canonical pipeline — no extra getUserMedia required if the user is - // already in voice OR the AudioContext is active. + // of the canonical pipeline — no extra getUserMedia required once the user + // is in voice. Re-runs on `audioCtxGen` bumps so the meter activates after + // the AudioContext appears mid-session. useEffect(() => { if (permState !== 'granted') return; let stopped = false; @@ -52,15 +71,17 @@ export function AudioInputSection() { stopped = true; if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current); }; - }, [permState]); + }, [permState, audioCtxGen]); - // Track the resolved upstream deviceId for the "System Default · X" hint. + // Track the resolved upstream deviceId for the "Currently using: X" hint. + // Re-runs on `audioCtxGen` because the resolved-default ID is only known + // after AudioManager has actually opened a stream. useEffect(() => { if (permState !== 'granted') return; const am = AudioManager.getInstance(); const id = am.getCurrentInputDeviceId(); setActiveUpstreamId(id === 'default' ? null : id); - }, [permState, inputDeviceId]); + }, [permState, inputDeviceId, audioCtxGen]); if (permState === 'unknown') { return ( @@ -187,44 +208,10 @@ export function AudioInputSection() { ))}
- The level meter is live whenever an audio session is active. Join a voice channel to test mic input. + The level meter activates once you join a voice channel.
); } - -function SectionShell({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-
{title}
-
{children}
-
- ); -} - -interface DropdownItemProps { - label: string; - active: boolean; - onClick: () => void; -} - -function DropdownItem({ label, active, onClick }: DropdownItemProps) { - return ( - - ); -} diff --git a/packages/web/src/components/modals/settingsPanels/_shared/SettingsPickerPrimitives.tsx b/packages/web/src/components/modals/settingsPanels/_shared/SettingsPickerPrimitives.tsx new file mode 100644 index 00000000..3dddae44 --- /dev/null +++ b/packages/web/src/components/modals/settingsPanels/_shared/SettingsPickerPrimitives.tsx @@ -0,0 +1,46 @@ +import type { ReactNode } from 'react'; + +/** + * Section wrapper used by audio/video picker subsections (AudioInputSection, + * AudioOutputSection, …). Provides the small uppercase title above a soft + * inset card. Settings-panel-internal — kept under `_shared/` rather than + * promoted to `ui/` because nothing outside settings panels needs this look. + */ +export function SectionShell({ title, children }: { title: string; children: ReactNode }) { + return ( +
+
{title}
+
{children}
+
+ ); +} + +export interface DropdownItemProps { + label: string; + active: boolean; + onClick: () => void; +} + +/** + * Single row of a settings picker dropdown (input device, output device, etc.). + * Renders a checkmark on the active row and indents inactive labels by the + * checkmark's width so labels align across rows. Settings-panel-internal. + */ +export function DropdownItem({ label, active, onClick }: DropdownItemProps) { + return ( + + ); +}