diff --git a/apps/extension/entrypoints/background.ts b/apps/extension/entrypoints/background.ts index 55468129..6f0ca733 100644 --- a/apps/extension/entrypoints/background.ts +++ b/apps/extension/entrypoints/background.ts @@ -2,6 +2,7 @@ import { onMessage, sendMessage, type CaptureContext, + type CountdownResult, type StartResult, } from "@/lib/messaging"; import { @@ -32,6 +33,11 @@ import { mountGrantFrame, unmountCameraBubble, } from "@/lib/overlay/camera-bubble"; +import { + COUNTDOWN_OVERLAY_ID, + mountCountdownOverlay, + unmountCountdownOverlay, +} from "@/lib/overlay/countdown-overlay"; import { RECORDER_BACKDROP_ID, RECORDER_FRAME_ID, @@ -107,6 +113,45 @@ async function releaseCameraBubble(): Promise { // Tab hosting the invisible camera+mic grant frame (one combined prompt). let grantTabId: number | undefined; +const COUNTDOWN_SECONDS = 3; +// Pending pre-roll resolver; the overlay's skip/cancel raw message settles it +// early, the worker's own clock settles it otherwise. +let countdownSettle: ((result: CountdownResult) => void) | undefined; + +async function runPageCountdown(): Promise { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + // Restricted pages can't host the overlay — start without a countdown. + if (tab?.id === undefined || !isInjectable(tab.url)) return "go"; + const tabId = tab.id; + try { + await chrome.scripting.executeScript({ + target: { tabId }, + func: mountCountdownOverlay, + args: [COUNTDOWN_SECONDS, COUNTDOWN_OVERLAY_ID], + }); + } catch { + return "go"; + } + // The worker owns the clock; the overlay only displays it. First settle + // wins — a late page message after the timeout resolves into a no-op. + const result = await new Promise((resolve) => { + countdownSettle = resolve; + setTimeout(() => resolve("go"), COUNTDOWN_SECONDS * 1000); + }); + countdownSettle = undefined; + // Self-removed page-side in the normal flow; sweep the race remainder. + try { + await chrome.scripting.executeScript({ + target: { tabId }, + func: unmountCountdownOverlay, + args: [COUNTDOWN_OVERLAY_ID], + }); + } catch { + /* tab closed or no longer injectable */ + } + return result; +} + async function requestMediaGrant(): Promise { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); if (tab?.id === undefined) return; @@ -312,6 +357,11 @@ export default defineBackground(() => { overlayTabId = undefined; void releaseCameraBubble(); } + const verdict = (message as { captureflowCountdown?: unknown }) + .captureflowCountdown; + if (verdict === "skip" || verdict === "cancel") { + countdownSettle?.(verdict === "skip" ? "go" : "cancel"); + } }); chrome.windows.onRemoved.addListener((windowId) => { @@ -494,6 +544,7 @@ export default defineBackground(() => { } }); + onMessage("runCountdown", () => runPageCountdown()); onMessage("stopRecording", () => sendMessage("stopCapture", undefined)); onMessage("pauseRecording", () => sendMessage("pauseCapture", undefined)); onMessage("resumeRecording", () => sendMessage("resumeCapture", undefined)); diff --git a/apps/extension/entrypoints/offscreen/main.ts b/apps/extension/entrypoints/offscreen/main.ts index 7db5d6d2..4336b749 100644 --- a/apps/extension/entrypoints/offscreen/main.ts +++ b/apps/extension/entrypoints/offscreen/main.ts @@ -15,6 +15,7 @@ import { */ onMessage("beginCapture", ({ data }) => recordAndUpload(data, { + onCountdown: () => sendMessage("runCountdown", undefined), onStatus: (status) => void sendMessage("recordingStatus", status), onResult: (result) => void sendMessage("recordingResult", result), onActiveUpload: (upload) => void sendMessage("activeUploadChanged", upload), diff --git a/apps/extension/lib/capture/recorder.ts b/apps/extension/lib/capture/recorder.ts index fe54e60f..6f0bcab7 100644 --- a/apps/extension/lib/capture/recorder.ts +++ b/apps/extension/lib/capture/recorder.ts @@ -19,6 +19,9 @@ import type { } from "../storage"; type Callbacks = { + // Pre-roll countdown, shown by the SW on the page. Runs after all grants + // and pickers settle, so a cancel tears down before any upload exists. + onCountdown: () => Promise<"go" | "cancel">; onStatus: (status: RecordingStatus) => void; onResult: (result: RecordingResultPayload) => void; // Relayed to the SW: chrome.storage is unavailable in offscreen documents. @@ -153,6 +156,15 @@ export async function recordAndUpload( if (micStream) stopTracks(micStream); }; + // A failed countdown round-trip must not strand live tracks — record anyway. + const countdown = await cb.onCountdown().catch(() => "go" as const); + if (countdown === "cancel") { + stopAllTracks(); + sessionActive = false; + cb.onStatus({ kind: "cancelled" }); + return; + } + // Restart discards the session's upload but keeps the acquired streams, so // no picker or permission prompt re-appears. try { diff --git a/apps/extension/lib/messaging.ts b/apps/extension/lib/messaging.ts index 99ba00c5..e9a88c2d 100644 --- a/apps/extension/lib/messaging.ts +++ b/apps/extension/lib/messaging.ts @@ -9,6 +9,8 @@ import type { export type StartResult = { ok: true } | { ok: false; error: string }; +export type CountdownResult = "go" | "cancel"; + export type CaptureContext = { deviceId: string; token: string; @@ -48,6 +50,9 @@ type ProtocolMap = { restartRecording(): void; deleteRecording(): void; beginCapture(ctx: CaptureContext): void; + // Offscreen docs can't inject page UI, so the SW renders the pre-roll + // countdown and reports how it ended. + runCountdown(): CountdownResult; stopCapture(): void; pauseCapture(): void; resumeCapture(): void; diff --git a/apps/extension/lib/overlay/countdown-overlay.ts b/apps/extension/lib/overlay/countdown-overlay.ts new file mode 100644 index 00000000..5f308ceb --- /dev/null +++ b/apps/extension/lib/overlay/countdown-overlay.ts @@ -0,0 +1,117 @@ +export const COUNTDOWN_OVERLAY_ID = "captureflow-countdown-overlay"; + +/* + * Injected via chrome.scripting (serialized): a pre-roll countdown over the + * page — big numeral flanked by cancel and skip controls. Args only — the + * serialized body can't close over module scope. + * + * The overlay is display + input only; the service worker owns the clock and + * starts capture on its own schedule. Buttons and Esc report through a raw + * runtime message (the typed messaging layer ignores foreign shapes). The + * last beat self-removes 150ms early so the overlay is off-screen before the + * recorder's first frame. + */ +export function mountCountdownOverlay(seconds: number, rootId: string): void { + document.getElementById(rootId)?.remove(); + + const root = document.createElement("div"); + root.id = rootId; + root.style.cssText = + "position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;" + + "justify-content:center;gap:40px;pointer-events:none;" + + "font-family:system-ui,-apple-system,sans-serif;"; + + const finish = (verdict: "cancel" | "skip" | null): void => { + window.clearInterval(tick); + window.removeEventListener("keydown", onKey, true); + root.remove(); + if (verdict) { + chrome.runtime.sendMessage({ captureflowCountdown: verdict }); + } + }; + + const sideButton = (label: string, svg: string): HTMLButtonElement => { + const b = document.createElement("button"); + b.type = "button"; + b.setAttribute("aria-label", label); + b.innerHTML = svg; + b.style.cssText = + "width:64px;height:64px;border-radius:50%;border:2px solid rgba(255,255,255,.9);" + + "background:rgba(10,11,14,.35);color:#fff;cursor:pointer;pointer-events:auto;" + + "display:flex;align-items:center;justify-content:center;padding:0;" + + "backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);"; + return b; + }; + + const cancelBtn = sideButton( + "Cancel recording", + '', + ); + cancelBtn.addEventListener("click", () => finish("cancel")); + + const circle = document.createElement("div"); + circle.style.cssText = + "width:170px;height:170px;border-radius:50%;background:#2563eb;" + + "box-shadow:0 0 0 10px rgba(37,99,235,.35),0 12px 40px rgba(0,0,0,.45);" + + "display:flex;align-items:center;justify-content:center;"; + const numeral = document.createElement("div"); + numeral.style.cssText = + "color:#fff;font-size:96px;font-weight:600;line-height:1;" + + "font-variant-numeric:tabular-nums;"; + circle.appendChild(numeral); + + const skipBtn = sideButton( + "Skip countdown", + '', + ); + skipBtn.addEventListener("click", () => finish("skip")); + + const hint = document.createElement("div"); + hint.textContent = "Press Esc to cancel"; + hint.style.cssText = + "position:fixed;left:50%;transform:translateX(-50%);bottom:18%;" + + "color:#fff;background:rgba(10,11,14,.65);border-radius:8px;" + + "padding:6px 12px;font-size:13px;"; + + const onKey = (e: KeyboardEvent): void => { + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + finish("cancel"); + } + }; + window.addEventListener("keydown", onKey, true); + + let remaining = seconds; + const show = (n: number): void => { + numeral.textContent = String(n); + circle.animate( + [ + { transform: "scale(.82)", opacity: 0.6 }, + { transform: "scale(1)", opacity: 1 }, + ], + { duration: 240, easing: "ease-out" }, + ); + }; + show(remaining); + const tick = window.setInterval(() => { + remaining -= 1; + if (remaining >= 1) { + show(remaining); + if (remaining === 1) { + // Clear the stage before the first recorded frame. + window.setTimeout(() => finish(null), 850); + } + } else { + finish(null); + } + }, 1000); + if (seconds === 1) window.setTimeout(() => finish(null), 850); + + root.append(cancelBtn, circle, skipBtn, hint); + document.documentElement.appendChild(root); +} + +export function unmountCountdownOverlay(rootId: string): void { + document.getElementById(rootId)?.remove(); +}