From 0dccfeade5832a012350b90a2230d0cb408cdb08 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 26 Nov 2024 11:46:58 +0000 Subject: [PATCH] feat: microphone indicator inside in-progress-recording window --- apps/desktop/src-tauri/src/lib.rs | 18 ++- apps/desktop/src-tauri/src/windows.rs | 2 +- .../src/routes/(window-chrome)/(main).tsx | 38 ++++-- .../src/routes/in-progress-recording.tsx | 120 +++++++++++++++--- apps/desktop/src/styles/theme.css | 4 +- packages/ui-solid/src/auto-imports.d.ts | 3 + 6 files changed, 155 insertions(+), 30 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index cd4635d2..6e43158e 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -273,7 +273,23 @@ type MutableState<'a, T> = State<'a, Arc>>; #[tauri::command] #[specta::specta] async fn get_recording_options(state: MutableState<'_, App>) -> Result { - let state = state.read().await; + let mut state = state.write().await; + + // If there's a saved audio input but no feed, initialize it + if let Some(audio_input_name) = state.start_recording_options.audio_input_name() { + if state.audio_input_feed.is_none() { + state.audio_input_feed = if let Ok(feed) = AudioInputFeed::init(audio_input_name) + .await + .map_err(|error| eprintln!("{error}")) + { + feed.add_sender(state.audio_input_tx.clone()).await.unwrap(); + Some(feed) + } else { + None + }; + } + } + Ok(state.start_recording_options.clone()) } diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index 48f887b9..9e1cb996 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -240,7 +240,7 @@ impl ShowCapWindow { Self::InProgressRecording { position: _position, } => { - let width = 160.0; + let width = 200.0; let height = 40.0; self.window_builder(app, "/in-progress-recording") diff --git a/apps/desktop/src/routes/(window-chrome)/(main).tsx b/apps/desktop/src/routes/(window-chrome)/(main).tsx index 4e9576e2..bba9e7cc 100644 --- a/apps/desktop/src/routes/(window-chrome)/(main).tsx +++ b/apps/desktop/src/routes/(window-chrome)/(main).tsx @@ -467,6 +467,8 @@ function MicrophoneSelect(props: { const currentRecording = createCurrentRecordingQuery(); const [open, setOpen] = createSignal(false); + const [dbs, setDbs] = createSignal(); + const [isInitialized, setIsInitialized] = createSignal(false); const value = () => devices?.data?.find((d) => d.name === props.options?.audioInputName) ?? @@ -490,22 +492,40 @@ function MicrophoneSelect(props: { if (!item.deviceId) setDbs(); }; - // raw db level - const [dbs, setDbs] = createSignal(); - - createEffect(() => { - if (!props.options?.audioInputName) setDbs(); - }); + // Create a single event listener using onMount + onMount(() => { + const listener = (event: Event) => { + const dbs = (event as CustomEvent).detail; + if (!props.options?.audioInputName) setDbs(); + else setDbs(dbs); + }; + + events.audioInputLevelChange.listen((dbs) => { + if (!props.options?.audioInputName) setDbs(); + else setDbs(dbs.payload); + }); - events.audioInputLevelChange.listen((dbs) => { - if (!props.options?.audioInputName) setDbs(); - else setDbs(dbs.payload); + return () => { + window.removeEventListener("audioLevelChange", listener); + }; }); // visual audio level from 0 -> 1 const audioLevel = () => Math.pow(1 - Math.max((dbs() ?? 0) + DB_SCALE, 0) / DB_SCALE, 0.5); + // Initialize audio input if needed - only once when component mounts + onMount(() => { + const audioInput = props.options?.audioInputName; + if (!audioInput || !permissionGranted() || isInitialized()) return; + + setIsInitialized(true); + handleMicrophoneChange({ + name: audioInput, + deviceId: audioInput, + }); + }); + return (
diff --git a/apps/desktop/src/routes/in-progress-recording.tsx b/apps/desktop/src/routes/in-progress-recording.tsx index bd735d99..907cfa13 100644 --- a/apps/desktop/src/routes/in-progress-recording.tsx +++ b/apps/desktop/src/routes/in-progress-recording.tsx @@ -4,12 +4,57 @@ import { cx } from "cva"; import { commands, events } from "~/utils/tauri"; import { createTimer } from "@solid-primitives/timer"; import { createMutation } from "@tanstack/solid-query"; +import { + createOptionsQuery, + createCurrentRecordingQuery, +} from "~/utils/queries"; + +// Create a singleton audio level store that persists across reloads +const audioLevelStore = { + level: 0, + initialized: false, + init() { + if (this.initialized) return; + + events.audioInputLevelChange.listen((dbs) => { + // Convert dB to a percentage (0-100) + // Typical microphone levels are between -60dB and 0dB + // We'll use -60dB as our floor and 0dB as our ceiling + const DB_MIN = -60; + const DB_MAX = 0; + + const dbValue = dbs.payload ?? DB_MIN; + const normalizedLevel = Math.max( + 0, + Math.min(1, (dbValue - DB_MIN) / (DB_MAX - DB_MIN)) + ); + this.level = normalizedLevel; + + window.dispatchEvent( + new CustomEvent("audioLevelChange", { detail: normalizedLevel }) + ); + }); + + this.initialized = true; + }, + cleanup() { + this.initialized = false; + this.level = 0; + }, +}; export default function () { const start = Date.now(); const [time, setTime] = createSignal(Date.now()); const [isPaused, setIsPaused] = createSignal(false); const [stopped, setStopped] = createSignal(false); + const [audioLevel, setAudioLevel] = createSignal(0); + const currentRecording = createCurrentRecordingQuery(); + const { options } = createOptionsQuery(); + + const isAudioEnabled = () => { + return options.data?.audioInputName != null; + }; createTimer( () => { @@ -24,6 +69,27 @@ export default function () { setTime(Date.now()); }); + // Single effect to handle audio initialization and cleanup + createEffect(() => { + if (!isAudioEnabled()) { + audioLevelStore.cleanup(); + setAudioLevel(0); + return; + } + + audioLevelStore.init(); + setAudioLevel(audioLevelStore.level); + + const handler = (e: CustomEvent) => { + setAudioLevel(e.detail); + }; + + window.addEventListener("audioLevelChange", handler as EventListener); + return () => { + window.removeEventListener("audioLevelChange", handler as EventListener); + }; + }); + const stopRecording = createMutation(() => ({ mutationFn: async () => { setStopped(true); @@ -67,30 +133,50 @@ export default function () { - {window.FLAGS.pauseResume && ( +
+
+ {isAudioEnabled() ? ( + <> + +
+
+
+ + ) : ( + + )} +
+ + {window.FLAGS.pauseResume && ( + togglePause.mutate()} + > + {isPaused() ? : } + + )} + togglePause.mutate()} + disabled={restartRecording.isPending} + onClick={() => restartRecording.mutate()} > - {isPaused() ? : } + - )} - - restartRecording.mutate()} - > - - +
- +
); diff --git a/apps/desktop/src/styles/theme.css b/apps/desktop/src/styles/theme.css index 9d363323..7ea1fdc9 100644 --- a/apps/desktop/src/styles/theme.css +++ b/apps/desktop/src/styles/theme.css @@ -1,10 +1,10 @@ @media (prefers-color-scheme: dark) { - [data-tauri-drag-region] { + [data-tauri-drag-region]:not(.non-styled-move) { background: var(--gray-50); } } -.dark [data-tauri-drag-region] { +.dark [data-tauri-drag-region]:not(.non-styled-move) { background: var(--gray-50); } diff --git a/packages/ui-solid/src/auto-imports.d.ts b/packages/ui-solid/src/auto-imports.d.ts index 6b5d4a05..0709a403 100644 --- a/packages/ui-solid/src/auto-imports.d.ts +++ b/packages/ui-solid/src/auto-imports.d.ts @@ -6,6 +6,7 @@ // biome-ignore lint: disable export {} declare global { + const IconCapArrows: typeof import('~icons/cap/arrows.jsx')['default'] const IconCapAudioOn: typeof import('~icons/cap/audio-on.jsx')['default'] const IconCapBlur: typeof import('~icons/cap/blur.jsx')['default'] const IconCapCamera: typeof import('~icons/cap/camera.jsx')['default'] @@ -40,6 +41,7 @@ declare global { const IconCapScissors: typeof import('~icons/cap/scissors.jsx')['default'] const IconCapSettings: typeof import('~icons/cap/settings.jsx')['default'] const IconCapShadow: typeof import('~icons/cap/shadow.jsx')['default'] + const IconCapSquare: typeof import('~icons/cap/square.jsx')['default'] const IconCapStopCircle: typeof import('~icons/cap/stop-circle.jsx')['default'] const IconCapTrash: typeof import('~icons/cap/trash.jsx')['default'] const IconCapUndo: typeof import('~icons/cap/undo.jsx')['default'] @@ -54,6 +56,7 @@ declare global { const IconLucideLayoutGrid: typeof import('~icons/lucide/layout-grid.jsx')['default'] const IconLucideLoaderCircle: typeof import('~icons/lucide/loader-circle.jsx')['default'] const IconLucideMessageSquarePlus: typeof import('~icons/lucide/message-square-plus.jsx')['default'] + const IconLucideMicOff: typeof import('~icons/lucide/mic-off.jsx')['default'] const IconLucideRabbit: typeof import('~icons/lucide/rabbit.jsx')['default'] const IconLucideRotateCcw: typeof import('~icons/lucide/rotate-ccw.jsx')['default'] const IconLucideSearch: typeof import('~icons/lucide/search.jsx')['default']