Skip to content

Commit

Permalink
feat: microphone indicator inside in-progress-recording window
Browse files Browse the repository at this point in the history
  • Loading branch information
richiemcilroy committed Nov 26, 2024
1 parent e2cb9e1 commit 0dccfea
Show file tree
Hide file tree
Showing 6 changed files with 155 additions and 30 deletions.
18 changes: 17 additions & 1 deletion apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,23 @@ type MutableState<'a, T> = State<'a, Arc<RwLock<T>>>;
#[tauri::command]
#[specta::specta]
async fn get_recording_options(state: MutableState<'_, App>) -> Result<RecordingOptions, ()> {
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())
}

Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/src/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
38 changes: 29 additions & 9 deletions apps/desktop/src/routes/(window-chrome)/(main).tsx
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,8 @@ function MicrophoneSelect(props: {
const currentRecording = createCurrentRecordingQuery();

const [open, setOpen] = createSignal(false);
const [dbs, setDbs] = createSignal<number | undefined>();
const [isInitialized, setIsInitialized] = createSignal(false);

const value = () =>
devices?.data?.find((d) => d.name === props.options?.audioInputName) ??
Expand All @@ -490,22 +492,40 @@ function MicrophoneSelect(props: {
if (!item.deviceId) setDbs();
};

// raw db level
const [dbs, setDbs] = createSignal<number | undefined>();

createEffect(() => {
if (!props.options?.audioInputName) setDbs();
});
// Create a single event listener using onMount
onMount(() => {
const listener = (event: Event) => {
const dbs = (event as CustomEvent<number>).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 (
<div class="flex flex-col gap-[0.25rem] items-stretch text-[--text-primary]">
<label class="text-[--text-tertiary]">Microphone</label>
Expand Down
120 changes: 103 additions & 17 deletions apps/desktop/src/routes/in-progress-recording.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>(0);
const currentRecording = createCurrentRecordingQuery();
const { options } = createOptionsQuery();

const isAudioEnabled = () => {
return options.data?.audioInputName != null;
};

createTimer(
() => {
Expand All @@ -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);
Expand Down Expand Up @@ -67,30 +133,50 @@ export default function () {
</span>
</button>

{window.FLAGS.pauseResume && (
<div class="flex items-center gap-1">
<div class="relative h-8 w-8 flex items-center justify-center">
{isAudioEnabled() ? (
<>
<IconCapMicrophone class="size-5 text-gray-400" />
<div class="absolute bottom-1 left-1 right-1 h-0.5 bg-gray-400 overflow-hidden rounded-full">
<div
class="absolute inset-0 bg-blue-400 transition-transform duration-100"
style={{
transform: `translateX(-${(1 - audioLevel()) * 100}%)`,
}}
/>
</div>
</>
) : (
<IconLucideMicOff
class="size-5 text-gray-300 opacity-20 dark:text-gray-300 dark:opacity-100"
data-tauri-drag-region
/>
)}
</div>

{window.FLAGS.pauseResume && (
<ActionButton
disabled={togglePause.isPending}
onClick={() => togglePause.mutate()}
>
{isPaused() ? <IconCapPlayCircle /> : <IconCapPauseCircle />}
</ActionButton>
)}

<ActionButton
disabled={togglePause.isPending}
onClick={() => togglePause.mutate()}
disabled={restartRecording.isPending}
onClick={() => restartRecording.mutate()}
>
{isPaused() ? <IconCapPlayCircle /> : <IconCapPauseCircle />}
<IconCapRestart />
</ActionButton>
)}

<ActionButton
disabled={restartRecording.isPending}
onClick={() => restartRecording.mutate()}
>
<IconCapRestart />
</ActionButton>
</div>
</div>
<div
class="bg-gray-500 dark:bg-gray-50 cursor-move flex items-center justify-center p-[0.25rem] border-l border-gray-400 dark:border-gray-200 hover:cursor-move"
class="non-styled-move cursor-move flex items-center justify-center p-[0.25rem] border-l border-gray-400 dark:border-gray-200 hover:cursor-move"
data-tauri-drag-region
>
<IconCapMoreVertical
class="text-gray-400 dark:text-gray-400"
data-tauri-drag-region
/>
<IconCapMoreVertical class="text-gray-400 dark:text-gray-400" />
</div>
</div>
);
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/styles/theme.css
Original file line number Diff line number Diff line change
@@ -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);
}

Expand Down
3 changes: 3 additions & 0 deletions packages/ui-solid/src/auto-imports.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down Expand Up @@ -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']
Expand All @@ -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']
Expand Down

1 comment on commit 0dccfea

@vercel
Copy link

@vercel vercel bot commented on 0dccfea Nov 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.