From c28098503458cdad38dd01f9fc0e81975b7dfa29 Mon Sep 17 00:00:00 2001 From: sardorml Date: Fri, 2 Oct 2026 12:37:19 +0500 Subject: [PATCH] feat(extension): pre-roll countdown before capture starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 3-2-1 overlay on the recorded page between stream acquisition and the first recorded frame, with skip and cancel controls (buttons or Esc). The gate sits after every grant and picker settles and before the upload session is created, so a cancel tears down with nothing to clean up server-side — it reuses the picker-cancel 'cancelled' status path. The service worker owns the clock and starts capture on its own schedule; the injected overlay (vanilla DOM, serialized function, like the other page surfaces) only displays the count and can end it early via the same raw runtime-message channel as recorderOverlayClosed. If the tab navigates mid-count the recording still starts on time, the overlay self-removes 150ms before zero so it never lands in the first frames, and restricted pages fall back to today's instant start. --- apps/extension/entrypoints/background.ts | 51 ++++++++ apps/extension/entrypoints/offscreen/main.ts | 1 + apps/extension/lib/capture/recorder.ts | 12 ++ apps/extension/lib/messaging.ts | 5 + .../lib/overlay/countdown-overlay.ts | 117 ++++++++++++++++++ 5 files changed, 186 insertions(+) create mode 100644 apps/extension/lib/overlay/countdown-overlay.ts 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(); +}