Skip to content

Commit c6fa07d

Browse files
committed
feat(desktop): add camera chrome controls to camera-only overlay preview
1 parent a24b158 commit c6fa07d

1 file changed

Lines changed: 140 additions & 68 deletions

File tree

apps/desktop/src/routes/target-select-overlay.tsx

Lines changed: 140 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Button } from "@cap/ui-solid";
22
import { createEventListener } from "@solid-primitives/event-listener";
33
import { createElementSize } from "@solid-primitives/resize-observer";
4+
import { makePersisted } from "@solid-primitives/storage";
45
import { useSearchParams } from "@solidjs/router";
56
import { createMutation, useQuery } from "@tanstack/solid-query";
67
import { invoke } from "@tauri-apps/api/core";
@@ -32,6 +33,19 @@ import {
3233
} from "solid-js";
3334
import { createStore, reconcile } from "solid-js/store";
3435
import toast from "solid-toast";
36+
import {
37+
CAMERA_DEFAULT_SIZE,
38+
CAMERA_PRESET_LARGE,
39+
CAMERA_WINDOW_STATE_STORAGE_KEY,
40+
CameraPreviewToolbar,
41+
CameraResizeHandles,
42+
type CameraWindowState,
43+
cameraBorderRadius,
44+
cameraToolbarScale,
45+
clampCameraSize,
46+
getDefaultCameraWindowState,
47+
normalizeBackgroundBlurMode,
48+
} from "~/components/CameraPreviewChrome";
3549
import {
3650
CROP_ZERO,
3751
type CropBounds,
@@ -315,10 +329,8 @@ function Inner() {
315329
Record using only your camera and microphone
316330
</span>
317331
</div>
318-
<div class="w-full max-w-[480px] px-6 mb-4">
319-
<div class="w-full aspect-video rounded-2xl border border-gray-6 bg-black overflow-hidden">
320-
<CameraPreviewInline />
321-
</div>
332+
<div class="flex justify-center w-full px-6 mb-4">
333+
<CameraPreviewInline />
322334
</div>
323335
<RecordingControls
324336
target={{ variant: "cameraOnly" } as ScreenCaptureTarget}
@@ -1169,10 +1181,18 @@ const WS_STALL_TIMEOUT_MS = 2000;
11691181

11701182
function CameraPreviewInline() {
11711183
const { rawOptions } = useRecordingOptions();
1184+
const [state, setState] = makePersisted(
1185+
createStore<CameraWindowState>(getDefaultCameraWindowState()),
1186+
{ name: CAMERA_WINDOW_STATE_STORAGE_KEY },
1187+
);
11721188
const [frame, setFrame] = createSignal<ImageData | null>(null);
11731189
const [connectionFailed, setConnectionFailed] = createSignal(false);
1190+
const [chromeVisible, setChromeVisible] = createSignal(false);
1191+
const [viewportSize, setViewportSize] = createSignal({
1192+
width: window.innerWidth,
1193+
height: window.innerHeight,
1194+
});
11741195
let canvasRef: HTMLCanvasElement | undefined;
1175-
let containerRef: HTMLDivElement | undefined;
11761196
let ws: WebSocket | undefined;
11771197
let retryCount = 0;
11781198
let reconnectTimeoutId: ReturnType<typeof setTimeout> | undefined;
@@ -1186,6 +1206,36 @@ function CameraPreviewInline() {
11861206
const cameraWsPort = window.__CAP__?.cameraWsPort;
11871207
const hasCameraSelected = () => rawOptions.cameraID !== null;
11881208

1209+
createEventListener(window, "resize", () => {
1210+
setViewportSize({
1211+
width: window.innerWidth,
1212+
height: window.innerHeight,
1213+
});
1214+
});
1215+
1216+
createEffect(() => {
1217+
let currentSize = state.size as number | string;
1218+
if (typeof currentSize !== "number" || Number.isNaN(currentSize)) {
1219+
currentSize =
1220+
currentSize === "lg" ? CAMERA_PRESET_LARGE : CAMERA_DEFAULT_SIZE;
1221+
setState("size", currentSize);
1222+
return;
1223+
}
1224+
1225+
const clampedSize = clampCameraSize(currentSize);
1226+
if (clampedSize !== currentSize) {
1227+
setState("size", clampedSize);
1228+
return;
1229+
}
1230+
1231+
commands.setCameraPreviewState({
1232+
size: state.size,
1233+
shape: state.shape,
1234+
mirrored: state.mirrored,
1235+
background_blur: normalizeBackgroundBlurMode(state.backgroundBlur),
1236+
});
1237+
});
1238+
11891239
const getReusableFrame = (width: number, height: number) => {
11901240
if (
11911241
!reusableFrame ||
@@ -1434,44 +1484,39 @@ function CameraPreviewInline() {
14341484
ws?.close();
14351485
});
14361486

1437-
const [containerSize, setContainerSize] = createSignal<{
1438-
width: number;
1439-
height: number;
1440-
} | null>(null);
1441-
1442-
onMount(() => {
1443-
if (!containerRef) return;
1444-
const observer = new ResizeObserver(() => {
1445-
if (!containerRef) return;
1446-
const rect = containerRef.getBoundingClientRect();
1447-
setContainerSize({ width: rect.width, height: rect.height });
1448-
});
1449-
observer.observe(containerRef);
1450-
onCleanup(() => observer.disconnect());
1451-
});
1452-
1453-
const canvasStyle = () => {
1487+
const previewDimensions = () => {
14541488
const f = frame();
1455-
const cs = containerSize();
1456-
if (!f || !cs || cs.width === 0 || cs.height === 0) return {};
1489+
const aspect = f ? f.width / f.height : 16 / 9;
1490+
const size = clampCameraSize(state.size);
1491+
const width = state.shape === "full" && aspect >= 1 ? size * aspect : size;
1492+
const height =
1493+
state.shape === "full" ? (aspect >= 1 ? size : size / aspect) : size;
1494+
const viewport = viewportSize();
1495+
const maxWidth = Math.max(160, viewport.width - 48);
1496+
const maxHeight = Math.max(160, viewport.height - 320);
1497+
const scale = Math.min(1, maxWidth / width, maxHeight / height);
14571498

1458-
const frameAspect = f.width / f.height;
1459-
const containerAspect = cs.width / cs.height;
1460-
1461-
let displayWidth: number;
1462-
let displayHeight: number;
1499+
return {
1500+
height: Math.round(height * scale),
1501+
width: Math.round(width * scale),
1502+
};
1503+
};
14631504

1464-
if (frameAspect > containerAspect) {
1465-
displayWidth = cs.width;
1466-
displayHeight = cs.width / frameAspect;
1467-
} else {
1468-
displayHeight = cs.height;
1469-
displayWidth = cs.height * frameAspect;
1470-
}
1505+
const previewFrameStyle = () => {
1506+
const dimensions = previewDimensions();
1507+
return {
1508+
"border-radius": cameraBorderRadius(state),
1509+
height: `${dimensions.height}px`,
1510+
width: `${dimensions.width}px`,
1511+
};
1512+
};
14711513

1514+
const canvasStyle = () => {
14721515
return {
1473-
width: `${Math.round(displayWidth)}px`,
1474-
height: `${Math.round(displayHeight)}px`,
1516+
height: "100%",
1517+
"object-fit": "cover" as const,
1518+
transform: state.mirrored ? "scaleX(-1)" : "scaleX(1)",
1519+
width: "100%",
14751520
};
14761521
};
14771522

@@ -1496,41 +1541,68 @@ function CameraPreviewInline() {
14961541

14971542
return (
14981543
<div
1499-
ref={containerRef}
1500-
class="flex items-center justify-center w-full h-full bg-black"
1544+
class="flex flex-col items-center max-w-full"
1545+
onPointerMove={() => setChromeVisible(true)}
1546+
onPointerLeave={() => setChromeVisible(false)}
1547+
onPointerCancel={() => setChromeVisible(false)}
15011548
>
1502-
<Show
1503-
when={hasCameraSelected()}
1504-
fallback={
1505-
<div class="flex flex-col items-center gap-2 text-center px-4">
1506-
<IconCapCamera class="size-8 text-gray-9 mb-2" />
1507-
<div class="text-sm text-gray-11">Please select a camera</div>
1508-
</div>
1509-
}
1510-
>
1511-
<Show
1512-
when={!connectionFailed()}
1513-
fallback={
1514-
<div class="flex flex-col items-center gap-2 text-center px-4">
1515-
<div class="text-sm text-red-400">Camera connection failed</div>
1516-
<button
1517-
type="button"
1518-
onClick={handleRetryConnection}
1519-
class="text-xs text-blue-400 hover:text-blue-300 underline"
1520-
>
1521-
Try again
1522-
</button>
1523-
</div>
1524-
}
1549+
<div class="h-14 flex items-center justify-center">
1550+
<CameraPreviewToolbar
1551+
state={state}
1552+
setState={setState}
1553+
visible={chromeVisible()}
1554+
scale={cameraToolbarScale(state.size)}
1555+
/>
1556+
</div>
1557+
<div class="relative shadow-lg" style={previewFrameStyle()}>
1558+
<div
1559+
class="flex items-center justify-center w-full h-full overflow-hidden border border-gray-6 bg-black text-gray-11"
1560+
style={{ "border-radius": "inherit" }}
15251561
>
15261562
<Show
1527-
when={frame()}
1528-
fallback={<div class="text-sm text-gray-11">Loading camera...</div>}
1563+
when={hasCameraSelected()}
1564+
fallback={
1565+
<div class="flex flex-col items-center gap-2 text-center px-4">
1566+
<IconCapCamera class="size-8 text-gray-9 mb-2" />
1567+
<div class="text-sm text-gray-11">Please select a camera</div>
1568+
</div>
1569+
}
15291570
>
1530-
<canvas ref={canvasRef} style={canvasStyle()} />
1571+
<Show
1572+
when={!connectionFailed()}
1573+
fallback={
1574+
<div class="flex flex-col items-center gap-2 text-center px-4">
1575+
<div class="text-sm text-red-400">
1576+
Camera connection failed
1577+
</div>
1578+
<button
1579+
type="button"
1580+
onClick={handleRetryConnection}
1581+
class="text-xs text-blue-400 hover:text-blue-300 underline"
1582+
>
1583+
Try again
1584+
</button>
1585+
</div>
1586+
}
1587+
>
1588+
<Show
1589+
when={frame()}
1590+
fallback={
1591+
<div class="text-sm text-gray-11">Loading camera...</div>
1592+
}
1593+
>
1594+
<canvas ref={canvasRef} style={canvasStyle()} />
1595+
</Show>
1596+
</Show>
15311597
</Show>
1532-
</Show>
1533-
</Show>
1598+
</div>
1599+
<CameraResizeHandles
1600+
state={state}
1601+
setState={setState}
1602+
toolbarHeight={0}
1603+
visible={chromeVisible()}
1604+
/>
1605+
</div>
15341606
</div>
15351607
);
15361608
}

0 commit comments

Comments
 (0)