Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions apps/extension/entrypoints/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
onMessage,
sendMessage,
type CaptureContext,
type CountdownResult,
type StartResult,
} from "@/lib/messaging";
import {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -107,6 +113,45 @@ async function releaseCameraBubble(): Promise<void> {
// 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<CountdownResult> {
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<CountdownResult>((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<void> {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (tab?.id === undefined) return;
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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));
Expand Down
1 change: 1 addition & 0 deletions apps/extension/entrypoints/offscreen/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
12 changes: 12 additions & 0 deletions apps/extension/lib/capture/recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions apps/extension/lib/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
117 changes: 117 additions & 0 deletions apps/extension/lib/overlay/countdown-overlay.ts
Original file line number Diff line number Diff line change
@@ -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",
'<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M5 5l14 14M19 5L5 19"/></svg>',
);
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",
'<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 5l10 7-10 7V5z"/><path d="M19 5v14"/></svg>',
);
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();
}
Loading