600 lines
30 KiB
TypeScript
600 lines
30 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import { Check, X } from "lucide-react";
|
||
import clsx from "clsx";
|
||
|
||
import Card from "../components/Card.tsx";
|
||
import Button from "../components/buttons/Button.tsx";
|
||
import PageHeader from "../components/PageHeader.tsx";
|
||
import IsLivePulse from "../components/IsLivePulse.tsx";
|
||
import NoAccessPage from "../components/NoAccessPage.tsx";
|
||
import JobProgress, { isSettled, type TrackedJob } from "../components/JobProgress.tsx";
|
||
|
||
import { useAuth } from "../utils/AuthContext.tsx";
|
||
import { getCurrentStream, getIngestUrl } from "../utils/api/stream.ts";
|
||
import { getMarkers, createMarker, deleteMarker } from "../utils/api/markers.ts";
|
||
import { saveSection, clipSection } from "../utils/api/media.ts";
|
||
import { getJob } from "../utils/api/jobs.ts";
|
||
import type { Marker, StreamStatus } from "../utils/types.ts";
|
||
import { formatTime, formatTimecode, parseTimecode, toRelativeOffset, stringToDate } from "../utils/utils.ts";
|
||
import { inputClass, monoInputClass, labelClass, kickerClass, chipButtonClass } from "../utils/styles.ts";
|
||
|
||
/** Ten-minute gridlines behind the marker ticks. */
|
||
const GRID_INTERVAL_SECONDS = 600;
|
||
|
||
const useCopy = () => {
|
||
const [copied, setCopied] = useState<string | null>(null);
|
||
|
||
const copy = (key: string, text: string) => {
|
||
navigator.clipboard.writeText(text);
|
||
setCopied(key);
|
||
setTimeout(() => setCopied((current) => (current === key ? null : current)), 1600);
|
||
};
|
||
|
||
return { copied, copy };
|
||
};
|
||
|
||
const StreamControl = () => {
|
||
const { user, loading } = useAuth();
|
||
const { copied, copy } = useCopy();
|
||
|
||
const [streamStatus, setStreamStatus] = useState<StreamStatus | null>(null);
|
||
const [now, setNow] = useState(Date.now());
|
||
|
||
const [markers, setMarkers] = useState<Marker[]>([]);
|
||
const [markerMessage, setMarkerMessage] = useState("");
|
||
const [markerBusy, setMarkerBusy] = useState(false);
|
||
const [markerError, setMarkerError] = useState<string | null>(null);
|
||
const markerInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
const [selected, setSelected] = useState<number[]>([]);
|
||
const [inValue, setInValue] = useState("");
|
||
const [outValue, setOutValue] = useState("");
|
||
const [rangeName, setRangeName] = useState("");
|
||
const [rangeError, setRangeError] = useState<string | null>(null);
|
||
const [rangeBusy, setRangeBusy] = useState(false);
|
||
|
||
const [clipDuration, setClipDuration] = useState(30);
|
||
const [clipName, setClipName] = useState("");
|
||
const [clipError, setClipError] = useState<string | null>(null);
|
||
|
||
const [keyVisible, setKeyVisible] = useState(false);
|
||
const [ingestUrl, setIngestUrl] = useState<string | null>(null);
|
||
const [jobs, setJobs] = useState<TrackedJob[]>([]);
|
||
|
||
const isStreaming = streamStatus?.isStreaming ?? false;
|
||
const startMs = streamStatus?.startTime ? stringToDate(streamStatus.startTime).getTime() : null;
|
||
const elapsedSeconds = startMs === null ? 0 : Math.max(0, (now - startMs) / 1000);
|
||
const elapsed = formatTimecode(elapsedSeconds);
|
||
|
||
// ── Polling ─────────────────────────────────────────────────────────────
|
||
useEffect(() => {
|
||
if (!user) return;
|
||
const poll = () => getCurrentStream().then(setStreamStatus).catch(() => {});
|
||
poll();
|
||
const interval = setInterval(poll, 5000);
|
||
return () => clearInterval(interval);
|
||
}, [user]);
|
||
|
||
useEffect(() => {
|
||
const interval = setInterval(() => setNow(Date.now()), 1000);
|
||
return () => clearInterval(interval);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!user) return;
|
||
getIngestUrl().then(setIngestUrl).catch(() => setIngestUrl(null));
|
||
}, [user]);
|
||
|
||
const refreshMarkers = () => {
|
||
getMarkers()
|
||
.then((data) => setMarkers([...data]
|
||
.sort((a, b) => stringToDate(a.timestamp).getTime() - stringToDate(b.timestamp).getTime())))
|
||
.catch(() => {});
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!isStreaming) {
|
||
setMarkers([]);
|
||
setSelected([]);
|
||
return;
|
||
}
|
||
refreshMarkers();
|
||
const interval = setInterval(refreshMarkers, 5000);
|
||
return () => clearInterval(interval);
|
||
}, [isStreaming, streamStatus?.id]);
|
||
|
||
// One interval for the whole pending set, rebuilt only when that set changes.
|
||
const pendingKey = jobs.filter((job) => !isSettled(job)).map((job) => job.uuid).join(",");
|
||
|
||
useEffect(() => {
|
||
if (!pendingKey) return;
|
||
const uuids = pendingKey.split(",");
|
||
|
||
const interval = setInterval(() => {
|
||
uuids.forEach((uuid) => {
|
||
getJob(uuid)
|
||
.then((result) => setJobs((prev) => prev.map((job) => job.uuid === uuid
|
||
? { ...job, progress: result.progress, state: result.state, errorOutput: result.errorOutput }
|
||
: job)))
|
||
.catch(() => {});
|
||
});
|
||
}, 1000);
|
||
|
||
return () => clearInterval(interval);
|
||
}, [pendingKey]);
|
||
|
||
// ── Marker helpers ──────────────────────────────────────────────────────
|
||
const offsetOf = (marker: Marker) =>
|
||
startMs === null ? 0 : (stringToDate(marker.timestamp).getTime() - startMs) / 1000;
|
||
|
||
|
||
const toggleMarker = (id: number) => {
|
||
const next = selected.includes(id)
|
||
? selected.filter((current) => current !== id)
|
||
: selected.length < 2 ? [...selected, id] : [selected[1], id];
|
||
|
||
setSelected(next);
|
||
setRangeError(null);
|
||
|
||
if (next.length !== 2 || startMs === null) return;
|
||
|
||
const offsets = next
|
||
.map((markerId) => markers.find((marker) => marker.id === markerId))
|
||
.filter((marker): marker is Marker => marker !== undefined)
|
||
.map(offsetOf)
|
||
.sort((a, b) => a - b);
|
||
|
||
if (offsets.length !== 2) return;
|
||
setInValue(formatTimecode(offsets[0]));
|
||
setOutValue(formatTimecode(offsets[1]));
|
||
};
|
||
|
||
// 'M' anywhere on the page jumps to the marker field.
|
||
useEffect(() => {
|
||
const onKey = (event: KeyboardEvent) => {
|
||
if (event.key !== "m" && event.key !== "M") return;
|
||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return;
|
||
event.preventDefault();
|
||
markerInputRef.current?.focus();
|
||
};
|
||
document.addEventListener("keydown", onKey);
|
||
return () => document.removeEventListener("keydown", onKey);
|
||
}, []);
|
||
|
||
// ── Actions ─────────────────────────────────────────────────────────────
|
||
const addJob = (uuid: string, label: string) =>
|
||
setJobs((prev) => [{ uuid, label, progress: 0, state: 'READY', errorOutput: null }, ...prev]);
|
||
|
||
const handleAddMarker = async () => {
|
||
if (markerBusy) return;
|
||
setMarkerBusy(true);
|
||
setMarkerError(null);
|
||
try {
|
||
await createMarker(markerMessage.trim() || `Marker at ${elapsed}`);
|
||
setMarkerMessage("");
|
||
refreshMarkers();
|
||
} catch (err) {
|
||
setMarkerError(err instanceof Error ? err.message : "Failed to drop the marker");
|
||
} finally {
|
||
setMarkerBusy(false);
|
||
}
|
||
};
|
||
|
||
const handleDeleteMarker = async (id: number) => {
|
||
try {
|
||
await deleteMarker(id);
|
||
setSelected((prev) => prev.filter((current) => current !== id));
|
||
refreshMarkers();
|
||
} catch (err) {
|
||
setMarkerError(err instanceof Error ? err.message : "Failed to remove the marker");
|
||
}
|
||
};
|
||
|
||
const inSeconds = parseTimecode(inValue);
|
||
const outSeconds = parseTimecode(outValue);
|
||
// In/Out are timecodes (hh:mm:ss); the length between them is a duration (h:mm:ss).
|
||
const rangeLength = inSeconds !== null && outSeconds !== null && outSeconds > inSeconds
|
||
? formatTime(outSeconds - inSeconds)
|
||
: null;
|
||
|
||
const handleSaveRange = async () => {
|
||
setRangeError(null);
|
||
|
||
if (startMs === null) {
|
||
setRangeError("No stream is running to take a range from.");
|
||
return;
|
||
}
|
||
if (inSeconds === null || outSeconds === null) {
|
||
setRangeError("Timecodes read as hh:mm:ss.");
|
||
return;
|
||
}
|
||
if (outSeconds <= inSeconds) {
|
||
setRangeError("The out point has to come after the in point.");
|
||
return;
|
||
}
|
||
if (outSeconds > elapsedSeconds) {
|
||
setRangeError("That out point hasn't been streamed yet.");
|
||
return;
|
||
}
|
||
|
||
setRangeBusy(true);
|
||
try {
|
||
const uuid = await saveSection(
|
||
new Date(startMs + inSeconds * 1000).toISOString(),
|
||
new Date(startMs + outSeconds * 1000).toISOString(),
|
||
rangeName || undefined
|
||
);
|
||
addJob(uuid, rangeName || `Saving ${inValue} – ${outValue}`);
|
||
clearSelection();
|
||
} catch (err) {
|
||
setRangeError(err instanceof Error ? err.message : "Failed to save the range");
|
||
} finally {
|
||
setRangeBusy(false);
|
||
}
|
||
};
|
||
|
||
const clearSelection = () => {
|
||
setSelected([]);
|
||
setInValue("");
|
||
setOutValue("");
|
||
setRangeName("");
|
||
setRangeError(null);
|
||
};
|
||
|
||
const handleClip = async () => {
|
||
setClipError(null);
|
||
try {
|
||
const uuid = await clipSection(clipDuration, clipName || undefined);
|
||
addJob(uuid, clipName || `Cutting the last ${clipDuration}s`);
|
||
setClipName("");
|
||
} catch (err) {
|
||
setClipError(err instanceof Error ? err.message : "Failed to cut the clip");
|
||
}
|
||
};
|
||
|
||
// ── Timeline ────────────────────────────────────────────────────────────
|
||
const gridlines = useMemo(() => {
|
||
if (elapsedSeconds < GRID_INTERVAL_SECONDS) return [];
|
||
return Array.from(
|
||
{ length: Math.floor(elapsedSeconds / GRID_INTERVAL_SECONDS) },
|
||
(_, index) => ((index + 1) * GRID_INTERVAL_SECONDS) / elapsedSeconds * 100
|
||
);
|
||
}, [elapsedSeconds]);
|
||
|
||
const runningJobs = jobs.filter((job) => !isSettled(job)).length;
|
||
|
||
if (loading) return null;
|
||
if (!user) return <NoAccessPage />;
|
||
|
||
return (
|
||
<div className="mx-auto max-w-[1180px] px-13 pt-10 pb-18">
|
||
<PageHeader kicker="Ingest" title="Stream panel" />
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_340px] gap-[26px] items-start">
|
||
{/* ── Main column ───────────────────────────────────────── */}
|
||
<div className="flex flex-col gap-[26px]">
|
||
<Card className="p-7">
|
||
<div className="flex items-baseline justify-between gap-4 pb-5">
|
||
<h2 className="font-heading text-[22px]">Server credentials</h2>
|
||
{isStreaming ? (
|
||
<div className="flex items-center gap-2">
|
||
<IsLivePulse />
|
||
<p className="font-data text-[11px] uppercase tracking-[0.12em] text-live-red">Live now</p>
|
||
</div>
|
||
) : (
|
||
<p className="font-data text-[11px] uppercase tracking-[0.12em] text-faint-mono">Off air</p>
|
||
)}
|
||
</div>
|
||
|
||
<p className={labelClass}>Ingest URL</p>
|
||
<div className="flex items-center gap-2.5 pt-2">
|
||
<input
|
||
type="text"
|
||
readOnly
|
||
value={ingestUrl ?? ""}
|
||
placeholder="asking the server…"
|
||
className={clsx(monoInputClass, "flex-1")}
|
||
/>
|
||
<button
|
||
className={clsx(chipButtonClass, !ingestUrl && "opacity-40 cursor-not-allowed")}
|
||
disabled={!ingestUrl}
|
||
onClick={() => ingestUrl && copy("url", ingestUrl)}
|
||
>
|
||
{copied === "url" ? "Copied" : "Copy"}
|
||
</button>
|
||
</div>
|
||
|
||
<p className={clsx(labelClass, "pt-5 block")}>Stream key</p>
|
||
<div className="flex items-center gap-2.5 pt-2">
|
||
<div className="flex-1 bg-fields rounded-field px-3 py-2.5 font-data text-sm text-text-strong truncate">
|
||
{keyVisible ? user.streamKey : "•".repeat(Math.min(user.streamKey.length, 28))}
|
||
</div>
|
||
<button className={chipButtonClass} onClick={() => setKeyVisible((visible) => !visible)}>
|
||
{keyVisible ? "Hide" : "Reveal"}
|
||
</button>
|
||
<button className={chipButtonClass} onClick={() => copy("key", user.streamKey)}>
|
||
{copied === "key" ? "Copied" : "Copy"}
|
||
</button>
|
||
</div>
|
||
|
||
<p className="pt-3 font-body text-[13px] text-muted">
|
||
Keep this private — anyone holding the key can stream to your account.
|
||
</p>
|
||
</Card>
|
||
|
||
{isStreaming ? (
|
||
<Card className="p-7">
|
||
<div className="flex items-baseline justify-between gap-4 pb-5">
|
||
<h2 className="font-heading text-[22px]">Live markers</h2>
|
||
<p className="font-data text-[13px] text-faint-mono tabular-nums">t = {elapsed}</p>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<input
|
||
ref={markerInputRef}
|
||
type="text"
|
||
value={markerMessage}
|
||
onChange={(event) => setMarkerMessage(event.target.value)}
|
||
onKeyDown={(event) => event.key === "Enter" && handleAddMarker()}
|
||
placeholder="What just happened? (optional)"
|
||
className={clsx(inputClass, "flex-1")}
|
||
/>
|
||
<Button
|
||
variant="dark"
|
||
onClick={handleAddMarker}
|
||
disabled={markerBusy}
|
||
className="whitespace-nowrap px-5 py-3"
|
||
>
|
||
+ Drop marker at {elapsed}
|
||
</Button>
|
||
</div>
|
||
|
||
<p className="pt-2.5 font-body text-[13px] text-muted">
|
||
or press{" "}
|
||
<kbd className="bg-chip rounded-[5px] px-1.5 py-0.5 font-data text-[11px] text-text-control">M</kbd>
|
||
{" "}to jump here
|
||
</p>
|
||
|
||
{markerError && <p className="pt-2 font-body text-[13px] text-live-red">{markerError}</p>}
|
||
|
||
{/* Timeline overview */}
|
||
<div className="relative mt-5 h-[74px] rounded-chip bg-fields overflow-hidden">
|
||
{gridlines.map((left, index) => (
|
||
<span
|
||
key={index}
|
||
className="absolute inset-y-0 w-px bg-inactive/60"
|
||
style={{ left: `${left}%` }}
|
||
/>
|
||
))}
|
||
|
||
{markers.map((marker) => {
|
||
const left = elapsedSeconds > 0
|
||
? Math.min(100, Math.max(0, (offsetOf(marker) / elapsedSeconds) * 100))
|
||
: 0;
|
||
return (
|
||
<span
|
||
key={marker.id}
|
||
title={marker.message}
|
||
className={clsx(
|
||
"absolute inset-y-0 w-[2px]",
|
||
selected.includes(marker.id) ? "bg-accent" : "bg-olive-light"
|
||
)}
|
||
style={{ left: `${left}%` }}
|
||
/>
|
||
);
|
||
})}
|
||
|
||
<span className="absolute bottom-2 left-3 font-data text-[10px] text-faint-mono">00:00:00</span>
|
||
<span className="absolute bottom-2 right-3 font-data text-[10px] text-faint-mono tabular-nums">{elapsed}</span>
|
||
</div>
|
||
|
||
{/* Marker list */}
|
||
<div className="flex flex-col gap-2 pt-5">
|
||
{markers.length === 0 && (
|
||
<p className="font-body text-[13px] text-muted py-2">
|
||
No markers yet. Drop one the moment something happens — you can cut around it later.
|
||
</p>
|
||
)}
|
||
|
||
{markers.map((marker) => {
|
||
const isSelected = selected.includes(marker.id);
|
||
const offset = offsetOf(marker);
|
||
return (
|
||
<div key={marker.id} className="group flex items-center gap-1">
|
||
<button
|
||
onClick={() => toggleMarker(marker.id)}
|
||
className={clsx(
|
||
"flex flex-1 items-center gap-4 rounded-tile px-4 py-3 text-left transition-colors duration-150 min-w-0",
|
||
isSelected
|
||
? "bg-armed inset-shadow-armed"
|
||
: "bg-card inset-shadow-hairline hover:bg-fields"
|
||
)}
|
||
>
|
||
<span className={clsx(
|
||
"flex h-[18px] w-[18px] items-center justify-center rounded-[5px] flex-shrink-0",
|
||
isSelected ? "bg-accent text-on-accent" : "bg-inactive"
|
||
)}>
|
||
{isSelected && <Check size={12} strokeWidth={3} />}
|
||
</span>
|
||
|
||
<span className="font-data text-[13px] text-accent tabular-nums flex-shrink-0">
|
||
{formatTimecode(offset)}
|
||
</span>
|
||
|
||
<span className="flex-1 font-body text-sm text-text-strong truncate">
|
||
{marker.message}
|
||
</span>
|
||
|
||
<span className="font-data text-[12px] text-faint-mono flex-shrink-0">
|
||
{toRelativeOffset(elapsedSeconds - offset)}
|
||
</span>
|
||
</button>
|
||
|
||
<button
|
||
onClick={() => handleDeleteMarker(marker.id)}
|
||
title="Remove marker"
|
||
className="flex h-7 w-7 items-center justify-center rounded-chip text-faint-mono opacity-0 group-hover:opacity-100 hover:bg-chip hover:text-live-red transition-all duration-150"
|
||
>
|
||
<X size={14} />
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</Card>
|
||
) : (
|
||
<Card className="p-8 flex flex-col gap-2">
|
||
<p className={kickerClass}>Off air</p>
|
||
<h2 className="font-heading text-[27px] leading-[1.25]">Nothing is coming in right now.</h2>
|
||
<p className="font-body text-[14.5px] leading-[1.65] text-text-body max-w-[62ch] text-pretty">
|
||
Point OBS at the ingest URL above with your stream key, and this panel fills
|
||
in — elapsed time, markers, and the range cutter.
|
||
</p>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── Side panel ────────────────────────────────────────── */}
|
||
<aside className="flex flex-col gap-[26px] lg:sticky lg:top-10">
|
||
{isStreaming && (
|
||
<>
|
||
<div className="bg-panel rounded-panel shadow-panel p-6 flex flex-col gap-4">
|
||
<div className="flex flex-col gap-2">
|
||
<h2 className="font-heading text-[22px]">Make something from a range</h2>
|
||
<p className="font-body text-[13.5px] leading-[1.6] text-text-body">
|
||
{selected.length === 2 && rangeLength
|
||
? `Two markers selected — ${inValue} to ${outValue}.`
|
||
: "Tick two markers, or type a timecode by hand."}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="flex flex-col gap-1.5">
|
||
<label className={labelClass} htmlFor="range-in">In</label>
|
||
<input
|
||
id="range-in"
|
||
value={inValue}
|
||
onChange={(event) => setInValue(event.target.value)}
|
||
placeholder="00:00:00"
|
||
className={clsx(monoInputClass, "bg-card")}
|
||
/>
|
||
</div>
|
||
<div className="flex flex-col gap-1.5">
|
||
<label className={labelClass} htmlFor="range-out">Out</label>
|
||
<input
|
||
id="range-out"
|
||
value={outValue}
|
||
onChange={(event) => setOutValue(event.target.value)}
|
||
placeholder="00:00:00"
|
||
className={clsx(monoInputClass, "bg-card")}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-baseline justify-between gap-3">
|
||
<span className="font-body text-[13.5px] text-text-body">Range length</span>
|
||
<span className="font-data text-[15px] text-text-primary tabular-nums">
|
||
{rangeLength ?? "—"}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-1.5">
|
||
<label className={labelClass} htmlFor="range-name">Name</label>
|
||
<input
|
||
id="range-name"
|
||
value={rangeName}
|
||
onChange={(event) => setRangeName(event.target.value)}
|
||
placeholder="Give it a name"
|
||
className={clsx(inputClass, "bg-card")}
|
||
/>
|
||
</div>
|
||
|
||
{rangeError && <p className="font-body text-[13px] text-live-red">{rangeError}</p>}
|
||
|
||
<div className="flex flex-col gap-2.5">
|
||
<Button
|
||
onClick={handleSaveRange}
|
||
disabled={rangeBusy || rangeLength === null}
|
||
className="w-full py-3"
|
||
>
|
||
{rangeBusy ? "Queueing…" : "Save range as VOD"}
|
||
</Button>
|
||
<Button variant="ghost" onClick={clearSelection} className="w-full">
|
||
Clear selection
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-panel rounded-panel shadow-panel p-6 flex flex-col gap-4">
|
||
<div className="flex flex-col gap-2">
|
||
<h2 className="font-heading text-[22px]">Cut a clip from just now</h2>
|
||
<p className="font-body text-[13.5px] leading-[1.6] text-text-body">
|
||
Takes the last stretch of stream, counted back from this moment.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="flex flex-col gap-1.5">
|
||
<label className={labelClass} htmlFor="clip-duration">Seconds</label>
|
||
<input
|
||
id="clip-duration"
|
||
type="number"
|
||
min={1}
|
||
max={180}
|
||
value={clipDuration}
|
||
onChange={(event) => setClipDuration(Number(event.target.value))}
|
||
className={clsx(monoInputClass, "bg-card")}
|
||
/>
|
||
</div>
|
||
<div className="flex flex-col gap-1.5">
|
||
<label className={labelClass} htmlFor="clip-name">Name</label>
|
||
<input
|
||
id="clip-name"
|
||
value={clipName}
|
||
onChange={(event) => setClipName(event.target.value)}
|
||
placeholder="Optional"
|
||
className={clsx(inputClass, "bg-card")}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{clipError && <p className="font-body text-[13px] text-live-red">{clipError}</p>}
|
||
|
||
<Button
|
||
variant="olive"
|
||
onClick={handleClip}
|
||
disabled={clipDuration < 1 || clipDuration > 180}
|
||
className="w-full py-3"
|
||
>
|
||
Cut the last {clipDuration}s as a clip
|
||
</Button>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{jobs.length > 0 && (
|
||
<div className="bg-panel rounded-panel shadow-panel p-6 flex flex-col gap-4">
|
||
<div className="flex items-baseline justify-between gap-3">
|
||
<p className={kickerClass}>Background jobs</p>
|
||
<p className="font-data text-[11px] text-faint-mono">{runningJobs} running</p>
|
||
</div>
|
||
{jobs.map((job) => <JobProgress key={job.uuid} job={job} />)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="bg-panel rounded-panel p-6 flex flex-col gap-3">
|
||
<p className={kickerClass}>How it works</p>
|
||
<p className="font-body text-[14.5px] leading-[1.65] text-text-body text-pretty">
|
||
Tick two markers to fill the range automatically, or type a timecode by hand.
|
||
Saving queues a background job — you can keep streaming while it runs.
|
||
</p>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default StreamControl;
|