@agent-native/core 0.175.2-nightly-20260826150235 → 0.175.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/README.md +1 -1
- package/corpus/templates/clips/app/components/player/comments-panel.tsx +1 -1
- package/corpus/templates/clips/app/components/player/video-player.tsx +5 -3
- package/corpus/templates/clips/desktop/src/app.tsx +171 -51
- package/corpus/templates/clips/desktop/src/lib/pill-completion.ts +111 -0
- package/corpus/templates/clips/desktop/src/lib/pill-session.ts +34 -0
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +181 -79
- package/corpus/templates/clips/desktop/src/lib/rewind-recording-start.ts +28 -4
- package/corpus/templates/clips/desktop/src/main.tsx +4 -4
- package/corpus/templates/clips/desktop/src/overlays/record-pill.tsx +1320 -0
- package/corpus/templates/clips/desktop/src/overlays/recording-pill.tsx +1 -1
- package/corpus/templates/clips/desktop/src/styles.css +20 -308
- package/corpus/templates/clips/desktop/src/tailwind.css +85 -0
- package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +153 -70
- package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +15 -0
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +25 -7
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen/live_upload.rs +3 -3
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +375 -24
- package/corpus/templates/clips/desktop/src-tauri/src/recording_indicator.rs +22 -30
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_clip.rs +5 -0
- package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +134 -14
- package/corpus/templates/clips/desktop/src-tauri/src/tray.rs +233 -4
- package/corpus/templates/clips/desktop/src-tauri/src/util.rs +8 -5
- package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +1 -1
- package/corpus/templates/design/.generated/bridge/embedded-wheel.generated.ts +3 -2
- package/corpus/templates/design/.generated/bridge/zoom.generated.ts +8 -2
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +46 -10
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +89 -63
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +3 -1
- package/corpus/templates/design/app/components/design/bridge/embedded-wheel.bridge.ts +8 -2
- package/corpus/templates/design/app/components/design/bridge/zoom.bridge.ts +11 -2
- package/corpus/templates/design/app/components/design/multi-screen/types.ts +3 -4
- package/corpus/templates/design/app/components/design/multi-screen/zoom-gesture.ts +19 -63
- package/corpus/templates/design/app/pages/design-editor/overview-camera.ts +2 -1
- package/corpus/templates/design/shared/canvas-math.ts +11 -1
- package/corpus/templates/slides/app/components/editor/EditorSidebar.tsx +44 -18
- package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +88 -23
- package/corpus/templates/slides/app/context/DeckContext.tsx +155 -86
- package/corpus/templates/slides/app/pages/DeckEditor.tsx +7 -5
- package/corpus/templates/slides/changelog/2026-08-26-thumbnail-arrow-keys-now-navigate-slides-after-selection.md +6 -0
- package/dist/client/hooks/index.d.ts +1 -0
- package/dist/client/hooks/index.js +1 -0
- package/dist/client/use-pinch-zoom.js +17 -4
- package/dist/client/zoom-gesture.d.ts +74 -0
- package/dist/client/zoom-gesture.js +100 -0
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +3 -3
- package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
- package/dist/provider-api/actions/provider-api.d.ts +13 -13
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/builder-browser.d.ts +13 -13
- package/dist/server/builder-browser.js +46 -15
- package/dist/server/builder-oauth.d.ts +12 -0
- package/dist/server/builder-oauth.js +13 -2
- package/dist/server/core-routes-plugin.js +54 -19
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/vite/client.js +2 -0
- package/docs/content/template-chat-developers.mdx +100 -13
- package/docs/content/template-chat-first-edits.mdx +5 -5
- package/docs/content/template-chat.mdx +84 -36
- package/package.json +4 -3
- package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +0 -458
|
@@ -0,0 +1,1320 @@
|
|
|
1
|
+
import {
|
|
2
|
+
IconAlertTriangle,
|
|
3
|
+
IconCheck,
|
|
4
|
+
IconCopy,
|
|
5
|
+
IconLink,
|
|
6
|
+
IconLoader2,
|
|
7
|
+
IconPlayerPauseFilled,
|
|
8
|
+
IconPlayerPlayFilled,
|
|
9
|
+
IconRefresh,
|
|
10
|
+
IconTrash,
|
|
11
|
+
IconX,
|
|
12
|
+
} from "@tabler/icons-react";
|
|
13
|
+
import { invoke } from "@tauri-apps/api/core";
|
|
14
|
+
import { PhysicalPosition, PhysicalSize } from "@tauri-apps/api/dpi";
|
|
15
|
+
import { emit, listen } from "@tauri-apps/api/event";
|
|
16
|
+
import { currentMonitor, getCurrentWindow } from "@tauri-apps/api/window";
|
|
17
|
+
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
|
18
|
+
import { open as openExternal } from "@tauri-apps/plugin-shell";
|
|
19
|
+
import { useEffect, useRef, useState } from "react";
|
|
20
|
+
import type { FocusEvent } from "react";
|
|
21
|
+
import { flushSync } from "react-dom";
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
completionCardState,
|
|
25
|
+
isCompletionForSession,
|
|
26
|
+
resolveCompletion,
|
|
27
|
+
} from "../lib/pill-completion";
|
|
28
|
+
import type {
|
|
29
|
+
NativeUploadFinished,
|
|
30
|
+
PillDoneStage as DoneStage,
|
|
31
|
+
} from "../lib/pill-completion";
|
|
32
|
+
import { toolbarEnabledEffect } from "../lib/pill-session";
|
|
33
|
+
import type { PillMode } from "../lib/pill-session";
|
|
34
|
+
|
|
35
|
+
const SEG_MS = 180;
|
|
36
|
+
const HOVER_INTENT_MS = 150;
|
|
37
|
+
// Within this distance of the right screen edge the pill anchors its RIGHT
|
|
38
|
+
// edge and grows left instead, so growth never runs off-screen.
|
|
39
|
+
const RIGHT_EDGE_ANCHOR_PX = 200;
|
|
40
|
+
const FINALIZING_RESULT_STORAGE_KEY = "clips-finalizing-result";
|
|
41
|
+
|
|
42
|
+
type Seg = "mid" | "q" | "del" | "res" | "extras";
|
|
43
|
+
|
|
44
|
+
type RecorderSession = {
|
|
45
|
+
viewUrl?: string | null;
|
|
46
|
+
recordingId?: string | null;
|
|
47
|
+
localOnly?: boolean;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const hasTauri = "__TAURI_INTERNALS__" in window;
|
|
51
|
+
const demoMode = import.meta.env.DEV && !hasTauri;
|
|
52
|
+
|
|
53
|
+
function safeEmit(event: string, payload?: unknown): Promise<void> {
|
|
54
|
+
if (!hasTauri) return Promise.resolve();
|
|
55
|
+
return emit(event, payload);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function safeInvoke<T = unknown>(
|
|
59
|
+
cmd: string,
|
|
60
|
+
args?: Record<string, unknown>,
|
|
61
|
+
): Promise<T | undefined> {
|
|
62
|
+
if (!hasTauri) return Promise.resolve(undefined);
|
|
63
|
+
return invoke<T>(cmd, args).then(
|
|
64
|
+
(v) => v,
|
|
65
|
+
(err) => {
|
|
66
|
+
console.warn(`[record-pill] invoke ${cmd} failed:`, err);
|
|
67
|
+
return undefined;
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function safeListen<T>(
|
|
73
|
+
event: string,
|
|
74
|
+
cb: (payload: T) => void,
|
|
75
|
+
): Promise<() => void> {
|
|
76
|
+
if (!hasTauri) return Promise.resolve(() => {});
|
|
77
|
+
return listen<T>(event, (ev) => cb(ev.payload));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function formatTimer(ms: number): string {
|
|
81
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
82
|
+
const totalMin = Math.floor(total / 60);
|
|
83
|
+
// Past 99:59 the m:ss form overflows its slot; switch to a compact h:mm so
|
|
84
|
+
// the anchored controls never shift for a marathon take.
|
|
85
|
+
if (totalMin >= 100) {
|
|
86
|
+
const h = Math.floor(totalMin / 60);
|
|
87
|
+
const m = totalMin % 60;
|
|
88
|
+
return `${h}:${m.toString().padStart(2, "0")}h`;
|
|
89
|
+
}
|
|
90
|
+
const s = total % 60;
|
|
91
|
+
return `${totalMin}:${s.toString().padStart(2, "0")}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function formatDurationCopy(ms: number): string {
|
|
95
|
+
const total = Math.max(0, Math.round(ms / 1000));
|
|
96
|
+
if (total < 60) return `${total} sec`;
|
|
97
|
+
return `${Math.round(total / 60)} min`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The recording pill — one dark capsule with four modes: recording, paused,
|
|
102
|
+
* confirm, done. The leading stop square is the recording indicator and the
|
|
103
|
+
* stop/save action — red while live, grey-white while paused — with the
|
|
104
|
+
* timer sharing its color, plain against the chrome. Every control is a bare
|
|
105
|
+
* glyph; no rings or fills. Left edge anchored: hover extras
|
|
106
|
+
* and the inline confirms grow rightward while the stop circle, timer, and
|
|
107
|
+
* pause button hold position (near the right screen edge the anchor
|
|
108
|
+
* mirrors). Stop swaps the
|
|
109
|
+
* pill for the completion card in place; the link is copied only when the
|
|
110
|
+
* user clicks Copy (an automatic copy would clear their clipboard
|
|
111
|
+
* unannounced). While paused the pause circle swaps to a play glyph — the
|
|
112
|
+
* amber dot carries the paused state; the button carries the way back.
|
|
113
|
+
* Pure command emitter — the recorder in the popover window owns
|
|
114
|
+
* capture, and drives us through the same IPC contract the old toolbar used:
|
|
115
|
+
*
|
|
116
|
+
* receives → `clips:recorder-state` { paused, elapsedMs },
|
|
117
|
+
* `clips:toolbar-enabled`, `clips:toolbar-preparing`,
|
|
118
|
+
* `clips:recorder-session` { viewUrl, recordingId, localOnly },
|
|
119
|
+
* `clips:native-upload-progress` / `-finished`,
|
|
120
|
+
* `voice:audio-level` { level, source }
|
|
121
|
+
* emits → `clips:recorder-stop`, `:pause`, `:resume`, `:restart`,
|
|
122
|
+
* `:cancel`, `clips:toolbar-ready`
|
|
123
|
+
*
|
|
124
|
+
* Stop must NOT close this window: it invokes `set_toolbar_finishing(true)`
|
|
125
|
+
* BEFORE emitting stop so every teardown path skips the toolbar label, then
|
|
126
|
+
* renders the completion card here until the user dismisses it.
|
|
127
|
+
*/
|
|
128
|
+
export function RecordingPill() {
|
|
129
|
+
const [mode, setMode] = useState<PillMode>("recording");
|
|
130
|
+
const [paused, setPaused] = useState(false);
|
|
131
|
+
const [elapsed, setElapsed] = useState(0);
|
|
132
|
+
const [enabled, setEnabled] = useState(demoMode);
|
|
133
|
+
const [barHeights, setBarHeights] = useState<number[]>([4, 4, 4, 4, 4]);
|
|
134
|
+
const [announcement, setAnnouncement] = useState("");
|
|
135
|
+
const [confirmQuestion, setConfirmQuestion] = useState("");
|
|
136
|
+
// What the confirm's action button does. Both intents share the confirm
|
|
137
|
+
// strip, but only delete is destructive-red; restart is a start-fresh
|
|
138
|
+
// action and wears the pill's white affirmative treatment, same family
|
|
139
|
+
// as Stop.
|
|
140
|
+
const [confirmIntent, setConfirmIntent] = useState<"delete" | "restart">(
|
|
141
|
+
"delete",
|
|
142
|
+
);
|
|
143
|
+
// Whether the delete confirm was entered from an already-paused recording:
|
|
144
|
+
// Esc then backs out to that pause, while the Resume button always resumes.
|
|
145
|
+
const confirmFromPausedRef = useRef(false);
|
|
146
|
+
const [doneStage, setDoneStage] = useState<DoneStage>("finishing");
|
|
147
|
+
const [doneDurationMs, setDoneDurationMs] = useState(0);
|
|
148
|
+
const [copied, setCopied] = useState(false);
|
|
149
|
+
const [savedLocally, setSavedLocally] = useState(false);
|
|
150
|
+
const [pendingAction, setPendingAction] = useState<
|
|
151
|
+
"restart" | "cancel" | null
|
|
152
|
+
>(null);
|
|
153
|
+
|
|
154
|
+
const modeRef = useRef<PillMode>("recording");
|
|
155
|
+
const elapsedRef = useRef(0);
|
|
156
|
+
const sessionRef = useRef<RecorderSession>({});
|
|
157
|
+
const [viewUrl, setViewUrl] = useState<string | null>(null);
|
|
158
|
+
const viewUrlRef = useRef<string | null>(null);
|
|
159
|
+
const reducedRef = useRef(
|
|
160
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches,
|
|
161
|
+
);
|
|
162
|
+
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
163
|
+
const revealedRef = useRef(false);
|
|
164
|
+
const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
165
|
+
const shrinkTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
166
|
+
const stallTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
167
|
+
const levelDecayRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
168
|
+
const animatingUntilRef = useRef(0);
|
|
169
|
+
const pauseTransitionRef = useRef<"pause" | "resume" | null>(null);
|
|
170
|
+
const pauseTransitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
|
171
|
+
null,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const settleBatchRef = useRef(0);
|
|
175
|
+
const pendingSegsRef = useRef<Set<Seg>>(new Set());
|
|
176
|
+
const finishSettleRef = useRef<(() => void) | null>(null);
|
|
177
|
+
const pillRef = useRef<HTMLDivElement | null>(null);
|
|
178
|
+
const cardRef = useRef<HTMLDivElement | null>(null);
|
|
179
|
+
const segRefs = useRef<Record<Seg, HTMLSpanElement | null>>({
|
|
180
|
+
mid: null,
|
|
181
|
+
q: null,
|
|
182
|
+
del: null,
|
|
183
|
+
res: null,
|
|
184
|
+
extras: null,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
modeRef.current = mode;
|
|
188
|
+
elapsedRef.current = elapsed;
|
|
189
|
+
viewUrlRef.current = viewUrl;
|
|
190
|
+
|
|
191
|
+
// Auto-gain state: raw mic peaks rarely pass ~0.3 even when shouting, so a
|
|
192
|
+
// fixed scale leaves the meter nearly flat. Levels are normalized against a
|
|
193
|
+
// slowly-decaying rolling peak, like a real meter's AGC.
|
|
194
|
+
const meterGainRef = useRef(0.04);
|
|
195
|
+
const meterHistoryRef = useRef<number[]>([0, 0, 0, 0, 0]);
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Each level event pushes one normalized sample through a five-slot
|
|
199
|
+
* history, so the lines are the actual signal scrolling by — a waveform,
|
|
200
|
+
* not a glyph — and shouting pins them to the top of the range.
|
|
201
|
+
*/
|
|
202
|
+
function applyMicLevel(raw: number) {
|
|
203
|
+
const level = Math.max(0, Math.min(1, raw));
|
|
204
|
+
meterGainRef.current = Math.max(level, meterGainRef.current * 0.985, 0.04);
|
|
205
|
+
const norm = Math.min(1, level / meterGainRef.current) ** 0.7;
|
|
206
|
+
const history = meterHistoryRef.current;
|
|
207
|
+
history.shift();
|
|
208
|
+
history.push(norm);
|
|
209
|
+
setBarHeights(history.map((v) => 3 + Math.round(v * 11)));
|
|
210
|
+
if (levelDecayRef.current) clearTimeout(levelDecayRef.current);
|
|
211
|
+
levelDecayRef.current = setTimeout(() => {
|
|
212
|
+
meterHistoryRef.current = [0, 0, 0, 0, 0];
|
|
213
|
+
setBarHeights([4, 4, 4, 4, 4]);
|
|
214
|
+
}, 350);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function clearPauseTransition() {
|
|
218
|
+
pauseTransitionRef.current = null;
|
|
219
|
+
if (pauseTransitionTimerRef.current) {
|
|
220
|
+
clearTimeout(pauseTransitionTimerRef.current);
|
|
221
|
+
pauseTransitionTimerRef.current = null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---- segment motion: measure natural width, animate exact pixels ----
|
|
226
|
+
|
|
227
|
+
function segInnerWidth(k: Seg): number {
|
|
228
|
+
const el = segRefs.current[k];
|
|
229
|
+
const inner = el?.firstElementChild as HTMLElement | null;
|
|
230
|
+
if (!inner) return 0;
|
|
231
|
+
return Math.ceil(inner.getBoundingClientRect().width);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function segCurrentWidth(k: Seg): number {
|
|
235
|
+
const el = segRefs.current[k];
|
|
236
|
+
if (!el) return 0;
|
|
237
|
+
return el.getBoundingClientRect().width;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function setSeg(k: Seg, open: boolean, delayMs = 0) {
|
|
241
|
+
const el = segRefs.current[k];
|
|
242
|
+
if (!el) return;
|
|
243
|
+
// A segment that still sits at `auto` (the Stop capsule before its first
|
|
244
|
+
// collapse) cannot animate FROM auto — snap it to its measured pixels
|
|
245
|
+
// and force a reflow so the width transition has a number to leave from.
|
|
246
|
+
if (!open && (!el.style.width || el.style.width === "auto")) {
|
|
247
|
+
el.style.transition = "none";
|
|
248
|
+
el.style.width = `${segCurrentWidth(k)}px`;
|
|
249
|
+
void el.offsetWidth;
|
|
250
|
+
}
|
|
251
|
+
const reduced = reducedRef.current;
|
|
252
|
+
el.style.transition = reduced
|
|
253
|
+
? "none"
|
|
254
|
+
: `width ${SEG_MS}ms var(--pill-ease), opacity ${SEG_MS}ms ease`;
|
|
255
|
+
el.style.transitionDelay = reduced ? "0ms" : `${delayMs}ms`;
|
|
256
|
+
el.style.width = open ? `${segInnerWidth(k)}px` : "0px";
|
|
257
|
+
el.style.opacity = open ? "1" : "0";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Native window ops run strictly one at a time. Concurrent
|
|
261
|
+
// setSize/setPosition sequences read stale rects out from under each other
|
|
262
|
+
// and strand the window clipped and offset (a half-cut pill with content
|
|
263
|
+
// painting past the window edge). Every op re-reads geometry at execution
|
|
264
|
+
// time inside the chain.
|
|
265
|
+
const windowOpChainRef = useRef<Promise<void>>(Promise.resolve());
|
|
266
|
+
function queueWindowOp(op: () => Promise<void>) {
|
|
267
|
+
windowOpChainRef.current = windowOpChainRef.current
|
|
268
|
+
.then(op)
|
|
269
|
+
.catch((err) => {
|
|
270
|
+
console.warn("[record-pill] window op failed", err);
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Resize the native window around the content, keeping the pill's anchor
|
|
276
|
+
* edge fixed. The left edge is the anchor unless the pill sits within
|
|
277
|
+
* RIGHT_EDGE_ANCHOR_PX of the screen's right edge — then the right edge
|
|
278
|
+
* holds and growth extends left. Height keeps the bottom edge fixed so the
|
|
279
|
+
* taller completion card rises from where the pill sat.
|
|
280
|
+
*/
|
|
281
|
+
function resizeWindowTo(contentW: number, contentH: number) {
|
|
282
|
+
if (!hasTauri) return;
|
|
283
|
+
queueWindowOp(async () => {
|
|
284
|
+
const win = getCurrentWindow();
|
|
285
|
+
const [pos, size, scale, monitor] = await Promise.all([
|
|
286
|
+
win.outerPosition(),
|
|
287
|
+
win.outerSize(),
|
|
288
|
+
win.scaleFactor(),
|
|
289
|
+
currentMonitor(),
|
|
290
|
+
]);
|
|
291
|
+
const w = Math.ceil(contentW * scale);
|
|
292
|
+
const h = Math.ceil(contentH * scale);
|
|
293
|
+
let x = pos.x;
|
|
294
|
+
if (monitor) {
|
|
295
|
+
const monRight = monitor.position.x + monitor.size.width;
|
|
296
|
+
const nearRightEdge =
|
|
297
|
+
pos.x + size.width >=
|
|
298
|
+
monRight - Math.round(RIGHT_EDGE_ANCHOR_PX * scale);
|
|
299
|
+
if (nearRightEdge) x = pos.x + size.width - w;
|
|
300
|
+
// Never let growth push past the screen edge — macOS shoves the
|
|
301
|
+
// window back and the correction fights the next resize.
|
|
302
|
+
x = Math.min(x, monRight - w);
|
|
303
|
+
x = Math.max(x, monitor.position.x);
|
|
304
|
+
}
|
|
305
|
+
const y = pos.y + size.height - h;
|
|
306
|
+
// Order the ops so the window never transiently overhangs: when the
|
|
307
|
+
// origin moves left/up (right-anchored growth), move first, then
|
|
308
|
+
// grow; otherwise grow first, then move.
|
|
309
|
+
if (x < pos.x || y < pos.y) {
|
|
310
|
+
await win.setPosition(new PhysicalPosition(x, y));
|
|
311
|
+
await win.setSize(new PhysicalSize(w, h));
|
|
312
|
+
} else {
|
|
313
|
+
await win.setSize(new PhysicalSize(w, h));
|
|
314
|
+
await win.setPosition(new PhysicalPosition(x, y));
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function syncWindowToContent() {
|
|
320
|
+
const el =
|
|
321
|
+
(mode === "done" ? cardRef.current : pillRef.current) ?? pillRef.current;
|
|
322
|
+
if (!el) return;
|
|
323
|
+
// offsetWidth/Height are layout metrics, immune to the card's scale-in
|
|
324
|
+
// entrance — a rect measured mid-animation locks the window too narrow.
|
|
325
|
+
resizeWindowTo(el.offsetWidth, el.offsetHeight);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Which segments are (or are animating toward) open. Live rects mid-flight
|
|
329
|
+
// under-measure a transition target, so the window budget is computed from
|
|
330
|
+
// this intent instead of from the DOM.
|
|
331
|
+
const openSegsRef = useRef<Record<Seg, boolean>>({
|
|
332
|
+
mid: true,
|
|
333
|
+
q: false,
|
|
334
|
+
del: false,
|
|
335
|
+
res: false,
|
|
336
|
+
extras: false,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Run one choreographed set of segment transitions: pre-grow the window to
|
|
341
|
+
* the post-transition budget (plus slack) so nothing clips, start every
|
|
342
|
+
* segment's width+opacity bar, then shrink to the exact measured rect once
|
|
343
|
+
* the last bar lands.
|
|
344
|
+
*/
|
|
345
|
+
function transitionSegs(changes: Array<[Seg, boolean, number]>) {
|
|
346
|
+
const pill = pillRef.current;
|
|
347
|
+
if (!pill) return;
|
|
348
|
+
for (const [k, open] of changes) openSegsRef.current[k] = open;
|
|
349
|
+
const pillW = pill.getBoundingClientRect().width;
|
|
350
|
+
const segsW = (Object.keys(openSegsRef.current) as Seg[]).reduce(
|
|
351
|
+
(sum, k) => sum + segCurrentWidth(k),
|
|
352
|
+
0,
|
|
353
|
+
);
|
|
354
|
+
const staticW = pillW - segsW;
|
|
355
|
+
const targetW = (Object.keys(openSegsRef.current) as Seg[]).reduce(
|
|
356
|
+
(sum, k) => sum + (openSegsRef.current[k] ? segInnerWidth(k) : 0),
|
|
357
|
+
staticW,
|
|
358
|
+
);
|
|
359
|
+
const maxDelay = changes.reduce((m, [, , d]) => Math.max(m, d), 0);
|
|
360
|
+
const settleMs = reducedRef.current ? 16 : SEG_MS + maxDelay + 40;
|
|
361
|
+
animatingUntilRef.current = Date.now() + settleMs;
|
|
362
|
+
if (shrinkTimerRef.current) clearTimeout(shrinkTimerRef.current);
|
|
363
|
+
// Budget the window for whichever is wider, plus slack for measurement
|
|
364
|
+
// rounding — the settle pass snaps to the exact rect.
|
|
365
|
+
resizeWindowTo(
|
|
366
|
+
Math.ceil(Math.max(pillW, targetW)) + 12,
|
|
367
|
+
Math.ceil(pill.getBoundingClientRect().height),
|
|
368
|
+
);
|
|
369
|
+
// The settle is driven by the transitions actually ending, not by a
|
|
370
|
+
// timer — a throttled clock runs them late, and snapping on a fixed
|
|
371
|
+
// schedule cuts the choreography off mid-motion.
|
|
372
|
+
const batch = ++settleBatchRef.current;
|
|
373
|
+
pendingSegsRef.current.clear();
|
|
374
|
+
for (const [k, open, delay] of changes) {
|
|
375
|
+
const before = segCurrentWidth(k);
|
|
376
|
+
setSeg(k, open, delay);
|
|
377
|
+
const el = segRefs.current[k];
|
|
378
|
+
const after = el ? Number.parseFloat(el.style.width) : before;
|
|
379
|
+
if (!reducedRef.current && Math.abs(after - before) > 0.5) {
|
|
380
|
+
pendingSegsRef.current.add(k);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
const finishSettle = () => {
|
|
384
|
+
if (settleBatchRef.current !== batch) return;
|
|
385
|
+
if (shrinkTimerRef.current) clearTimeout(shrinkTimerRef.current);
|
|
386
|
+
pendingSegsRef.current.clear();
|
|
387
|
+
animatingUntilRef.current = Date.now();
|
|
388
|
+
syncWindowToContent();
|
|
389
|
+
};
|
|
390
|
+
finishSettleRef.current = finishSettle;
|
|
391
|
+
if (pendingSegsRef.current.size === 0) {
|
|
392
|
+
finishSettle();
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
// Dead-clock fallback: if the ends never fire, force every segment to
|
|
396
|
+
// its intended state so the pill can never strand half-open. Sized well
|
|
397
|
+
// past any late-running transition so it never clips a live one.
|
|
398
|
+
shrinkTimerRef.current = setTimeout(
|
|
399
|
+
() => {
|
|
400
|
+
if (settleBatchRef.current !== batch) return;
|
|
401
|
+
for (const k of Object.keys(openSegsRef.current) as Seg[]) {
|
|
402
|
+
const el = segRefs.current[k];
|
|
403
|
+
if (!el) continue;
|
|
404
|
+
el.style.transition = "none";
|
|
405
|
+
el.style.width = openSegsRef.current[k]
|
|
406
|
+
? `${segInnerWidth(k)}px`
|
|
407
|
+
: "0px";
|
|
408
|
+
el.style.opacity = openSegsRef.current[k] ? "1" : "0";
|
|
409
|
+
}
|
|
410
|
+
finishSettle();
|
|
411
|
+
},
|
|
412
|
+
Math.max(1_000, settleMs * 3),
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Snap every segment to its resting state (Stop visible, all confirm and
|
|
417
|
+
* hover segments collapsed) with no animation — used when the recorder
|
|
418
|
+
* disables the pill (restart teardown, session reset). */
|
|
419
|
+
function resetToRest() {
|
|
420
|
+
revealedRef.current = false;
|
|
421
|
+
for (const k of ["q", "del", "res", "extras"] as Seg[]) {
|
|
422
|
+
const el = segRefs.current[k];
|
|
423
|
+
if (!el) continue;
|
|
424
|
+
el.style.transition = "none";
|
|
425
|
+
el.style.width = "0px";
|
|
426
|
+
el.style.opacity = "0";
|
|
427
|
+
openSegsRef.current[k] = false;
|
|
428
|
+
}
|
|
429
|
+
const mid = segRefs.current.mid;
|
|
430
|
+
if (mid) {
|
|
431
|
+
mid.style.transition = "none";
|
|
432
|
+
mid.style.width = "auto";
|
|
433
|
+
mid.style.opacity = "1";
|
|
434
|
+
openSegsRef.current.mid = true;
|
|
435
|
+
}
|
|
436
|
+
setMode("recording");
|
|
437
|
+
clearPauseTransition();
|
|
438
|
+
setPaused(false);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ---- reveal / confirm / done choreography ----
|
|
442
|
+
|
|
443
|
+
function reveal(open: boolean) {
|
|
444
|
+
if (open === revealedRef.current) return;
|
|
445
|
+
if (open && modeRef.current !== "recording") return;
|
|
446
|
+
revealedRef.current = open;
|
|
447
|
+
transitionSegs([["extras", open, 0]]);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const pausedRef = useRef(false);
|
|
451
|
+
pausedRef.current = paused;
|
|
452
|
+
const enabledRef = useRef(false);
|
|
453
|
+
enabledRef.current = enabled;
|
|
454
|
+
const elapsedAnchorRef = useRef<{ elapsedMs: number; at: number } | null>(
|
|
455
|
+
null,
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
// The recorder reports state every 500ms; rendering only those ticks makes
|
|
459
|
+
// the clock feel like it starts late and counts in lurches. Interpolate
|
|
460
|
+
// from the last report at 250ms so the timer runs the moment capture is
|
|
461
|
+
// live and re-anchors on every real tick.
|
|
462
|
+
useEffect(() => {
|
|
463
|
+
if (!enabled || paused || mode === "done") return;
|
|
464
|
+
const t = setInterval(() => {
|
|
465
|
+
const anchor = elapsedAnchorRef.current;
|
|
466
|
+
if (!anchor) return;
|
|
467
|
+
setElapsed(anchor.elapsedMs + (performance.now() - anchor.at));
|
|
468
|
+
}, 250);
|
|
469
|
+
return () => clearInterval(t);
|
|
470
|
+
}, [enabled, paused, mode]);
|
|
471
|
+
|
|
472
|
+
function enterConfirm(intent: "delete" | "restart") {
|
|
473
|
+
if (modeRef.current !== "recording") return;
|
|
474
|
+
const wasPaused = pausedRef.current;
|
|
475
|
+
confirmFromPausedRef.current = wasPaused;
|
|
476
|
+
setMode("confirm");
|
|
477
|
+
// The question's segment width is measured synchronously below, so the
|
|
478
|
+
// new text (and the action button's label) must be committed to the DOM
|
|
479
|
+
// before transitionSegs runs — without flushSync it would measure the
|
|
480
|
+
// previous (empty) question.
|
|
481
|
+
flushSync(() => {
|
|
482
|
+
setConfirmIntent(intent);
|
|
483
|
+
setConfirmQuestion(
|
|
484
|
+
intent === "delete"
|
|
485
|
+
? `Delete ${formatDurationCopy(elapsedRef.current)}?`
|
|
486
|
+
: "Start a new recording?",
|
|
487
|
+
);
|
|
488
|
+
});
|
|
489
|
+
// Pause at the instant of the click — the deliberation must not end up
|
|
490
|
+
// in the clip. A recording already paused by hand stays exactly as the
|
|
491
|
+
// user left it, and exiting the confirm restores that state instead of
|
|
492
|
+
// resuming behind their back.
|
|
493
|
+
if (!wasPaused) applyPauseIntent("pause");
|
|
494
|
+
revealedRef.current = false;
|
|
495
|
+
transitionSegs([
|
|
496
|
+
["extras", false, 0],
|
|
497
|
+
["mid", false, 0],
|
|
498
|
+
["q", true, 0],
|
|
499
|
+
["del", true, 20],
|
|
500
|
+
["res", true, 40],
|
|
501
|
+
]);
|
|
502
|
+
setAnnouncement("Paused");
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function exitConfirm(resume: boolean) {
|
|
506
|
+
if (modeRef.current !== "confirm") return;
|
|
507
|
+
setMode("recording");
|
|
508
|
+
if (resume && pausedRef.current) applyPauseIntent("resume");
|
|
509
|
+
else if (!resume && !confirmFromPausedRef.current) {
|
|
510
|
+
applyPauseIntent("resume");
|
|
511
|
+
}
|
|
512
|
+
transitionSegs([
|
|
513
|
+
["res", false, 0],
|
|
514
|
+
["del", false, 20],
|
|
515
|
+
["q", false, 40],
|
|
516
|
+
["mid", true, 40],
|
|
517
|
+
]);
|
|
518
|
+
setAnnouncement(pausedRef.current ? "Paused" : "Recording");
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function applyPauseIntent(transition: "pause" | "resume") {
|
|
522
|
+
clearPauseTransition();
|
|
523
|
+
pauseTransitionRef.current = transition;
|
|
524
|
+
setPaused(transition === "pause");
|
|
525
|
+
pauseTransitionTimerRef.current = setTimeout(clearPauseTransition, 3_000);
|
|
526
|
+
void safeEmit(`clips:recorder-${transition}`).catch(() => {
|
|
527
|
+
clearPauseTransition();
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function togglePause() {
|
|
532
|
+
if (!enabled || modeRef.current !== "recording") return;
|
|
533
|
+
const transition = pausedRef.current ? "resume" : "pause";
|
|
534
|
+
applyPauseIntent(transition);
|
|
535
|
+
setAnnouncement(transition === "pause" ? "Paused" : "Recording");
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Copying is always the user's click — an automatic copy would clear their
|
|
539
|
+
// clipboard without them knowing.
|
|
540
|
+
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
541
|
+
async function copyLink(url: string) {
|
|
542
|
+
try {
|
|
543
|
+
if (hasTauri) await writeText(url);
|
|
544
|
+
else await navigator.clipboard.writeText(url);
|
|
545
|
+
setCopied(true);
|
|
546
|
+
setAnnouncement("Link copied");
|
|
547
|
+
if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current);
|
|
548
|
+
copiedTimerRef.current = setTimeout(() => setCopied(false), 1_600);
|
|
549
|
+
} catch (err) {
|
|
550
|
+
console.error("[record-pill] clipboard write failed:", err);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function stop() {
|
|
555
|
+
// Guarded through the ref: the tray-stop listener holds a first-render
|
|
556
|
+
// closure of this function, where the `enabled` state is still false.
|
|
557
|
+
if (!enabledRef.current || modeRef.current === "done") return;
|
|
558
|
+
setDoneDurationMs(elapsedRef.current);
|
|
559
|
+
// Every stop — hosted or local-only — starts as "finishing" and is only
|
|
560
|
+
// called done by the completion event the stop actually produces. A
|
|
561
|
+
// local-only export that fails must not have already claimed it saved.
|
|
562
|
+
setDoneStage("finishing");
|
|
563
|
+
// Anchor the card on THIS take. The window is reused across restarts, so
|
|
564
|
+
// anything a previous session's late completion left behind has to go.
|
|
565
|
+
setViewUrl(sessionRef.current.viewUrl ?? null);
|
|
566
|
+
setSavedLocally(false);
|
|
567
|
+
setCopied(false);
|
|
568
|
+
// Hold the window open BEFORE the stop event so the recorder's teardown
|
|
569
|
+
// can't close us out from under the card.
|
|
570
|
+
void safeInvoke("set_toolbar_finishing", { hold: true }).then(() => {
|
|
571
|
+
void safeEmit("clips:recorder-stop");
|
|
572
|
+
});
|
|
573
|
+
// Pre-grow the window for the card so its entrance never renders clipped;
|
|
574
|
+
// the done-mode effect refits to the exact card rect one frame later.
|
|
575
|
+
resizeWindowTo(340, 180);
|
|
576
|
+
setMode("done");
|
|
577
|
+
if (demoMode) {
|
|
578
|
+
setTimeout(() => {
|
|
579
|
+
handleUploadFinished({
|
|
580
|
+
ok: true,
|
|
581
|
+
viewUrl: "https://clips.agent-native.com/r/demo",
|
|
582
|
+
});
|
|
583
|
+
}, 2_000);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function scheduleCloseFallback(action: string) {
|
|
588
|
+
fallbackTimerRef.current = setTimeout(() => {
|
|
589
|
+
console.warn(
|
|
590
|
+
`[record-pill] recorder did not close the pill within 3s after ${action} — self-closing`,
|
|
591
|
+
);
|
|
592
|
+
if (hasTauri)
|
|
593
|
+
getCurrentWindow()
|
|
594
|
+
.close()
|
|
595
|
+
.catch(() => {});
|
|
596
|
+
}, 3_000);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function confirmDestructive() {
|
|
600
|
+
if (pendingAction) return;
|
|
601
|
+
// The confirm strip is a question about the session that is ending here.
|
|
602
|
+
// Leave it now, before either answer is dispatched: the restart path
|
|
603
|
+
// reuses this same window for the replacement take, and a pill still in
|
|
604
|
+
// `confirm` would come back with the old question up and Stop/Pause
|
|
605
|
+
// disabled. `pendingAction` (cleared by the next `toolbar-enabled`) keeps
|
|
606
|
+
// the restart/delete glyphs disabled meanwhile.
|
|
607
|
+
resetToRest();
|
|
608
|
+
if (confirmIntent === "restart") {
|
|
609
|
+
setPendingAction("restart");
|
|
610
|
+
setElapsed(0);
|
|
611
|
+
// Hide immediately — the restart teardown and fresh countdown follow,
|
|
612
|
+
// and recording controls must not sit on screen while no capture is
|
|
613
|
+
// live. The replacement session's `clips:toolbar-enabled` re-shows the
|
|
614
|
+
// pill at 0:00, reusing this window thanks to the finishing hold.
|
|
615
|
+
setEnabled(false);
|
|
616
|
+
void safeInvoke("set_toolbar_finishing", { hold: true }).then(() => {
|
|
617
|
+
void safeEmit("clips:recorder-restart");
|
|
618
|
+
});
|
|
619
|
+
fallbackTimerRef.current = setTimeout(() => {
|
|
620
|
+
console.warn(
|
|
621
|
+
"[record-pill] recorder did not restart within 15s — self-closing",
|
|
622
|
+
);
|
|
623
|
+
void safeInvoke("set_toolbar_finishing", { hold: false });
|
|
624
|
+
if (hasTauri)
|
|
625
|
+
getCurrentWindow()
|
|
626
|
+
.close()
|
|
627
|
+
.catch(() => {});
|
|
628
|
+
}, 15_000);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
setPendingAction("cancel");
|
|
632
|
+
// Vanish now — feedback must not wait on the recorder's teardown. The
|
|
633
|
+
// window close (or its 3s fallback) follows behind.
|
|
634
|
+
setEnabled(false);
|
|
635
|
+
void safeEmit("clips:recorder-cancel").then(() =>
|
|
636
|
+
scheduleCloseFallback("cancel"),
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function dismissCard() {
|
|
641
|
+
void safeInvoke("set_toolbar_finishing", { hold: false }).then(() => {
|
|
642
|
+
if (hasTauri)
|
|
643
|
+
getCurrentWindow()
|
|
644
|
+
.close()
|
|
645
|
+
.catch(() => {});
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function handleUploadFinished(payload: NativeUploadFinished) {
|
|
650
|
+
// A completion only means something to a card that is on screen. While
|
|
651
|
+
// the pill is recording there is no card, and anything applied here would
|
|
652
|
+
// sit in state waiting to surface on the NEXT take's card — which is how
|
|
653
|
+
// a discarded take's URL reached its replacement. Nothing is lost by
|
|
654
|
+
// dropping these: the done-mode effect drains both the stored result and
|
|
655
|
+
// the localStorage hand-off when a card does open.
|
|
656
|
+
if (modeRef.current !== "done") return;
|
|
657
|
+
const completion = resolveCompletion(
|
|
658
|
+
sessionRef.current.recordingId,
|
|
659
|
+
payload,
|
|
660
|
+
);
|
|
661
|
+
if (!completion) return;
|
|
662
|
+
if (stallTimerRef.current) clearTimeout(stallTimerRef.current);
|
|
663
|
+
setSavedLocally(completion.savedLocally);
|
|
664
|
+
// The session already published this clip's link so Stop could offer Copy
|
|
665
|
+
// immediately; a payload without one must not take it away.
|
|
666
|
+
if (completion.viewUrl) setViewUrl(completion.viewUrl);
|
|
667
|
+
setDoneStage(completion.stage);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// ---- listeners ----
|
|
671
|
+
|
|
672
|
+
useEffect(() => {
|
|
673
|
+
const unlistens: Array<() => void> = [];
|
|
674
|
+
const registrations: Array<Promise<() => void>> = [];
|
|
675
|
+
let stopped = false;
|
|
676
|
+
const track = (p: Promise<() => void>) => {
|
|
677
|
+
registrations.push(
|
|
678
|
+
p.then((u) => {
|
|
679
|
+
if (stopped) u();
|
|
680
|
+
else unlistens.push(u);
|
|
681
|
+
return u;
|
|
682
|
+
}),
|
|
683
|
+
);
|
|
684
|
+
};
|
|
685
|
+
track(
|
|
686
|
+
safeListen<{ paused: boolean; elapsedMs: number }>(
|
|
687
|
+
"clips:recorder-state",
|
|
688
|
+
(payload) => {
|
|
689
|
+
const nextPaused = !!payload.paused;
|
|
690
|
+
const pending = pauseTransitionRef.current;
|
|
691
|
+
const reached =
|
|
692
|
+
(pending === "pause" && nextPaused) ||
|
|
693
|
+
(pending === "resume" && !nextPaused);
|
|
694
|
+
if (!pending || reached) setPaused(nextPaused);
|
|
695
|
+
if (reached) clearPauseTransition();
|
|
696
|
+
elapsedAnchorRef.current = {
|
|
697
|
+
elapsedMs: payload.elapsedMs ?? 0,
|
|
698
|
+
at: performance.now(),
|
|
699
|
+
};
|
|
700
|
+
setElapsed(payload.elapsedMs ?? 0);
|
|
701
|
+
},
|
|
702
|
+
),
|
|
703
|
+
);
|
|
704
|
+
track(
|
|
705
|
+
safeListen<boolean>("clips:toolbar-enabled", (payload) => {
|
|
706
|
+
setEnabled(!!payload);
|
|
707
|
+
setPendingAction(null);
|
|
708
|
+
if (fallbackTimerRef.current) {
|
|
709
|
+
clearTimeout(fallbackTimerRef.current);
|
|
710
|
+
fallbackTimerRef.current = null;
|
|
711
|
+
}
|
|
712
|
+
if (payload && !elapsedAnchorRef.current) {
|
|
713
|
+
elapsedAnchorRef.current = { elapsedMs: 0, at: performance.now() };
|
|
714
|
+
}
|
|
715
|
+
// A live session owns the pill now: release any restart hold.
|
|
716
|
+
if (payload) void safeInvoke("set_toolbar_finishing", { hold: false });
|
|
717
|
+
switch (toolbarEnabledEffect(!!payload, modeRef.current)) {
|
|
718
|
+
case "adopt-new-session":
|
|
719
|
+
setViewUrl(null);
|
|
720
|
+
setCopied(false);
|
|
721
|
+
setSavedLocally(false);
|
|
722
|
+
setDoneStage("finishing");
|
|
723
|
+
sessionRef.current = {};
|
|
724
|
+
resetToRest();
|
|
725
|
+
break;
|
|
726
|
+
case "reset-to-rest":
|
|
727
|
+
setElapsed(0);
|
|
728
|
+
elapsedAnchorRef.current = null;
|
|
729
|
+
resetToRest();
|
|
730
|
+
break;
|
|
731
|
+
case "keep":
|
|
732
|
+
break;
|
|
733
|
+
}
|
|
734
|
+
}),
|
|
735
|
+
);
|
|
736
|
+
track(
|
|
737
|
+
safeListen("clips:toolbar-sync", () => {
|
|
738
|
+
void safeEmit("clips:toolbar-ready", {});
|
|
739
|
+
}),
|
|
740
|
+
);
|
|
741
|
+
track(
|
|
742
|
+
safeListen<RecorderSession>("clips:recorder-session", (payload) => {
|
|
743
|
+
sessionRef.current = payload ?? {};
|
|
744
|
+
if (payload?.viewUrl) setViewUrl(payload.viewUrl);
|
|
745
|
+
}),
|
|
746
|
+
);
|
|
747
|
+
track(
|
|
748
|
+
safeListen<{
|
|
749
|
+
recordingId?: string;
|
|
750
|
+
stage?: string;
|
|
751
|
+
progress?: number | null;
|
|
752
|
+
}>("clips:native-upload-progress", (payload) => {
|
|
753
|
+
if (modeRef.current !== "done") return;
|
|
754
|
+
// A local-only take never uploads, so any native upload progress
|
|
755
|
+
// reaching this card belongs to some other recording.
|
|
756
|
+
if (sessionRef.current.localOnly) return;
|
|
757
|
+
if (!isCompletionForSession(sessionRef.current.recordingId, payload)) {
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
if (stallTimerRef.current) clearTimeout(stallTimerRef.current);
|
|
761
|
+
stallTimerRef.current = setTimeout(() => {
|
|
762
|
+
setDoneStage((s) => (s === "uploaded" ? s : "failed"));
|
|
763
|
+
}, 120_000);
|
|
764
|
+
if (payload?.stage && payload.stage !== "opening") {
|
|
765
|
+
setDoneStage((s) =>
|
|
766
|
+
s === "uploaded" || s === "failed" ? s : "uploading",
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
}),
|
|
770
|
+
);
|
|
771
|
+
track(
|
|
772
|
+
safeListen<NativeUploadFinished>(
|
|
773
|
+
"clips:native-upload-finished",
|
|
774
|
+
(payload) => handleUploadFinished(payload ?? {}),
|
|
775
|
+
),
|
|
776
|
+
);
|
|
777
|
+
track(
|
|
778
|
+
// The menu-bar status item doubles as a Stop button while recording;
|
|
779
|
+
// route its click through the same stop flow so the finishing hold and
|
|
780
|
+
// completion card run. Before capture is live there is nothing to stop,
|
|
781
|
+
// so the click falls back to opening Clips.
|
|
782
|
+
safeListen("clips:tray-stop-request", () => {
|
|
783
|
+
if (enabledRef.current && modeRef.current !== "done") stop();
|
|
784
|
+
else void safeInvoke("show_popover");
|
|
785
|
+
}),
|
|
786
|
+
);
|
|
787
|
+
track(
|
|
788
|
+
safeListen<{ level?: number; source?: string }>(
|
|
789
|
+
"voice:audio-level",
|
|
790
|
+
(payload) => {
|
|
791
|
+
if ((payload?.source ?? "mic") !== "mic") return;
|
|
792
|
+
applyMicLevel(Number(payload?.level) || 0);
|
|
793
|
+
},
|
|
794
|
+
),
|
|
795
|
+
);
|
|
796
|
+
// The handshake goes out only once our own listeners exist. The recorder
|
|
797
|
+
// answers `toolbar-ready` immediately, and `listen()` is asynchronous, so
|
|
798
|
+
// emitting first can drop the reply — costing the pill its enable and its
|
|
799
|
+
// session identity until some later event happens to arrive.
|
|
800
|
+
void Promise.allSettled(registrations).then((results) => {
|
|
801
|
+
const failures = results.flatMap((r) =>
|
|
802
|
+
r.status === "rejected" ? [r.reason] : [],
|
|
803
|
+
);
|
|
804
|
+
if (failures.length > 0) {
|
|
805
|
+
console.error("[record-pill] listener registration failed:", failures);
|
|
806
|
+
}
|
|
807
|
+
if (stopped) return;
|
|
808
|
+
void safeEmit("clips:toolbar-ready", {});
|
|
809
|
+
});
|
|
810
|
+
return () => {
|
|
811
|
+
stopped = true;
|
|
812
|
+
unlistens.forEach((u) => {
|
|
813
|
+
try {
|
|
814
|
+
u();
|
|
815
|
+
} catch {
|
|
816
|
+
// coercion-ok: unlisten during window teardown has no failure mode to surface
|
|
817
|
+
}
|
|
818
|
+
});
|
|
819
|
+
for (const t of [
|
|
820
|
+
hoverTimerRef,
|
|
821
|
+
fallbackTimerRef,
|
|
822
|
+
shrinkTimerRef,
|
|
823
|
+
stallTimerRef,
|
|
824
|
+
levelDecayRef,
|
|
825
|
+
pauseTransitionTimerRef,
|
|
826
|
+
]) {
|
|
827
|
+
if (t.current) clearTimeout(t.current);
|
|
828
|
+
}
|
|
829
|
+
};
|
|
830
|
+
}, []);
|
|
831
|
+
|
|
832
|
+
function handleSegTransitionEnd(e: React.TransitionEvent) {
|
|
833
|
+
if (e.propertyName !== "width") return;
|
|
834
|
+
const el = e.target as HTMLElement;
|
|
835
|
+
const entry = (Object.keys(segRefs.current) as Seg[]).find(
|
|
836
|
+
(k) => segRefs.current[k] === el,
|
|
837
|
+
);
|
|
838
|
+
if (!entry) return;
|
|
839
|
+
pendingSegsRef.current.delete(entry);
|
|
840
|
+
if (pendingSegsRef.current.size === 0) finishSettleRef.current?.();
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Escape resumes during confirm — window-level, per the spec.
|
|
844
|
+
useEffect(() => {
|
|
845
|
+
const onKey = (e: KeyboardEvent) => {
|
|
846
|
+
if (e.key === "Escape" && modeRef.current === "confirm") {
|
|
847
|
+
e.preventDefault();
|
|
848
|
+
exitConfirm(false);
|
|
849
|
+
}
|
|
850
|
+
};
|
|
851
|
+
window.addEventListener("keydown", onKey);
|
|
852
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
853
|
+
}, []);
|
|
854
|
+
|
|
855
|
+
// Fit the native window to the measured pill once fonts have settled.
|
|
856
|
+
useEffect(() => {
|
|
857
|
+
let cancelled = false;
|
|
858
|
+
const fit = () => {
|
|
859
|
+
if (cancelled) return;
|
|
860
|
+
syncWindowToContent();
|
|
861
|
+
};
|
|
862
|
+
if (document.fonts?.ready) {
|
|
863
|
+
void document.fonts.ready.then(fit);
|
|
864
|
+
} else {
|
|
865
|
+
fit();
|
|
866
|
+
}
|
|
867
|
+
return () => {
|
|
868
|
+
cancelled = true;
|
|
869
|
+
};
|
|
870
|
+
}, []);
|
|
871
|
+
|
|
872
|
+
// Done mode: card replaces the pill in place — refit the window to the
|
|
873
|
+
// card and drain any completion result the events may have raced past.
|
|
874
|
+
useEffect(() => {
|
|
875
|
+
if (mode !== "done") return;
|
|
876
|
+
requestAnimationFrame(() => syncWindowToContent());
|
|
877
|
+
stallTimerRef.current = setTimeout(() => {
|
|
878
|
+
setDoneStage((s) =>
|
|
879
|
+
s === "uploaded" ? s : s === "failed" ? s : "failed",
|
|
880
|
+
);
|
|
881
|
+
}, 120_000);
|
|
882
|
+
try {
|
|
883
|
+
const raw = window.localStorage.getItem(FINALIZING_RESULT_STORAGE_KEY);
|
|
884
|
+
if (raw) {
|
|
885
|
+
window.localStorage.removeItem(FINALIZING_RESULT_STORAGE_KEY);
|
|
886
|
+
const parsed = JSON.parse(raw) as NativeUploadFinished;
|
|
887
|
+
if (parsed && typeof parsed === "object") handleUploadFinished(parsed);
|
|
888
|
+
}
|
|
889
|
+
} catch {
|
|
890
|
+
// coercion-ok: storage is a best-effort race fallback; the event path is authoritative
|
|
891
|
+
}
|
|
892
|
+
void safeInvoke<NativeUploadFinished | null>(
|
|
893
|
+
"native_fullscreen_take_upload_finished",
|
|
894
|
+
).then((payload) => {
|
|
895
|
+
if (payload) handleUploadFinished(payload);
|
|
896
|
+
});
|
|
897
|
+
}, [mode]);
|
|
898
|
+
|
|
899
|
+
// Persist the dragged position, debounced, only while at rest so a grown
|
|
900
|
+
// pill never becomes the stored anchor.
|
|
901
|
+
useEffect(() => {
|
|
902
|
+
if (!hasTauri) return;
|
|
903
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
904
|
+
let unlisten: (() => void) | null = null;
|
|
905
|
+
void getCurrentWindow()
|
|
906
|
+
.onMoved(() => {
|
|
907
|
+
if (timer) clearTimeout(timer);
|
|
908
|
+
timer = setTimeout(() => {
|
|
909
|
+
if (
|
|
910
|
+
modeRef.current !== "recording" ||
|
|
911
|
+
revealedRef.current ||
|
|
912
|
+
Date.now() < animatingUntilRef.current
|
|
913
|
+
) {
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
void getCurrentWindow()
|
|
917
|
+
.outerPosition()
|
|
918
|
+
.then((pos) =>
|
|
919
|
+
safeInvoke("toolbar_save_position", { x: pos.x, y: pos.y }),
|
|
920
|
+
);
|
|
921
|
+
}, 600);
|
|
922
|
+
})
|
|
923
|
+
.then((u) => {
|
|
924
|
+
unlisten = u;
|
|
925
|
+
})
|
|
926
|
+
.catch(() => {});
|
|
927
|
+
return () => {
|
|
928
|
+
if (timer) clearTimeout(timer);
|
|
929
|
+
unlisten?.();
|
|
930
|
+
};
|
|
931
|
+
}, []);
|
|
932
|
+
|
|
933
|
+
// Demo drive for browser previews (no Tauri): tick the timer and meter.
|
|
934
|
+
useEffect(() => {
|
|
935
|
+
if (!demoMode) return;
|
|
936
|
+
const t = setInterval(() => {
|
|
937
|
+
if (modeRef.current !== "done" && !pausedRef.current) {
|
|
938
|
+
setElapsed((e) => e + 500);
|
|
939
|
+
}
|
|
940
|
+
}, 500);
|
|
941
|
+
const levels = setInterval(() => {
|
|
942
|
+
if (modeRef.current !== "done" && !pausedRef.current) {
|
|
943
|
+
applyMicLevel(0.05 + Math.random() * 0.3);
|
|
944
|
+
}
|
|
945
|
+
}, 90);
|
|
946
|
+
return () => {
|
|
947
|
+
clearInterval(t);
|
|
948
|
+
clearInterval(levels);
|
|
949
|
+
};
|
|
950
|
+
}, []);
|
|
951
|
+
|
|
952
|
+
// Self-healing size net: whatever strands the window at the wrong size —
|
|
953
|
+
// a resize racing a transition, a throttled animation clock finishing
|
|
954
|
+
// late, a font swap — the pill's layout size is the truth, so any drift
|
|
955
|
+
// outside a choreographed transition re-syncs the window to it.
|
|
956
|
+
useEffect(() => {
|
|
957
|
+
if (!hasTauri) return;
|
|
958
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
959
|
+
const observer = new ResizeObserver(() => {
|
|
960
|
+
if (Date.now() < animatingUntilRef.current) return;
|
|
961
|
+
if (timer) clearTimeout(timer);
|
|
962
|
+
timer = setTimeout(() => syncWindowToContent(), 120);
|
|
963
|
+
});
|
|
964
|
+
const el = mode === "done" ? cardRef.current : pillRef.current;
|
|
965
|
+
if (el) observer.observe(el);
|
|
966
|
+
return () => {
|
|
967
|
+
if (timer) clearTimeout(timer);
|
|
968
|
+
observer.disconnect();
|
|
969
|
+
};
|
|
970
|
+
}, [mode]);
|
|
971
|
+
|
|
972
|
+
// The pill owns its window's visibility: hidden through pre-record and the
|
|
973
|
+
// countdown, shown the moment capture is live, and kept up while the
|
|
974
|
+
// completion card is open. Rust never shows this window itself.
|
|
975
|
+
const visibleRef = useRef(false);
|
|
976
|
+
useEffect(() => {
|
|
977
|
+
if (!hasTauri) return;
|
|
978
|
+
const visible = enabled || mode === "done";
|
|
979
|
+
if (visibleRef.current === visible) return;
|
|
980
|
+
visibleRef.current = visible;
|
|
981
|
+
if (visible) syncWindowToContent();
|
|
982
|
+
queueWindowOp(async () => {
|
|
983
|
+
await invoke("toolbar_set_visible", { visible });
|
|
984
|
+
});
|
|
985
|
+
}, [enabled, mode]);
|
|
986
|
+
|
|
987
|
+
// The pill is also the single writer of the menu bar's recording mode:
|
|
988
|
+
// stop square + ticking timer exactly while capture is live, the app logo
|
|
989
|
+
// otherwise. Rust infers nothing; a window-destroyed backstop covers the
|
|
990
|
+
// one report this effect can never send.
|
|
991
|
+
const trayLive = enabled && mode !== "done";
|
|
992
|
+
// One ordered writer. These calls used to be two independent fire-and-forget
|
|
993
|
+
// invokes, so a timer update issued just before a stop could land after the
|
|
994
|
+
// stop's inactive write and put the stop square back for a session that had
|
|
995
|
+
// already ended, until the dead-man cleared it seconds later.
|
|
996
|
+
const trayOpChainRef = useRef<Promise<unknown>>(Promise.resolve());
|
|
997
|
+
function writeTrayStatus(active: boolean, title: string | null) {
|
|
998
|
+
trayOpChainRef.current = trayOpChainRef.current.then(() =>
|
|
999
|
+
safeInvoke("tray_recording_status", { active, title }),
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
function liveTrayTitle() {
|
|
1003
|
+
return `${pausedRef.current ? "⏸ " : ""}${formatTimer(elapsedRef.current)}`;
|
|
1004
|
+
}
|
|
1005
|
+
useEffect(() => {
|
|
1006
|
+
if (!hasTauri) return;
|
|
1007
|
+
writeTrayStatus(trayLive, trayLive ? liveTrayTitle() : null);
|
|
1008
|
+
}, [trayLive, elapsed, paused]);
|
|
1009
|
+
// The tray's dead-man clears the status item after 2.5s without a write.
|
|
1010
|
+
// While paused, `elapsed` stops advancing and `paused` stops changing, so
|
|
1011
|
+
// the effect above stops firing and the menu bar would drop a recording
|
|
1012
|
+
// that is still very much live. This heartbeat is what keeps it.
|
|
1013
|
+
useEffect(() => {
|
|
1014
|
+
if (!hasTauri || !trayLive) return;
|
|
1015
|
+
const beat = setInterval(() => writeTrayStatus(true, liveTrayTitle()), 800);
|
|
1016
|
+
return () => clearInterval(beat);
|
|
1017
|
+
}, [trayLive]);
|
|
1018
|
+
|
|
1019
|
+
// ---- interactions ----
|
|
1020
|
+
|
|
1021
|
+
function handleMouseEnter() {
|
|
1022
|
+
if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
|
|
1023
|
+
hoverTimerRef.current = setTimeout(() => reveal(true), HOVER_INTENT_MS);
|
|
1024
|
+
}
|
|
1025
|
+
function handleMouseLeave() {
|
|
1026
|
+
if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
|
|
1027
|
+
reveal(false);
|
|
1028
|
+
}
|
|
1029
|
+
function handleFocusCapture() {
|
|
1030
|
+
reveal(true);
|
|
1031
|
+
}
|
|
1032
|
+
function handleBlurCapture(e: FocusEvent<HTMLDivElement>) {
|
|
1033
|
+
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
|
|
1034
|
+
reveal(false);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function handlePillMouseDown(e: React.MouseEvent) {
|
|
1038
|
+
if (e.button !== 0) return;
|
|
1039
|
+
const target = e.target as HTMLElement;
|
|
1040
|
+
if (target.closest("button")) return;
|
|
1041
|
+
// During confirm only Delete, Resume, or Esc answer the question — a
|
|
1042
|
+
// stray tap must never resume the recording.
|
|
1043
|
+
if (hasTauri) {
|
|
1044
|
+
getCurrentWindow()
|
|
1045
|
+
.startDragging()
|
|
1046
|
+
.catch(() => {});
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
const inConfirm = mode === "confirm";
|
|
1051
|
+
const showPaused = paused;
|
|
1052
|
+
const meterFlat = showPaused || !enabled;
|
|
1053
|
+
const timerText = formatTimer(elapsed);
|
|
1054
|
+
const shownBarHeights = meterFlat ? [4, 4, 4, 4, 4] : barHeights;
|
|
1055
|
+
|
|
1056
|
+
const card = completionCardState(doneStage, {
|
|
1057
|
+
hasLink: Boolean(viewUrl),
|
|
1058
|
+
savedLocally,
|
|
1059
|
+
});
|
|
1060
|
+
const cardBadgeClass =
|
|
1061
|
+
card.tone === "ok"
|
|
1062
|
+
? "bg-[var(--pill-card-badge-bg)] text-[var(--pill-card-badge)]"
|
|
1063
|
+
: card.tone === "warn"
|
|
1064
|
+
? "bg-[var(--pill-card-badge-warn-bg)] text-[var(--pill-card-badge-warn)]"
|
|
1065
|
+
: "bg-[var(--pill-card-well)] text-[var(--pill-card-ink-2)]";
|
|
1066
|
+
// Announce what the card actually says, when it says it. Stop announced
|
|
1067
|
+
// "Recording saved" the moment it was clicked, before the export or upload
|
|
1068
|
+
// had returned anything to say that about.
|
|
1069
|
+
const cardTitle = mode === "done" ? card.title : null;
|
|
1070
|
+
useEffect(() => {
|
|
1071
|
+
if (cardTitle) setAnnouncement(cardTitle);
|
|
1072
|
+
}, [cardTitle]);
|
|
1073
|
+
|
|
1074
|
+
return (
|
|
1075
|
+
<div
|
|
1076
|
+
data-tw-surface
|
|
1077
|
+
className="record-pill-scope flex h-screen w-screen select-none items-end"
|
|
1078
|
+
>
|
|
1079
|
+
<div aria-live="polite" className="sr-only">
|
|
1080
|
+
{announcement}
|
|
1081
|
+
</div>
|
|
1082
|
+
{mode === "done" ? (
|
|
1083
|
+
<div
|
|
1084
|
+
ref={cardRef}
|
|
1085
|
+
className={`w-[340px] flex-none rounded-[14px] border-[0.5px] border-[var(--pill-card-border)] bg-[var(--pill-card-surface)] p-4 ${reducedRef.current ? "" : "record-pill-card-in"}`}
|
|
1086
|
+
>
|
|
1087
|
+
<div className="mb-3 flex items-center gap-2.5">
|
|
1088
|
+
<span
|
|
1089
|
+
className={`flex size-8 flex-none items-center justify-center rounded-full ${cardBadgeClass}`}
|
|
1090
|
+
>
|
|
1091
|
+
{card.tone === "ok" ? (
|
|
1092
|
+
<IconCheck size={16} stroke={2.4} aria-hidden />
|
|
1093
|
+
) : card.tone === "warn" ? (
|
|
1094
|
+
<IconAlertTriangle size={16} stroke={2.2} aria-hidden />
|
|
1095
|
+
) : (
|
|
1096
|
+
<IconLoader2
|
|
1097
|
+
size={16}
|
|
1098
|
+
stroke={2.2}
|
|
1099
|
+
aria-hidden
|
|
1100
|
+
className={reducedRef.current ? "" : "animate-spin"}
|
|
1101
|
+
/>
|
|
1102
|
+
)}
|
|
1103
|
+
</span>
|
|
1104
|
+
<div className="min-w-0">
|
|
1105
|
+
<div className="text-sm font-semibold text-[var(--pill-card-ink)]">
|
|
1106
|
+
{card.title}
|
|
1107
|
+
</div>
|
|
1108
|
+
<div className="text-xs text-[var(--pill-card-ink-2)]">
|
|
1109
|
+
{formatDurationCopy(doneDurationMs)}
|
|
1110
|
+
{card.detail ? ` · ${card.detail}` : ""}
|
|
1111
|
+
</div>
|
|
1112
|
+
</div>
|
|
1113
|
+
<button
|
|
1114
|
+
type="button"
|
|
1115
|
+
onClick={dismissCard}
|
|
1116
|
+
aria-label="Dismiss"
|
|
1117
|
+
className="ml-auto flex size-6 flex-none items-center justify-center rounded text-[var(--pill-card-ink-3)] hover:text-[var(--pill-card-ink)]"
|
|
1118
|
+
>
|
|
1119
|
+
<IconX size={15} aria-hidden />
|
|
1120
|
+
</button>
|
|
1121
|
+
</div>
|
|
1122
|
+
{viewUrl ? (
|
|
1123
|
+
<div className="mb-3 flex items-center gap-2 rounded-lg bg-[var(--pill-card-well)] px-2.5 py-2 text-xs">
|
|
1124
|
+
<IconLink
|
|
1125
|
+
size={14}
|
|
1126
|
+
className="flex-none text-[var(--pill-card-ink-2)]"
|
|
1127
|
+
aria-hidden
|
|
1128
|
+
/>
|
|
1129
|
+
<span className="record-pill-mono min-w-0 flex-1 truncate text-[var(--pill-card-ink-2)]">
|
|
1130
|
+
{viewUrl.replace(/^https?:\/\//, "")}
|
|
1131
|
+
</span>
|
|
1132
|
+
<button
|
|
1133
|
+
type="button"
|
|
1134
|
+
onClick={() => void copyLink(viewUrl)}
|
|
1135
|
+
aria-label="Copy link"
|
|
1136
|
+
className={`flex size-5 flex-none items-center justify-center rounded ${copied ? "text-[var(--pill-card-badge)]" : "text-[var(--pill-card-ink-2)] hover:text-[var(--pill-card-ink)]"}`}
|
|
1137
|
+
>
|
|
1138
|
+
{copied ? (
|
|
1139
|
+
<IconCheck size={14} aria-hidden />
|
|
1140
|
+
) : (
|
|
1141
|
+
<IconCopy size={14} aria-hidden />
|
|
1142
|
+
)}
|
|
1143
|
+
</button>
|
|
1144
|
+
</div>
|
|
1145
|
+
) : null}
|
|
1146
|
+
<div className="flex gap-2">
|
|
1147
|
+
{viewUrl ? (
|
|
1148
|
+
<>
|
|
1149
|
+
<button
|
|
1150
|
+
type="button"
|
|
1151
|
+
onClick={() => {
|
|
1152
|
+
if (hasTauri) void openExternal(viewUrl).catch(() => {});
|
|
1153
|
+
else window.open(viewUrl, "_blank");
|
|
1154
|
+
}}
|
|
1155
|
+
className="h-[34px] flex-1 rounded-lg bg-[var(--pill-card-ink)] text-[13px] font-semibold text-[var(--pill-on-chrome)]"
|
|
1156
|
+
>
|
|
1157
|
+
Open
|
|
1158
|
+
</button>
|
|
1159
|
+
<button
|
|
1160
|
+
type="button"
|
|
1161
|
+
onClick={() => void copyLink(viewUrl)}
|
|
1162
|
+
className="h-[34px] flex-1 rounded-lg border border-[var(--pill-card-border-strong)] bg-[var(--pill-on-chrome)] text-[13px] font-semibold text-[var(--pill-card-ink)]"
|
|
1163
|
+
>
|
|
1164
|
+
{copied ? "Copied" : "Copy"}
|
|
1165
|
+
</button>
|
|
1166
|
+
</>
|
|
1167
|
+
) : null}
|
|
1168
|
+
</div>
|
|
1169
|
+
</div>
|
|
1170
|
+
) : (
|
|
1171
|
+
<div
|
|
1172
|
+
ref={pillRef}
|
|
1173
|
+
onMouseDown={handlePillMouseDown}
|
|
1174
|
+
onTransitionEnd={handleSegTransitionEnd}
|
|
1175
|
+
onMouseEnter={handleMouseEnter}
|
|
1176
|
+
onMouseLeave={handleMouseLeave}
|
|
1177
|
+
onFocusCapture={handleFocusCapture}
|
|
1178
|
+
onBlurCapture={handleBlurCapture}
|
|
1179
|
+
className={`flex h-[42px] flex-none items-center rounded-full bg-[var(--pill-chrome)] p-1.5 text-[var(--pill-on-chrome)] ${enabled ? "" : "opacity-80"}`}
|
|
1180
|
+
>
|
|
1181
|
+
<button
|
|
1182
|
+
type="button"
|
|
1183
|
+
onClick={stop}
|
|
1184
|
+
disabled={!enabled || inConfirm}
|
|
1185
|
+
aria-label="Stop and save"
|
|
1186
|
+
className="flex size-[30px] flex-none items-center justify-center rounded-full transition-colors duration-150 disabled:cursor-default"
|
|
1187
|
+
style={{
|
|
1188
|
+
color: showPaused ? "var(--pill-ghost-ink)" : "var(--pill-rec)",
|
|
1189
|
+
}}
|
|
1190
|
+
>
|
|
1191
|
+
<span
|
|
1192
|
+
aria-hidden
|
|
1193
|
+
className="size-[13px] rounded-[3px] bg-current"
|
|
1194
|
+
/>
|
|
1195
|
+
</button>
|
|
1196
|
+
<span
|
|
1197
|
+
aria-live="off"
|
|
1198
|
+
className="record-pill-mono ml-1.5 flex-none text-sm font-medium transition-colors duration-150"
|
|
1199
|
+
style={{
|
|
1200
|
+
color: showPaused ? "var(--pill-on-chrome)" : "var(--pill-rec)",
|
|
1201
|
+
}}
|
|
1202
|
+
>
|
|
1203
|
+
{timerText}
|
|
1204
|
+
</span>
|
|
1205
|
+
<span
|
|
1206
|
+
ref={(el) => {
|
|
1207
|
+
segRefs.current.mid = el;
|
|
1208
|
+
}}
|
|
1209
|
+
className="record-pill-seg"
|
|
1210
|
+
style={{ width: "auto", opacity: 1 }}
|
|
1211
|
+
>
|
|
1212
|
+
<span className="inline-flex flex-none items-center">
|
|
1213
|
+
<span
|
|
1214
|
+
aria-hidden
|
|
1215
|
+
className="ml-3.5 flex h-3.5 w-[18px] flex-none items-center justify-center gap-[2px] transition-opacity duration-150"
|
|
1216
|
+
style={{ opacity: meterFlat ? 0.3 : 1 }}
|
|
1217
|
+
>
|
|
1218
|
+
{shownBarHeights.map((h, i) => (
|
|
1219
|
+
<i
|
|
1220
|
+
key={i}
|
|
1221
|
+
className="block w-[2px] rounded-full bg-[var(--pill-meter)] transition-[height] duration-100 ease-out"
|
|
1222
|
+
style={{ height: `${h}px` }}
|
|
1223
|
+
/>
|
|
1224
|
+
))}
|
|
1225
|
+
</span>
|
|
1226
|
+
<button
|
|
1227
|
+
type="button"
|
|
1228
|
+
onClick={togglePause}
|
|
1229
|
+
disabled={!enabled || inConfirm}
|
|
1230
|
+
aria-label={showPaused ? "Resume" : "Pause"}
|
|
1231
|
+
className="ml-1.5 flex size-[30px] flex-none items-center justify-center rounded-full text-[var(--pill-ghost-ink)] transition-colors duration-150 hover:text-[var(--pill-on-chrome)] disabled:cursor-default disabled:opacity-50"
|
|
1232
|
+
>
|
|
1233
|
+
{showPaused ? (
|
|
1234
|
+
<IconPlayerPlayFilled size={14} aria-hidden />
|
|
1235
|
+
) : (
|
|
1236
|
+
<IconPlayerPauseFilled size={14} aria-hidden />
|
|
1237
|
+
)}
|
|
1238
|
+
</button>
|
|
1239
|
+
</span>
|
|
1240
|
+
</span>
|
|
1241
|
+
<span
|
|
1242
|
+
ref={(el) => {
|
|
1243
|
+
segRefs.current.q = el;
|
|
1244
|
+
}}
|
|
1245
|
+
className="record-pill-seg"
|
|
1246
|
+
>
|
|
1247
|
+
<span className="inline-flex flex-none items-center">
|
|
1248
|
+
<span className="pl-2.5 text-xs whitespace-nowrap text-[var(--pill-q-ink)]">
|
|
1249
|
+
{confirmQuestion}
|
|
1250
|
+
</span>
|
|
1251
|
+
</span>
|
|
1252
|
+
</span>
|
|
1253
|
+
<span
|
|
1254
|
+
ref={(el) => {
|
|
1255
|
+
segRefs.current.del = el;
|
|
1256
|
+
}}
|
|
1257
|
+
className="record-pill-seg"
|
|
1258
|
+
>
|
|
1259
|
+
<span className="inline-flex flex-none items-center">
|
|
1260
|
+
<button
|
|
1261
|
+
type="button"
|
|
1262
|
+
onClick={confirmDestructive}
|
|
1263
|
+
className={`ml-2.5 flex h-7 flex-none items-center rounded-full px-3.5 text-xs font-semibold ${confirmIntent === "delete" ? "bg-[var(--pill-rec)] text-[var(--pill-on-chrome)]" : "bg-[var(--pill-on-chrome)] text-[var(--pill-chrome)]"}`}
|
|
1264
|
+
>
|
|
1265
|
+
{confirmIntent === "delete" ? "Delete" : "Restart"}
|
|
1266
|
+
</button>
|
|
1267
|
+
</span>
|
|
1268
|
+
</span>
|
|
1269
|
+
<span
|
|
1270
|
+
ref={(el) => {
|
|
1271
|
+
segRefs.current.res = el;
|
|
1272
|
+
}}
|
|
1273
|
+
className="record-pill-seg"
|
|
1274
|
+
>
|
|
1275
|
+
<span className="inline-flex flex-none items-center">
|
|
1276
|
+
<button
|
|
1277
|
+
type="button"
|
|
1278
|
+
onClick={() => exitConfirm(true)}
|
|
1279
|
+
className="ml-2 flex h-7 flex-none items-center rounded-full bg-[var(--pill-soft)] px-3.5 text-xs font-semibold text-[var(--pill-on-chrome)]"
|
|
1280
|
+
>
|
|
1281
|
+
Resume
|
|
1282
|
+
</button>
|
|
1283
|
+
</span>
|
|
1284
|
+
</span>
|
|
1285
|
+
<span
|
|
1286
|
+
ref={(el) => {
|
|
1287
|
+
segRefs.current.extras = el;
|
|
1288
|
+
}}
|
|
1289
|
+
className="record-pill-seg"
|
|
1290
|
+
>
|
|
1291
|
+
<span className="inline-flex flex-none items-center">
|
|
1292
|
+
<span
|
|
1293
|
+
aria-hidden
|
|
1294
|
+
className="ml-1.5 h-[18px] w-px flex-none bg-[var(--pill-soft)]"
|
|
1295
|
+
/>
|
|
1296
|
+
<button
|
|
1297
|
+
type="button"
|
|
1298
|
+
onClick={() => enterConfirm("restart")}
|
|
1299
|
+
disabled={!enabled || !!pendingAction}
|
|
1300
|
+
aria-label="Restart recording"
|
|
1301
|
+
className="ml-1.5 flex size-[30px] flex-none items-center justify-center rounded-full text-[var(--pill-ghost-ink)] hover:text-[var(--pill-on-chrome)]"
|
|
1302
|
+
>
|
|
1303
|
+
<IconRefresh size={14} stroke={2} aria-hidden />
|
|
1304
|
+
</button>
|
|
1305
|
+
<button
|
|
1306
|
+
type="button"
|
|
1307
|
+
onClick={() => enterConfirm("delete")}
|
|
1308
|
+
disabled={!enabled || !!pendingAction}
|
|
1309
|
+
aria-label="Delete recording"
|
|
1310
|
+
className="ml-0 flex size-[30px] flex-none items-center justify-center rounded-full text-[var(--pill-ghost-ink)] hover:text-[var(--pill-on-chrome)]"
|
|
1311
|
+
>
|
|
1312
|
+
<IconTrash size={14} stroke={2} aria-hidden />
|
|
1313
|
+
</button>
|
|
1314
|
+
</span>
|
|
1315
|
+
</span>
|
|
1316
|
+
</div>
|
|
1317
|
+
)}
|
|
1318
|
+
</div>
|
|
1319
|
+
);
|
|
1320
|
+
}
|