diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/ChangeLogButton.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/ChangeLogButton.tsx index 46c7d527b20..e01c49bb873 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/ChangeLogButton.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/ChangeLogButton.tsx @@ -1,16 +1,25 @@ import { makePersisted } from "@solid-primitives/storage"; import { getVersion } from "@tauri-apps/api/app"; import { createEffect, createResource } from "solid-js"; -import { createStore } from "solid-js/store"; +import { createStore, type SetStoreFunction } from "solid-js/store"; import Tooltip from "~/components/Tooltip"; import { hideCurrentWindow } from "~/utils/hide-window"; import { commands } from "~/utils/tauri"; import { apiClient } from "~/utils/web-api"; import IconLucideBell from "~icons/lucide/bell"; +type ChangelogState = { + hasUpdate: boolean; + lastOpenedVersion: string; + changelogClicked: boolean; +}; + const ChangelogButton = () => { - const [changelogState, setChangelogState] = makePersisted( - createStore({ + const [changelogState, setChangelogState] = makePersisted< + ChangelogState, + [ChangelogState, SetStoreFunction] + >( + createStore({ hasUpdate: false, lastOpenedVersion: "", changelogClicked: false, diff --git a/apps/desktop/src/routes/(window-chrome)/onboarding.tsx b/apps/desktop/src/routes/(window-chrome)/onboarding.tsx index 8ef45a58638..f72a664b75b 100644 --- a/apps/desktop/src/routes/(window-chrome)/onboarding.tsx +++ b/apps/desktop/src/routes/(window-chrome)/onboarding.tsx @@ -15,7 +15,7 @@ import { onMount, Show, } from "solid-js"; -import { createStore } from "solid-js/store"; +import { createStore, type SetStoreFunction } from "solid-js/store"; import { generalSettingsStore } from "~/store"; import { isPermissionGranted as isPermitted, @@ -1700,10 +1700,12 @@ function StartupOverlay(props: { isExiting: boolean; onGetStarted: () => void; }) { - const [audioState, setAudioState] = makePersisted( - createStore({ isMuted: false }), - { name: "audioSettings" }, - ); + const [audioState, setAudioState] = makePersisted< + { isMuted: boolean }, + [{ isMuted: boolean }, SetStoreFunction<{ isMuted: boolean }>] + >(createStore<{ isMuted: boolean }>({ isMuted: false }), { + name: "audioSettings", + }); let audioEl: HTMLAudioElement | undefined; let cloud1Animation: Animation | undefined; diff --git a/apps/desktop/src/routes/camera.tsx b/apps/desktop/src/routes/camera.tsx index 715652f69e0..2c29bb44cf1 100644 --- a/apps/desktop/src/routes/camera.tsx +++ b/apps/desktop/src/routes/camera.tsx @@ -20,7 +20,7 @@ import { Show, Suspense, } from "solid-js"; -import { createStore } from "solid-js/store"; +import { createStore, type SetStoreFunction } from "solid-js/store"; import { CAMERA_DEFAULT_SIZE, CAMERA_MAX_SIZE, @@ -237,10 +237,12 @@ function NativeCameraPreviewPage(props: { }) { const isCameraOnlyMode = () => getCameraOnlyMode(); - const [state, setState] = makePersisted( - createStore(getDefaultCameraWindowState()), - { name: CAMERA_WINDOW_STATE_STORAGE_KEY }, - ); + const [state, setState] = makePersisted< + CameraWindowState, + [CameraWindowState, SetStoreFunction] + >(createStore(getDefaultCameraWindowState()), { + name: CAMERA_WINDOW_STATE_STORAGE_KEY, + }); const centerCameraOnlyWindow = () => { ignoreMoveFor(1500); @@ -369,10 +371,12 @@ function LegacyCameraPreviewPage(props: { const currentRecording = createCurrentRecordingQuery(); const isInstantRecording = () => currentRecording.data?.mode === "instant"; - const [state, setState] = makePersisted( - createStore(getDefaultCameraWindowState()), - { name: CAMERA_WINDOW_STATE_STORAGE_KEY }, - ); + const [state, setState] = makePersisted< + CameraWindowState, + [CameraWindowState, SetStoreFunction] + >(createStore(getDefaultCameraWindowState()), { + name: CAMERA_WINDOW_STATE_STORAGE_KEY, + }); const centerCameraOnlyWindow = () => { ignoreMoveFor(1500); diff --git a/apps/desktop/src/routes/capture-area.tsx b/apps/desktop/src/routes/capture-area.tsx index 0a2a3feeb17..61135b40ba5 100644 --- a/apps/desktop/src/routes/capture-area.tsx +++ b/apps/desktop/src/routes/capture-area.tsx @@ -9,7 +9,7 @@ import { } from "@tauri-apps/api/webviewWindow"; import { type as ostype } from "@tauri-apps/plugin-os"; import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"; -import { createStore, reconcile } from "solid-js/store"; +import { createStore, reconcile, type SetStoreFunction } from "solid-js/store"; import { Transition } from "solid-transition-group"; import { CROP_ZERO, @@ -42,11 +42,19 @@ export default function CaptureArea() { onCleanup(() => unlisten()); }); - const [state, setState] = makePersisted( - createStore<{ - snapToRatio: boolean; - lastSelectedBounds: { screenId: DisplayId; bounds: CropBounds }[]; - }>({ snapToRatio: true, lastSelectedBounds: [] }), + type CaptureAreaState = { + snapToRatio: boolean; + lastSelectedBounds: { screenId: DisplayId; bounds: CropBounds }[]; + }; + + const [state, setState] = makePersisted< + CaptureAreaState, + [CaptureAreaState, SetStoreFunction] + >( + createStore({ + snapToRatio: true, + lastSelectedBounds: [], + }), { name: "capture-area", }, diff --git a/apps/desktop/src/routes/editor/Editor.tsx b/apps/desktop/src/routes/editor/Editor.tsx index aac65a6c360..4f894964eca 100644 --- a/apps/desktop/src/routes/editor/Editor.tsx +++ b/apps/desktop/src/routes/editor/Editor.tsx @@ -26,6 +26,7 @@ import { onCleanup, onMount, Show, + type Signal, Suspense, Switch, } from "solid-js"; @@ -577,10 +578,12 @@ function Inner(props: { const [layoutRef, setLayoutRef] = createSignal(); const layoutBounds = createElementBounds(layoutRef); - const [userTimelineHeight, setUserTimelineHeight] = makePersisted( - createSignal(null), - { name: "editorTimelineHeightOverride" }, - ); + const [userTimelineHeight, setUserTimelineHeight] = makePersisted< + number | null, + Signal + >(createSignal(null), { + name: "editorTimelineHeightOverride", + }); const [isResizingTimeline, setIsResizingTimeline] = createSignal(false); const [timelineContentHeight, setTimelineContentHeight] = createSignal( DEFAULT_TIMELINE_CONTENT_HEIGHT, @@ -848,7 +851,7 @@ function Inner(props: { const MAX_SPLIT_RATIO = 0.75; const DEFAULT_SPLIT_RATIO = 0.5; - const [splitRatio, setSplitRatio] = makePersisted( + const [splitRatio, setSplitRatio] = makePersisted>( createSignal(DEFAULT_SPLIT_RATIO), { name: "editorTranscriptSplitRatio" }, ); @@ -1433,10 +1436,10 @@ function Dialogs() { height: dialog().size.y, }; - const [snapToRatio, setSnapToRatioEnabled] = makePersisted( - createSignal(true), - { name: "editorCropSnapToRatio" }, - ); + const [snapToRatio, setSnapToRatioEnabled] = makePersisted< + boolean, + Signal + >(createSignal(true), { name: "editorCropSnapToRatio" }); async function showCropOptionsMenu( e: UIEvent, diff --git a/apps/desktop/src/routes/editor/ExportPage.tsx b/apps/desktop/src/routes/editor/ExportPage.tsx index 68e60f5f6ac..d459c85983c 100644 --- a/apps/desktop/src/routes/editor/ExportPage.tsx +++ b/apps/desktop/src/routes/editor/ExportPage.tsx @@ -24,7 +24,12 @@ import { Switch, type ValidComponent, } from "solid-js"; -import { createStore, produce, reconcile } from "solid-js/store"; +import { + createStore, + produce, + reconcile, + type SetStoreFunction, +} from "solid-js/store"; import { Dynamic } from "solid-js/web"; import toast from "solid-toast"; import { Toggle } from "~/components/Toggle"; @@ -210,7 +215,10 @@ export function ExportPage() { (error.message === "Export cancelled" || error.message === "Save dialog cancelled")); - const [_settings, setSettings] = makePersisted( + const [_settings, setSettings] = makePersisted< + Settings, + [Settings, SetStoreFunction] + >( createStore({ format: "Mp4", fps: 30, diff --git a/apps/desktop/src/routes/editor/TranscriptPage.tsx b/apps/desktop/src/routes/editor/TranscriptPage.tsx index 738f640781a..8c3d0c131b3 100644 --- a/apps/desktop/src/routes/editor/TranscriptPage.tsx +++ b/apps/desktop/src/routes/editor/TranscriptPage.tsx @@ -10,6 +10,7 @@ import { For, on, Show, + type Signal, } from "solid-js"; import { produce } from "solid-js/store"; import toast from "solid-toast"; @@ -80,7 +81,10 @@ export function TranscriptPanel() { const recordingSegments = () => editorInstance.recordings.segments; - const [textSizeIndex, setTextSizeIndex] = makePersisted(createSignal(1), { + const [textSizeIndex, setTextSizeIndex] = makePersisted< + number, + Signal + >(createSignal(1), { name: "editorTranscriptTextSize", }); const [exportingFormat, setExportingFormat] = diff --git a/apps/desktop/src/routes/editor/editor-skeleton.tsx b/apps/desktop/src/routes/editor/editor-skeleton.tsx index 091b59d4ff2..7388d7ef1f1 100644 --- a/apps/desktop/src/routes/editor/editor-skeleton.tsx +++ b/apps/desktop/src/routes/editor/editor-skeleton.tsx @@ -1,7 +1,7 @@ import { createElementBounds } from "@solid-primitives/bounds"; import { makePersisted } from "@solid-primitives/storage"; import { type as ostype } from "@tauri-apps/plugin-os"; -import { createSignal, For, Show } from "solid-js"; +import { createSignal, For, Show, type Signal } from "solid-js"; import CaptionControlsMacOS from "~/components/titlebar/controls/CaptionControlsMacOS"; import CaptionControlsWindows11 from "~/components/titlebar/controls/CaptionControlsWindows11"; import { DEFAULT_TIMELINE_HEIGHT, editorVerticalLayout } from "./editor-layout"; @@ -191,9 +191,12 @@ export function EditorSkeleton() { const model = usePreparingEditorModel(); const [layoutRef, setLayoutRef] = createSignal(); const bounds = createElementBounds(layoutRef); - const [savedHeight] = makePersisted(createSignal(null), { - name: "editorTimelineHeightOverride", - }); + const [savedHeight] = makePersisted>( + createSignal(null), + { + name: "editorTimelineHeightOverride", + }, + ); const layout = () => editorVerticalLayout( (bounds.height ?? 576) - 16, diff --git a/apps/desktop/src/routes/recordings-overlay.tsx b/apps/desktop/src/routes/recordings-overlay.tsx index 0ff8f77a3b5..1a2be3ef5a7 100644 --- a/apps/desktop/src/routes/recordings-overlay.tsx +++ b/apps/desktop/src/routes/recordings-overlay.tsx @@ -96,14 +96,14 @@ export default function () { document.body.style.background = "transparent"; }); - const [recordings, setRecordings] = makePersisted( - createStore([]), - { name: "recordings-store" }, - ); - const [screenshots, setScreenshots] = makePersisted( - createStore([]), - { name: "screenshots-store" }, - ); + const [recordings, setRecordings] = makePersisted< + MediaEntry[], + [MediaEntry[], SetStoreFunction] + >(createStore([]), { name: "recordings-store" }); + const [screenshots, setScreenshots] = makePersisted< + MediaEntry[], + [MediaEntry[], SetStoreFunction] + >(createStore([]), { name: "screenshots-store" }); const addMediaEntry = (path: string, type?: "recording" | "screenshot") => { const setMedia = type === "screenshot" ? setScreenshots : setRecordings; diff --git a/apps/desktop/src/routes/screenshot-editor/Editor.tsx b/apps/desktop/src/routes/screenshot-editor/Editor.tsx index 86bffee703b..c9e602fc2b6 100644 --- a/apps/desktop/src/routes/screenshot-editor/Editor.tsx +++ b/apps/desktop/src/routes/screenshot-editor/Editor.tsx @@ -11,6 +11,7 @@ import { onCleanup, onMount, Show, + type Signal, Switch, } from "solid-js"; import { unwrap } from "solid-js/store"; @@ -288,10 +289,10 @@ function Dialogs() { }; }; - const [snapToRatio, setSnapToRatioEnabled] = makePersisted( - createSignal(true), - { name: "editorCropSnapToRatio" }, - ); + const [snapToRatio, setSnapToRatioEnabled] = makePersisted< + boolean, + Signal + >(createSignal(true), { name: "editorCropSnapToRatio" }); async function showCropOptionsMenu( e: UIEvent, diff --git a/apps/desktop/src/routes/screenshot-editor/context.tsx b/apps/desktop/src/routes/screenshot-editor/context.tsx index c5930fe71e0..9b7b231a5e2 100644 --- a/apps/desktop/src/routes/screenshot-editor/context.tsx +++ b/apps/desktop/src/routes/screenshot-editor/context.tsx @@ -9,6 +9,7 @@ import { createSignal, on, onCleanup, + type Signal, } from "solid-js"; import { createStore, reconcile, unwrap } from "solid-js/store"; import { createLazySignal, type FrameData } from "~/utils/socket"; @@ -188,10 +189,10 @@ function createScreenshotEditorContext() { const [activeTool, setActiveTool] = createSignal("select"); - const [layersPanelOpen, setLayersPanelOpen] = makePersisted( - createSignal(false), - { name: "screenshotEditorLayersPanelOpen" }, - ); + const [layersPanelOpen, setLayersPanelOpen] = makePersisted< + boolean, + Signal + >(createSignal(false), { name: "screenshotEditorLayersPanelOpen" }); const [focusAnnotationId, setFocusAnnotationId] = createSignal( null, ); diff --git a/apps/desktop/src/routes/target-select-overlay.tsx b/apps/desktop/src/routes/target-select-overlay.tsx index bf7816745e6..9038f7e0caf 100644 --- a/apps/desktop/src/routes/target-select-overlay.tsx +++ b/apps/desktop/src/routes/target-select-overlay.tsx @@ -32,7 +32,7 @@ import { Suspense, Switch, } from "solid-js"; -import { createStore, reconcile } from "solid-js/store"; +import { createStore, reconcile, type SetStoreFunction } from "solid-js/store"; import toast from "solid-toast"; import { CAMERA_DEFAULT_SIZE, @@ -201,7 +201,10 @@ function Inner() { overlayInstance: string; }>(); const [options, setOptions] = useOptions(); - const [areaSelectionPreferences, setAreaSelectionPreferences] = makePersisted( + const [areaSelectionPreferences, setAreaSelectionPreferences] = makePersisted< + AreaSelectionPreferences, + [AreaSelectionPreferences, SetStoreFunction] + >( createStore( createDefaultAreaSelectionPreferences(), ), @@ -1536,10 +1539,12 @@ const WS_STALL_TIMEOUT_MS = 2000; function CameraPreviewInline() { const { rawOptions } = useRecordingOptions(); - const [state, setState] = makePersisted( - createStore(getDefaultCameraWindowState()), - { name: CAMERA_WINDOW_STATE_STORAGE_KEY }, - ); + const [state, setState] = makePersisted< + CameraWindowState, + [CameraWindowState, SetStoreFunction] + >(createStore(getDefaultCameraWindowState()), { + name: CAMERA_WINDOW_STATE_STORAGE_KEY, + }); const [hasFrame, setHasFrame] = createSignal(false); const [frameDimensions, setFrameDimensions] = createSignal<{ width: number; diff --git a/apps/desktop/src/utils/queries.ts b/apps/desktop/src/utils/queries.ts index 70fa5ce0e58..8fb6befa417 100644 --- a/apps/desktop/src/utils/queries.ts +++ b/apps/desktop/src/utils/queries.ts @@ -8,7 +8,7 @@ import { } from "@tanstack/solid-query"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { batch, createEffect, createMemo, onCleanup } from "solid-js"; -import { createStore, reconcile } from "solid-js/store"; +import { createStore, reconcile, type SetStoreFunction } from "solid-js/store"; import { useRecordingOptions } from "~/routes/(window-chrome)/OptionsContext"; import { authStore, @@ -184,21 +184,23 @@ function isStoredCameraId(value: unknown): value is DeviceOrModelID | null { return false; } +type RecordingOptionsState = { + captureTarget: CameraCaptureTarget; + micName: string | null; + mode: RecordingMode; + captureSystemAudio?: boolean; + targetMode?: ExtendedRecordingTargetMode; + targetModeSource?: RecordingTargetModeSource; + targetModeDismissal?: TargetModeDismissal | null; + cameraID?: DeviceOrModelID | null; + organizationId?: string | null; + /** @deprecated */ + cameraLabel: string | null; +}; + export function createOptionsQuery() { const PERSIST_KEY = "recording-options-query-2"; - const [_state, _setState] = createStore<{ - captureTarget: CameraCaptureTarget; - micName: string | null; - mode: RecordingMode; - captureSystemAudio?: boolean; - targetMode?: ExtendedRecordingTargetMode; - targetModeSource?: RecordingTargetModeSource; - targetModeDismissal?: TargetModeDismissal | null; - cameraID?: DeviceOrModelID | null; - organizationId?: string | null; - /** @deprecated */ - cameraLabel: string | null; - }>({ + const [_state, _setState] = createStore({ captureTarget: { variant: "display", id: "0" }, micName: null, cameraLabel: null, @@ -291,7 +293,10 @@ export function createOptionsQuery() { }); onCleanup(() => storeListenerCleanup.then((c) => c())); - const [state, setState] = makePersisted([_state, _setState], { + const [state, setState] = makePersisted< + RecordingOptionsState, + [RecordingOptionsState, SetStoreFunction] + >([_state, _setState], { name: PERSIST_KEY, }); if (state.cameraID !== undefined && !isStoredCameraId(state.cameraID)) { diff --git a/apps/web/__tests__/unit/embed-playback-speed.test.ts b/apps/web/__tests__/unit/embed-playback-speed.test.ts index ef1b41d2a6b..5f04209973a 100644 --- a/apps/web/__tests__/unit/embed-playback-speed.test.ts +++ b/apps/web/__tests__/unit/embed-playback-speed.test.ts @@ -14,6 +14,7 @@ vi.mock("@cap/env", () => ({ vi.mock("@cap/ui", () => ({ Logo: () => null })); vi.mock("@cap/utils", () => ({ userIsPro: () => true })); vi.mock("@cap/web-backend", () => ({ + ImageUploads: Context.GenericTag("ImageUploads"), VideosPolicy: Context.GenericTag("EmbedTestPolicy"), provideOptionalAuth: (effect: Effect.Effect) => effect, resolveEffectiveVideoRules: () => ({ settings: {} }), @@ -24,6 +25,9 @@ vi.mock("@/lib/server", () => ({ Effect.provideService(Context.GenericTag("EmbedTestPolicy"), { canView: () => Effect.void, }), + Effect.provideService(Context.GenericTag("ImageUploads"), { + resolveImageUrl: () => Effect.succeed(""), + }), Effect.runPromise, ), })); diff --git a/apps/web/lib/mcp-card-html.json b/apps/web/lib/mcp-card-html.json index 30b83d7dcaf..077f6ae8acc 100644 --- a/apps/web/lib/mcp-card-html.json +++ b/apps/web/lib/mcp-card-html.json @@ -1,3 +1,3 @@ { - "html": "
Cap

Recording

Loading recording context…

Transcript

" + "html": "
Cap

Recording

Loading recording context…

Transcript

" } diff --git a/crates/recording/src/sources/microphone.rs b/crates/recording/src/sources/microphone.rs index a81ea9da8f5..89d2bcb9cfd 100644 --- a/crates/recording/src/sources/microphone.rs +++ b/crates/recording/src/sources/microphone.rs @@ -40,6 +40,17 @@ fn should_fabricate_stall_silence(stall_duration: Duration, keepalive_after: Dur stall_duration >= keepalive_after } +fn is_audio_starved(captured_delta: u64, silence_delta: u64, is_muted: bool) -> bool { + if is_muted { + return false; + } + let total = captured_delta.saturating_add(silence_delta); + if total == 0 || silence_delta == 0 { + return false; + } + (100.0 * silence_delta as f64 / total as f64) >= 80.0 +} + struct ReconnectAttemptGuard(Arc); impl Drop for ReconnectAttemptGuard { @@ -683,6 +694,7 @@ impl AudioSource for Microphone { crate::output_pipeline::spawn_capture_task({ let cancel = cancel.clone(); let health_tx = health_tx.clone(); + let recording_muted = recording_muted.clone(); async move { let frame_counter = mic_frame_counter; let drop_counter = mic_drop_counter; @@ -690,8 +702,12 @@ impl AudioSource for Microphone { let mut last_log = Instant::now(); let mut prev_captured: u64 = 0; let mut prev_dropped: u64 = 0; + let mut prev_silence: u64 = 0; let mut stale_count: u32 = 0; let mut high_drop_intervals: u32 = 0; + let mut high_silence_intervals: u32 = 0; + let mut silence_stall_start: Option = None; + let mut silence_stall_emitted = false; loop { tokio::select! { biased; @@ -714,9 +730,13 @@ impl AudioSource for Microphone { let captured_delta = captured.saturating_sub(prev_captured); let dropped_delta = dropped.saturating_sub(prev_dropped); - let data_changed = captured != prev_captured || dropped != prev_dropped; + let silence_delta = silence.saturating_sub(prev_silence); + let data_changed = captured != prev_captured + || dropped != prev_dropped + || silence != prev_silence; prev_captured = captured; prev_dropped = dropped; + prev_silence = silence; // Surface a *sustained* high drop rate as a health event, like // every other source/muxer. The case that matters most is a @@ -746,6 +766,31 @@ impl AudioSource for Microphone { ); } + let is_muted = recording_muted.load(Ordering::Relaxed); + if is_audio_starved(captured_delta, silence_delta, is_muted) { + if silence_stall_start.is_none() { + silence_stall_start = Some(Instant::now()); + } + high_silence_intervals = high_silence_intervals.saturating_add(1); + } else { + high_silence_intervals = 0; + silence_stall_start = None; + silence_stall_emitted = false; + } + if high_silence_intervals >= 2 && !silence_stall_emitted { + silence_stall_emitted = true; + let waited_ms = silence_stall_start + .map(|s| s.elapsed().as_millis() as u64) + .unwrap_or(0); + emit_health( + &health_tx, + PipelineHealthEvent::Stalled { + source: "microphone".to_string(), + waited_ms, + }, + ); + } + if !data_changed { stale_count = stale_count.saturating_add(1); } else { @@ -1336,6 +1381,16 @@ mod tests { assert!(prev.is_some(), "resampler produced no frames"); } + + #[test] + fn audio_starvation_detects_sustained_silence_dominance() { + assert!(!is_audio_starved(100, 0, false)); + assert!(!is_audio_starved(100, 10, false)); + assert!(is_audio_starved(0, 100, false)); + assert!(is_audio_starved(5, 95, false)); + assert!(!is_audio_starved(0, 100, true)); + assert!(!is_audio_starved(0, 0, false)); + } } #[cfg(all(test, target_os = "linux"))]