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 ( + + ); +}