Skip to content

Commit 2528bde

Browse files
Merge pull request #1451 from its-thepoe/main
feat(desktop): add timeline slider to crop modal for frame-accurate cropping
2 parents 1193ab0 + 62110cc commit 2528bde

File tree

14 files changed

+336
-36
lines changed

14 files changed

+336
-36
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,4 @@ tauri.windows.conf.json
5050
# Cursor
5151
.cursor
5252
.env*.local
53+
.docs/

apps/desktop/app.config.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,5 +37,21 @@ export default defineConfig({
3737
define: {
3838
"import.meta.vitest": "undefined",
3939
},
40+
optimizeDeps: {
41+
include: [
42+
"@tauri-apps/plugin-os",
43+
"@tanstack/solid-query",
44+
"@tauri-apps/api/webviewWindow",
45+
"@tauri-apps/plugin-dialog",
46+
"@tauri-apps/plugin-store",
47+
"posthog-js",
48+
"uuid",
49+
"@tauri-apps/plugin-clipboard-manager",
50+
"@tauri-apps/api/window",
51+
"@tauri-apps/api/core",
52+
"@tauri-apps/api/event",
53+
"cva",
54+
],
55+
},
4056
}),
4157
});

apps/desktop/src-tauri/src/general_settings.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ fn default_enable_native_camera_preview() -> bool {
134134
}
135135

136136
fn default_enable_new_recording_flow() -> bool {
137-
true
137+
false
138138
}
139139

140140
fn no(_: &bool) -> bool {
@@ -259,7 +259,7 @@ pub fn init(app: &AppHandle) {
259259
};
260260

261261
if !store.recording_picker_preference_set {
262-
store.enable_new_recording_flow = true;
262+
store.enable_new_recording_flow = false;
263263
store.recording_picker_preference_set = true;
264264
}
265265

apps/desktop/src-tauri/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2699,6 +2699,8 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) {
26992699
async move {
27002700
if !permissions.screen_recording.permitted()
27012701
|| !permissions.accessibility.permitted()
2702+
|| !permissions.microphone.permitted()
2703+
|| !permissions.camera.permitted()
27022704
|| GeneralSettingsStore::get(&app)
27032705
.ok()
27042706
.flatten()

apps/desktop/src/App.tsx

Lines changed: 106 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
import { Router, useCurrentMatches } from "@solidjs/router";
2-
import { FileRoutes } from "@solidjs/start/router";
1+
import { Route, Router, useCurrentMatches } from "@solidjs/router";
32
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
43
import {
54
getCurrentWebviewWindow,
65
type WebviewWindow,
76
} from "@tauri-apps/api/webviewWindow";
87
import { message } from "@tauri-apps/plugin-dialog";
9-
import { createEffect, onCleanup, onMount, Suspense } from "solid-js";
8+
import { createEffect, lazy, onCleanup, onMount, Suspense } from "solid-js";
109
import { Toaster } from "solid-toast";
1110

1211
import "@cap/ui-solid/main.css";
@@ -19,6 +18,61 @@ import { initAnonymousUser } from "./utils/analytics";
1918
import { type AppTheme, commands } from "./utils/tauri";
2019
import titlebar from "./utils/titlebar-state";
2120

21+
const WindowChromeLayout = lazy(() => import("./routes/(window-chrome)"));
22+
const MainPage = lazy(() => import("./routes/(window-chrome)/(main)"));
23+
const NewMainPage = lazy(() => import("./routes/(window-chrome)/new-main"));
24+
const SetupPage = lazy(() => import("./routes/(window-chrome)/setup"));
25+
const SettingsLayout = lazy(() => import("./routes/(window-chrome)/settings"));
26+
const SettingsGeneralPage = lazy(
27+
() => import("./routes/(window-chrome)/settings/general"),
28+
);
29+
const SettingsRecordingsPage = lazy(
30+
() => import("./routes/(window-chrome)/settings/recordings"),
31+
);
32+
const SettingsScreenshotsPage = lazy(
33+
() => import("./routes/(window-chrome)/settings/screenshots"),
34+
);
35+
const SettingsHotkeysPage = lazy(
36+
() => import("./routes/(window-chrome)/settings/hotkeys"),
37+
);
38+
const SettingsChangelogPage = lazy(
39+
() => import("./routes/(window-chrome)/settings/changelog"),
40+
);
41+
const SettingsFeedbackPage = lazy(
42+
() => import("./routes/(window-chrome)/settings/feedback"),
43+
);
44+
const SettingsExperimentalPage = lazy(
45+
() => import("./routes/(window-chrome)/settings/experimental"),
46+
);
47+
const SettingsLicensePage = lazy(
48+
() => import("./routes/(window-chrome)/settings/license"),
49+
);
50+
const SettingsIntegrationsPage = lazy(
51+
() => import("./routes/(window-chrome)/settings/integrations"),
52+
);
53+
const SettingsS3ConfigPage = lazy(
54+
() => import("./routes/(window-chrome)/settings/integrations/s3-config"),
55+
);
56+
const UpgradePage = lazy(() => import("./routes/(window-chrome)/upgrade"));
57+
const UpdatePage = lazy(() => import("./routes/(window-chrome)/update"));
58+
const CameraPage = lazy(() => import("./routes/camera"));
59+
const CaptureAreaPage = lazy(() => import("./routes/capture-area"));
60+
const DebugPage = lazy(() => import("./routes/debug"));
61+
const EditorPage = lazy(() => import("./routes/editor"));
62+
const InProgressRecordingPage = lazy(
63+
() => import("./routes/in-progress-recording"),
64+
);
65+
const ModeSelectPage = lazy(() => import("./routes/mode-select"));
66+
const NotificationsPage = lazy(() => import("./routes/notifications"));
67+
const RecordingsOverlayPage = lazy(() => import("./routes/recordings-overlay"));
68+
const ScreenshotEditorPage = lazy(() => import("./routes/screenshot-editor"));
69+
const TargetSelectOverlayPage = lazy(
70+
() => import("./routes/target-select-overlay"),
71+
);
72+
const WindowCaptureOccluderPage = lazy(
73+
() => import("./routes/window-capture-occluder"),
74+
);
75+
2276
const queryClient = new QueryClient({
2377
defaultOptions: {
2478
queries: {
@@ -97,7 +151,55 @@ function Inner() {
97151
);
98152
}}
99153
>
100-
<FileRoutes />
154+
<Route path="/" component={WindowChromeLayout}>
155+
<Route path="/" component={MainPage} />
156+
<Route path="/new-main" component={NewMainPage} />
157+
<Route path="/setup" component={SetupPage} />
158+
<Route path="/settings" component={SettingsLayout}>
159+
<Route path="/" component={SettingsGeneralPage} />
160+
<Route path="/general" component={SettingsGeneralPage} />
161+
<Route path="/recordings" component={SettingsRecordingsPage} />
162+
<Route path="/screenshots" component={SettingsScreenshotsPage} />
163+
<Route path="/hotkeys" component={SettingsHotkeysPage} />
164+
<Route path="/changelog" component={SettingsChangelogPage} />
165+
<Route path="/feedback" component={SettingsFeedbackPage} />
166+
<Route
167+
path="/experimental"
168+
component={SettingsExperimentalPage}
169+
/>
170+
<Route path="/license" component={SettingsLicensePage} />
171+
<Route
172+
path="/integrations"
173+
component={SettingsIntegrationsPage}
174+
/>
175+
<Route
176+
path="/integrations/s3-config"
177+
component={SettingsS3ConfigPage}
178+
/>
179+
</Route>
180+
<Route path="/upgrade" component={UpgradePage} />
181+
<Route path="/update" component={UpdatePage} />
182+
</Route>
183+
<Route path="/camera" component={CameraPage} />
184+
<Route path="/capture-area" component={CaptureAreaPage} />
185+
<Route path="/debug" component={DebugPage} />
186+
<Route path="/editor" component={EditorPage} />
187+
<Route
188+
path="/in-progress-recording"
189+
component={InProgressRecordingPage}
190+
/>
191+
<Route path="/mode-select" component={ModeSelectPage} />
192+
<Route path="/notifications" component={NotificationsPage} />
193+
<Route path="/recordings-overlay" component={RecordingsOverlayPage} />
194+
<Route path="/screenshot-editor" component={ScreenshotEditorPage} />
195+
<Route
196+
path="/target-select-overlay"
197+
component={TargetSelectOverlayPage}
198+
/>
199+
<Route
200+
path="/window-capture-occluder"
201+
component={WindowCaptureOccluderPage}
202+
/>
101203
</Router>
102204
</CapErrorBoundary>
103205
</>

apps/desktop/src/entry-client.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
// @refresh reload
22
import { mount, StartClient } from "@solidjs/start/client";
3-
import { type } from "@tauri-apps/plugin-os";
43

5-
document.documentElement.classList.add(`platform-${type()}`);
6-
mount(() => <StartClient />, document.getElementById("app")!);
4+
async function initApp() {
5+
try {
6+
const { type } = await import("@tauri-apps/plugin-os");
7+
const osType = type();
8+
document.documentElement.classList.add(`platform-${osType}`);
9+
} catch (error) {
10+
console.error("Failed to get OS type:", error);
11+
}
12+
13+
mount(() => <StartClient />, document.getElementById("app")!);
14+
}
15+
16+
initApp();

apps/desktop/src/routes/(window-chrome).tsx

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,6 @@ import {
1313
WindowChromeContext,
1414
} from "./(window-chrome)/Context";
1515

16-
export const route = {
17-
info: {
18-
AUTO_SHOW_WINDOW: false,
19-
},
20-
};
21-
2216
export default function (props: RouteSectionProps) {
2317
let unlistenResize: UnlistenFn | undefined;
2418

apps/desktop/src/routes/(window-chrome)/setup.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,17 @@ const permissions = [
3939
description:
4040
"During recording, Cap collects mouse activity locally to generate automatic zoom in segments.",
4141
},
42+
{
43+
name: "Microphone",
44+
key: "microphone" as const,
45+
description: "This permission is required to record audio in your Caps.",
46+
},
47+
{
48+
name: "Camera",
49+
key: "camera" as const,
50+
description:
51+
"This permission is required to record your camera in your Caps.",
52+
},
4253
] as const;
4354

4455
export default function () {
@@ -83,7 +94,6 @@ export default function () {
8394
);
8495

8596
const handleContinue = () => {
86-
// Just proceed to the main window without saving mode to store
8797
commands.showWindow({ Main: { init_target_mode: null } }).then(() => {
8898
getCurrentWindow().close();
8999
});

apps/desktop/src/routes/editor/Editor.tsx

Lines changed: 110 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,15 @@ import { ExportDialog } from "./ExportDialog";
4444
import { Header } from "./Header";
4545
import { PlayerContent } from "./Player";
4646
import { Timeline } from "./Timeline";
47-
import { Dialog, DialogContent, EditorButton, Input, Subfield } from "./ui";
47+
import {
48+
Dialog,
49+
DialogContent,
50+
EditorButton,
51+
Input,
52+
Slider,
53+
Subfield,
54+
} from "./ui";
55+
import { formatTime } from "./utils";
4856

4957
const DEFAULT_TIMELINE_HEIGHT = 260;
5058
const MIN_PLAYER_CONTENT_HEIGHT = 320;
@@ -414,13 +422,61 @@ function Dialogs() {
414422
})()}
415423
>
416424
{(dialog) => {
417-
const { setProject: setState, editorInstance } =
418-
useEditorContext();
425+
const {
426+
setProject: setState,
427+
editorInstance,
428+
editorState,
429+
totalDuration,
430+
project,
431+
} = useEditorContext();
419432
const display = editorInstance.recordings.segments[0].display;
420433

421434
let cropperRef: CropperRef | undefined;
435+
let videoRef: HTMLVideoElement | undefined;
422436
const [crop, setCrop] = createSignal(CROP_ZERO);
423437
const [aspect, setAspect] = createSignal<Ratio | null>(null);
438+
const [previewTime, setPreviewTime] = createSignal(
439+
editorState.playbackTime,
440+
);
441+
const [videoLoaded, setVideoLoaded] = createSignal(false);
442+
443+
const currentSegment = createMemo(() => {
444+
const time = previewTime();
445+
let elapsed = 0;
446+
for (const seg of project.timeline?.segments ?? []) {
447+
const segDuration = (seg.end - seg.start) / seg.timescale;
448+
if (time < elapsed + segDuration) {
449+
return {
450+
index: seg.recordingSegment ?? 0,
451+
localTime: seg.start / seg.timescale + (time - elapsed),
452+
};
453+
}
454+
elapsed += segDuration;
455+
}
456+
return { index: 0, localTime: 0 };
457+
});
458+
459+
const videoSrc = createMemo(() =>
460+
convertFileSrc(
461+
`${editorInstance.path}/content/segments/segment-${currentSegment().index}/display.mp4`,
462+
),
463+
);
464+
465+
createEffect(
466+
on(
467+
() => currentSegment().index,
468+
() => {
469+
setVideoLoaded(false);
470+
},
471+
{ defer: true },
472+
),
473+
);
474+
475+
createEffect(() => {
476+
if (videoRef && videoLoaded()) {
477+
videoRef.currentTime = currentSegment().localTime;
478+
}
479+
});
424480

425481
const initialBounds = {
426482
x: dialog().position.x,
@@ -582,16 +638,60 @@ function Dialogs() {
582638
allowLightMode={true}
583639
onContextMenu={(e) => showCropOptionsMenu(e, true)}
584640
>
585-
<img
586-
class="shadow pointer-events-none max-h-[70vh]"
587-
alt="screenshot"
588-
src={convertFileSrc(
589-
`${editorInstance.path}/screenshots/display.jpg`,
590-
)}
591-
/>
641+
<div class="relative">
642+
<img
643+
class="shadow pointer-events-none max-h-[70vh]"
644+
alt="screenshot"
645+
src={convertFileSrc(
646+
`${editorInstance.path}/screenshots/display.jpg`,
647+
)}
648+
style={{
649+
opacity: videoLoaded() ? 0 : 1,
650+
transition: "opacity 150ms ease-out",
651+
}}
652+
/>
653+
<video
654+
ref={videoRef}
655+
src={videoSrc()}
656+
class="absolute inset-0 w-full h-full object-contain shadow pointer-events-none"
657+
preload="auto"
658+
muted
659+
onLoadedData={() => setVideoLoaded(true)}
660+
onError={() => setVideoLoaded(false)}
661+
style={{
662+
opacity: videoLoaded() ? 1 : 0,
663+
transition: "opacity 150ms ease-out",
664+
}}
665+
/>
666+
<Show when={!videoLoaded()}>
667+
<div class="absolute bottom-2 right-2 flex items-center gap-1.5 bg-gray-500/80 rounded-full px-2 py-1">
668+
<IconLucideLoaderCircle class="size-3 text-white animate-spin" />
669+
<span class="text-[10px] text-white font-medium">
670+
Loading
671+
</span>
672+
</div>
673+
</Show>
674+
</div>
592675
</Cropper>
593676
</div>
594677
</div>
678+
<div class="flex items-center gap-3 mt-4 px-2">
679+
<span class="text-gray-11 text-sm tabular-nums min-w-[3rem]">
680+
{formatTime(previewTime())}
681+
</span>
682+
<Slider
683+
class="flex-1"
684+
minValue={0}
685+
maxValue={totalDuration()}
686+
step={1 / FPS}
687+
value={[previewTime()]}
688+
onChange={([v]) => setPreviewTime(v)}
689+
aria-label="Video timeline"
690+
/>
691+
<span class="text-gray-11 text-sm tabular-nums min-w-[3rem] text-right">
692+
{formatTime(totalDuration())}
693+
</span>
694+
</div>
595695
</Dialog.Content>
596696
<Dialog.Footer>
597697
<Button

0 commit comments

Comments
 (0)