commit 43620a86ed8c1fa649143891e0445161906aaced
parent 91b0ce5dee6e005b0c5cd0a08b7521d706ae852d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 24 Jun 2026 22:13:36 -0400
Merge feat/widget-control-buttons: opt-in widget controls (pause/drain)
Diffstat:
7 files changed, 118 insertions(+), 19 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **The monitor widget can now carry optional control buttons (Pause/Resume Transcriptions, Drain all).** The `/widget` view stays read-only by default, but a new **Show control buttons** option in the widget builder (URL flag `controls=1`) adds an interactive row at the top with the same **Pause Transcriptions** toggle (between-segment GPU release) and **Drain all** as the full app — so a pinned/iframe monitor can pause the GPU or wind work down without opening the editor. The controls stay visible even when the widget is otherwise idle (so you can pause preemptively), and the widget refetches its worker payload on a pause/resume so the toggle flips immediately instead of waiting for the next poll. Defaults keep the widget control-free, so existing links render unchanged. See `editor/app/widget/lib/config.ts`, the new `editor/app/widget/components/WidgetControls.tsx`, `editor/app/widget/components/MonitorWidget.tsx`, and `editor/app/widget/builder/components/WidgetBuilder.tsx`.
- **"Pause Transcriptions" now frees the GPU between parakeet segments instead of running the in-flight video to completion.** The global pause control (renamed from "Pause all" → **Pause Transcriptions** / **Resume Transcriptions**) used to only stop handing out new worker slots — any in-flight transcription kept running until its whole file was done, so the GPU stayed busy. Pausing now *also* sends a graceful between-segment stop to any partial-capable in-flight job: a **parakeet** worker finishes the current window, caches it (`win-NNNN.json`), and exits `paused` (a skip, not a failure), so the GPU frees within one segment and the video resumes from its cached windows on the next run — the same mechanism as the per-worker **Stop & keep progress** button, now wired into the global pause. Non-parakeet engines (whisper-cpp, chough) keep prior behavior: they stop taking new work but run their in-flight file to completion. The button is also surfaced on the **Active Jobs** screen (`/jobs/active`) next to **Drain all**, not just the Workers page. `resumeAll()` restores each worker's pre-pause state as before. See `common/jobs/workerPool.ts` (`pauseAll`), the new `editor/app/jobs/components/PauseTranscriptionsButton.tsx` (shared by both screens), `editor/app/workers/components/WorkersView.tsx`, and `editor/app/jobs/active/page.tsx`.
- **"Drain all" (and the per-runner Drain) no longer hangs the auto-transcribe runner.** Draining could leave the runner stuck "draining" forever — appearing to freeze the app — whenever one of its per-video transcription units was *parked* in the worker pool waiting for a free slot at the moment drain fired (much more likely now that auto units yield slots to manual transcriptions). The runner forwarded only its hard-cancel signal — never the soft `drainSignal` — to a parked unit's `pool.acquire()`, so a soft drain could never unblock it: the unit's promise never settled, the runner's in-flight count never reached zero, and its drain loop spun indefinitely (hard **Cancel**/**Stop** always worked, since that signal *was* forwarded). The runner now threads `ctx.drainSignal` into each transcription unit, so a parked (not-yet-started) unit unblocks and is skipped on drain while a unit already transcribing finishes normally — correct drain semantics, and the runner finalizes promptly. See `common/controller/autoRunner.ts` (the `launchUnit` drainSignal wiring) and the new parked-unit drain regression test in `editor/e2e/auto-queue.spec.ts`.
- **Manual transcriptions now preempt auto-queued ones — click "Transcribe missing" (or any non-auto transcribe) while auto-transcribe is running and yours goes next.** The auto-transcribe runner shares the global transcription **worker pool** with every manual transcribe (channel batch, bucket retry, single-video), so they already serialized — but the pool granted freed worker slots in plain arrival order, so a manual transcribe could wait behind the runner's next auto pick. The pool's waiter queue is now **priority-ordered**: a manual (foreground) acquire is served before any parked auto (background) one, with FIFO preserved within each class — the same foreground-vs-background priority the per-platform sync queue already uses, now applied to the pool too. The auto-runner acquires its per-video transcription slots at **background priority**, so a manual transcribe jumps ahead: the in-flight auto transcription is **not interrupted** (it finishes — "drains"), then every freed worker goes to the manual work until it's exhausted, then auto resumes. Worker N-way parallelism is untouched (only *parked* waiters are reordered). A future "auto-sync queues its own transcriptions" path gets the same yield for free by acquiring as background. See `common/jobs/workerPool.ts` (waiter priority), `common/controller/transcribeOne.ts`, `common/controller/transcribeOneFromQueue.ts`, and `common/controller/autoRunner.ts`.
diff --git a/editor/app/widget/builder/components/WidgetBuilder.tsx b/editor/app/widget/builder/components/WidgetBuilder.tsx
@@ -112,6 +112,11 @@ export function WidgetBuilder() {
checked={config.hideIdle}
onChange={(v) => patch({ hideIdle: v })}
/>
+ <Check
+ label="Show control buttons (pause, drain)"
+ checked={config.controls}
+ onChange={(v) => patch({ controls: v })}
+ />
</fieldset>
<label className="flex flex-col gap-1 text-sm">
@@ -170,8 +175,10 @@ export function WidgetBuilder() {
</div>
<p className="text-xs text-zinc-500">
Open this in a small pinned window, or embed it with an{" "}
- <code className="font-mono"><iframe></code>. It has no sidebar
- and no controls — read-only. <strong>Open popup</strong> launches a
+ <code className="font-mono"><iframe></code>. It has no sidebar;
+ by default it's read-only, but{" "}
+ <strong>Show control buttons</strong> adds Pause/Resume
+ Transcriptions and Drain all. <strong>Open popup</strong> launches a
chromeless window at the selected preview size.
</p>
</div>
diff --git a/editor/app/widget/components/MonitorWidget.tsx b/editor/app/widget/components/MonitorWidget.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
import { formatDuration, formatBytes } from "yt-dlp-transcript-common/lib/format";
import type {
ActiveJobsPayload,
@@ -10,6 +10,7 @@ import type { RunningJobsListItem } from "../../jobs/components/RunningJobsList"
import { jobKindLabel } from "../../jobs/jobKindLabels";
import type { WorkersPayload, WorkerView } from "../../workers/components/WorkersView";
import type { WidgetConfig } from "../lib/config";
+import { WidgetControls } from "./WidgetControls";
// Read-only monitor widget. Reuses the existing ~1s poll pattern from
// ActiveJobsLive / WorkersView against the same /api endpoints, but renders a
@@ -29,13 +30,23 @@ function useNow(): number | null {
// Generic poller: fetches `url` every `pollMs` while enabled, swallowing
// transient errors. Disabled (enabled=false) leaves the initial value as-is.
+// Returns the latest payload plus a `refetch` so a control action can refresh
+// it immediately instead of waiting for the next poll tick.
function usePolledPayload<T>(
url: string,
enabled: boolean,
pollMs: number,
initial: T | null,
-): T | null {
+): { data: T | null; refetch: () => Promise<void> } {
const [data, setData] = useState<T | null>(initial);
+ const refetch = useCallback(async () => {
+ try {
+ const res = await fetch(url, { cache: "no-store" });
+ if (res.ok) setData((await res.json()) as T);
+ } catch {
+ // transient — ignore
+ }
+ }, [url]);
useEffect(() => {
if (!enabled) return;
let cancelled = false;
@@ -56,7 +67,7 @@ function usePolledPayload<T>(
if (timer) clearTimeout(timer);
};
}, [url, enabled, pollMs]);
- return data;
+ return { data, refetch };
}
export function MonitorWidget({
@@ -69,18 +80,22 @@ export function MonitorWidget({
initialWorkers: WorkersPayload | null;
}) {
const pollMs = config.pollSeconds * 1000;
- const jobsPayload = usePolledPayload<ActiveJobsPayload>(
+ // The controls row needs live `paused` state, so poll workers whenever either
+ // the Workers section or the controls are shown.
+ const workersEnabled = config.workers || config.controls;
+ const { data: jobsPayload } = usePolledPayload<ActiveJobsPayload>(
"/api/jobs/active",
config.jobs,
pollMs,
initialJobs,
);
- const workersPayload = usePolledPayload<WorkersPayload>(
- "/api/workers",
- config.workers,
- pollMs,
- initialWorkers,
- );
+ const { data: workersPayload, refetch: refetchWorkers } =
+ usePolledPayload<WorkersPayload>(
+ "/api/workers",
+ workersEnabled,
+ pollMs,
+ initialWorkers,
+ );
const jobs = (jobsPayload?.jobs ?? []).filter(
(j) => !config.channel || j.channelSlug === config.channel,
@@ -93,8 +108,9 @@ export function MonitorWidget({
(!config.workers || workers.every((w) => !w.busy));
// A low-disk warning is worth showing even when nothing is running, since it
- // explains why no downloads start — so it overrides hideIdle.
- if (config.hideIdle && nothingActive && !disk?.low) {
+ // explains why no downloads start — so it overrides hideIdle. Interactive
+ // controls also stay visible when idle (you may want to pause preemptively).
+ if (config.hideIdle && nothingActive && !disk?.low && !config.controls) {
return (
<div className="p-2 text-xs text-zinc-500" aria-label="monitor idle">
Idle
@@ -104,6 +120,12 @@ export function MonitorWidget({
return (
<div className="flex flex-col gap-3 p-2 text-zinc-900 dark:text-zinc-100">
+ {config.controls && (
+ <WidgetControls
+ paused={workersPayload?.paused ?? false}
+ onWorkersChange={refetchWorkers}
+ />
+ )}
{config.disk && disk?.enabled && <DiskStrip disk={disk} />}
{config.workers && (
<WorkersStrip
diff --git a/editor/app/widget/components/WidgetControls.tsx b/editor/app/widget/components/WidgetControls.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import { PauseTranscriptionsButton } from "../../jobs/components/PauseTranscriptionsButton";
+import { DrainAllButton } from "../../jobs/components/DrainAllButton";
+
+// Opt-in interactive controls for the monitor widget (enabled with controls=1).
+// Reuses the same actions/buttons as the Workers and Active Jobs pages so a
+// pinned widget can free the GPU (pause) or wind work down (drain) without
+// opening the full app. `onWorkersChange` refetches the widget's worker payload
+// so the pause/resume label flips immediately instead of waiting for the next
+// poll.
+export function WidgetControls({
+ paused,
+ onWorkersChange,
+}: {
+ paused: boolean;
+ onWorkersChange: () => void | Promise<void>;
+}) {
+ return (
+ <section
+ aria-label="Controls"
+ className="flex flex-wrap items-center gap-1.5"
+ >
+ <PauseTranscriptionsButton paused={paused} onChange={onWorkersChange} />
+ <DrainAllButton />
+ </section>
+ );
+}
diff --git a/editor/app/widget/lib/config.ts b/editor/app/widget/lib/config.ts
@@ -30,6 +30,9 @@ export type WidgetConfig = {
disk: boolean;
// Show worker names in the Workers strip. Off = colored dots only (denser).
workerLabels: boolean;
+ // Opt-in interactive controls (Pause/Resume Transcriptions, Drain all). Off by
+ // default — the widget stays read-only unless this is enabled.
+ controls: boolean;
};
export const WIDGET_DEFAULTS: WidgetConfig = {
@@ -45,6 +48,7 @@ export const WIDGET_DEFAULTS: WidgetConfig = {
eta: true,
disk: true,
workerLabels: true,
+ controls: false,
};
// Next's searchParams give each key as string | string[] | undefined.
@@ -86,6 +90,7 @@ export function parseWidgetConfig(params: RawParams): WidgetConfig {
eta: parseBool(params.eta, WIDGET_DEFAULTS.eta),
disk: parseBool(params.disk, WIDGET_DEFAULTS.disk),
workerLabels: parseBool(params.wnames, WIDGET_DEFAULTS.workerLabels),
+ controls: parseBool(params.controls, WIDGET_DEFAULTS.controls),
};
}
@@ -113,5 +118,7 @@ export function buildWidgetQuery(config: WidgetConfig): string {
sp.set("disk", config.disk ? "1" : "0");
if (config.workerLabels !== WIDGET_DEFAULTS.workerLabels)
sp.set("wnames", config.workerLabels ? "1" : "0");
+ if (config.controls !== WIDGET_DEFAULTS.controls)
+ sp.set("controls", config.controls ? "1" : "0");
return sp.toString();
}
diff --git a/editor/app/widget/page.tsx b/editor/app/widget/page.tsx
@@ -20,7 +20,10 @@ export default async function WidgetPage({
}) {
const config = parseWidgetConfig((await searchParams) ?? {});
const initialJobs = config.jobs ? await buildActiveJobsPayload() : null;
- const initialWorkers = config.workers ? buildWorkersPayload() : null;
+ // Controls need the workers payload (for `paused`) even if the Workers section
+ // itself is hidden.
+ const initialWorkers =
+ config.workers || config.controls ? buildWorkersPayload() : null;
return (
<MonitorWidget
config={config}
diff --git a/editor/e2e/widget.spec.ts b/editor/e2e/widget.spec.ts
@@ -1,7 +1,8 @@
-// The read-only monitor widget (/widget) and its builder (/widget/builder).
-// The widget is meant to embed in a small pinned window or iframe: the app shell
-// (sidebar, command palette) is stripped on exactly /widget, and it carries no
-// action controls. The builder lives inside the normal shell and composes links.
+// The monitor widget (/widget) and its builder (/widget/builder). The widget is
+// meant to embed in a small pinned window or iframe: the app shell (sidebar,
+// command palette) is stripped on exactly /widget. It is read-only by default,
+// but `controls=1` opts into interactive Pause/Resume + Drain buttons. The
+// builder lives inside the normal shell and composes links.
import { test, expect } from "@playwright/test";
import { resetData, writeSettings } from "./helpers";
@@ -34,6 +35,30 @@ test("the bare widget has no sidebar and no controls", async ({ page }) => {
await expect(page.getByRole("button")).toHaveCount(0);
});
+test("controls=1 adds interactive Pause/Resume and Drain buttons", async ({
+ page,
+}) => {
+ await page.goto("/widget?controls=1");
+
+ // The opt-in controls render even though the bare widget is read-only.
+ const pause = page.getByRole("button", { name: "Pause Transcriptions" });
+ await expect(pause).toBeVisible();
+ await expect(page.getByRole("button", { name: "Drain all" })).toBeVisible();
+
+ // Pausing flips the toggle to Resume (the widget refetches workers on change).
+ await pause.click();
+ await expect(
+ page.getByRole("button", { name: "Resume Transcriptions" }),
+ ).toBeVisible({ timeout: 10_000 });
+ // The Workers strip also reflects the paused state.
+ await expect(page.getByText("paused", { exact: true })).toBeVisible();
+
+ await page.getByRole("button", { name: "Resume Transcriptions" }).click();
+ await expect(
+ page.getByRole("button", { name: "Pause Transcriptions" }),
+ ).toBeVisible({ timeout: 10_000 });
+});
+
test("section params gate what renders", async ({ page }) => {
await page.goto("/widget?jobs=0");
await expect(page.getByRole("heading", { name: "Workers" })).toBeVisible();
@@ -98,4 +123,10 @@ test("display toggles serialize into the link and preview", async ({ page }) =>
await page.getByRole("checkbox", { name: "Show worker names" }).uncheck();
await expect(url).toHaveValue(/[?&]wnames=0(&|$)/);
await expect(preview).toHaveAttribute("src", /[?&]wnames=0(&|$)/);
+
+ await page
+ .getByRole("checkbox", { name: "Show control buttons (pause, drain)" })
+ .check();
+ await expect(url).toHaveValue(/[?&]controls=1(&|$)/);
+ await expect(preview).toHaveAttribute("src", /[?&]controls=1(&|$)/);
});