restyle the rest of the pages
This commit is contained in:
@@ -1,94 +1,72 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Radio, Check, Bookmark, Scissors, Film, Activity, Loader2, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Check, X } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import Card from "../components/Card.tsx";
|
||||
import BlueButton from "../components/buttons/BlueButton.tsx";
|
||||
import { getUser } from "../utils/api/users.ts";
|
||||
import { getCurrentStream, getStreamHistory } from "../utils/api/stream.ts";
|
||||
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 { saveSectionByMarkers, clipSection } from "../utils/api/media.ts";
|
||||
import { saveSection, clipSection } from "../utils/api/media.ts";
|
||||
import { getJob } from "../utils/api/jobs.ts";
|
||||
import type { User, StreamStatus, StreamHistoryItem, Marker, JobResponse } from "../utils/types.ts";
|
||||
import { formatTime, formatLocalDate, stringToDate } from "../utils/utils.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";
|
||||
|
||||
const inputClass = "border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors";
|
||||
const labelClass = "text-xs font-medium text-gray-500 uppercase tracking-wide";
|
||||
/** Ten-minute gridlines behind the marker ticks. */
|
||||
const GRID_INTERVAL_SECONDS = 600;
|
||||
|
||||
type TrackedJob = {
|
||||
uuid: string;
|
||||
label: string;
|
||||
progress: number;
|
||||
state: JobResponse['state'];
|
||||
errorOutput: string | null;
|
||||
};
|
||||
const useCopy = () => {
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
const CardHeader = ({ icon: Icon, title, accent = "primary" }: { icon: React.ElementType; title: string; accent?: "primary" | "accent" }) => (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className={clsx("w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0", accent === "primary" ? "bg-primary/10" : "bg-accent/10")}>
|
||||
<Icon size={16} className={accent === "primary" ? "text-primary" : "text-accent"} />
|
||||
</div>
|
||||
<h2 className="text-sm font-semibold text-gray-800">{title}</h2>
|
||||
</div>
|
||||
);
|
||||
const copy = (key: string, text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(key);
|
||||
setTimeout(() => setCopied((current) => (current === key ? null : current)), 1600);
|
||||
};
|
||||
|
||||
const JobStatus = ({ job }: { job: TrackedJob }) => {
|
||||
if (job.state === 'FAILED') {
|
||||
return (
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<X size={15} className="text-red-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-red-500">{job.label} failed: {job.errorOutput ?? "Unknown error"}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (job.state === 'SUCCEEDED') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Check size={15} className="text-green-600 flex-shrink-0" />
|
||||
<p className="text-gray-700">{job.label} saved</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 size={15} className="text-primary animate-spin flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-600 mb-1">{job.label}…</p>
|
||||
<progress value={job.progress} className="w-full h-1.5 rounded bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return { copied, copy };
|
||||
};
|
||||
|
||||
const StreamControl = () => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const { user, loading } = useAuth();
|
||||
const { copied, copy } = useCopy();
|
||||
|
||||
const [streamStatus, setStreamStatus] = useState<StreamStatus | null>(null);
|
||||
const [streamDetails, setStreamDetails] = useState<StreamHistoryItem | 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 [clipTitle, setClipTitle] = useState("");
|
||||
const [clipDescription, setClipDescription] = useState("");
|
||||
const [clipName, setClipName] = useState("");
|
||||
const [clipError, setClipError] = useState<string | null>(null);
|
||||
|
||||
const [startMarkerId, setStartMarkerId] = useState<string>("");
|
||||
const [endMarkerId, setEndMarkerId] = useState<string>("");
|
||||
const [markerSaveTitle, setMarkerSaveTitle] = useState("");
|
||||
const [markerSaveDescription, setMarkerSaveDescription] = useState("");
|
||||
const [markerSaveError, setMarkerSaveError] = 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?.startDate ? stringToDate(streamStatus.startDate).getTime() : null;
|
||||
const elapsedSeconds = startMs === null ? 0 : Math.max(0, (now - startMs) / 1000);
|
||||
const elapsed = formatTimecode(elapsedSeconds);
|
||||
|
||||
useEffect(() => {
|
||||
getUser().then(setUser).catch(() => setUser(null));
|
||||
}, []);
|
||||
|
||||
// ── Polling ─────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const poll = () => getCurrentStream().then(setStreamStatus).catch(() => {});
|
||||
@@ -98,24 +76,18 @@ const StreamControl = () => {
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !streamStatus?.id) {
|
||||
setStreamDetails(null);
|
||||
return;
|
||||
}
|
||||
getStreamHistory(user.id)
|
||||
.then((history) => setStreamDetails(history.find((s) => s.id === streamStatus.id) ?? null))
|
||||
.catch(() => {});
|
||||
}, [user, streamStatus?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!streamDetails?.startDate) return;
|
||||
const interval = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [streamDetails?.startDate]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
getIngestUrl().then(setIngestUrl).catch(() => setIngestUrl(null));
|
||||
}, [user]);
|
||||
|
||||
const refreshMarkers = () => {
|
||||
getMarkers()
|
||||
.then((data) => setMarkers(data
|
||||
.then((data) => setMarkers([...data]
|
||||
.sort((a, b) => stringToDate(a.timestamp).getTime() - stringToDate(b.timestamp).getTime())))
|
||||
.catch(() => {});
|
||||
};
|
||||
@@ -123,6 +95,7 @@ const StreamControl = () => {
|
||||
useEffect(() => {
|
||||
if (!isStreaming) {
|
||||
setMarkers([]);
|
||||
setSelected([]);
|
||||
return;
|
||||
}
|
||||
refreshMarkers();
|
||||
@@ -130,285 +103,499 @@ const StreamControl = () => {
|
||||
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(() => {
|
||||
const pending = jobs.filter((j) => j.state !== 'SUCCEEDED' && j.state !== 'FAILED');
|
||||
if (pending.length === 0) return;
|
||||
if (!pendingKey) return;
|
||||
const uuids = pendingKey.split(",");
|
||||
|
||||
const interval = setInterval(() => {
|
||||
pending.forEach((job) => {
|
||||
getJob(job.uuid)
|
||||
.then((result) => {
|
||||
setJobs((prev) => prev.map((j) => j.uuid === job.uuid
|
||||
? { ...j, progress: result.progress, state: result.state, errorOutput: result.errorOutput }
|
||||
: j));
|
||||
})
|
||||
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);
|
||||
}, [jobs]);
|
||||
}, [pendingKey]);
|
||||
|
||||
const addJob = (uuid: string, label: string) => {
|
||||
setJobs((prev) => [{ uuid, label, progress: 0, state: 'READY', errorOutput: null }, ...prev]);
|
||||
// ── Marker helpers ──────────────────────────────────────────────────────
|
||||
const offsetOf = (marker: Marker) =>
|
||||
startMs === null ? 0 : (stringToDate(marker.timestamp).getTime() - startMs) / 1000;
|
||||
|
||||
// Ticking two markers fills the range. Done here rather than in an effect so a
|
||||
// marker poll can't stomp on timecodes the user has since typed over.
|
||||
const toggleMarker = (id: number) => {
|
||||
const next = selected.includes(id)
|
||||
? selected.filter((current) => current !== id)
|
||||
// Third tick replaces the older of the two.
|
||||
: 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 (!markerMessage.trim() || markerBusy) return;
|
||||
if (markerBusy) return;
|
||||
setMarkerBusy(true);
|
||||
setMarkerError(null);
|
||||
try {
|
||||
await createMarker(markerMessage.trim());
|
||||
// Dropping first and naming later isn't possible — markers have no PATCH —
|
||||
// so an unnamed marker keeps the timecode it landed on.
|
||||
await createMarker(markerMessage.trim() || `Marker at ${elapsed}`);
|
||||
setMarkerMessage("");
|
||||
refreshMarkers();
|
||||
} catch (err) {
|
||||
setMarkerError(err instanceof Error ? err.message : "Failed to add marker");
|
||||
setMarkerError(err instanceof Error ? err.message : "Failed to drop the marker");
|
||||
} finally {
|
||||
setMarkerBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClip = async () => {
|
||||
setClipError(null);
|
||||
try {
|
||||
const uuid = await clipSection(clipDuration, clipTitle || undefined, clipDescription || undefined);
|
||||
addJob(uuid, `Clip (last ${clipDuration}s)`);
|
||||
setClipTitle("");
|
||||
setClipDescription("");
|
||||
} catch (err) {
|
||||
setClipError(err instanceof Error ? err.message : "Failed to clip section");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveByMarkers = async () => {
|
||||
setMarkerSaveError(null);
|
||||
if (!startMarkerId || !endMarkerId) {
|
||||
setMarkerSaveError("Tap two markers above to pick a start and end");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const uuid = await saveSectionByMarkers(
|
||||
Number(startMarkerId),
|
||||
Number(endMarkerId),
|
||||
markerSaveTitle || undefined,
|
||||
markerSaveDescription || undefined
|
||||
);
|
||||
addJob(uuid, "Save (by markers)");
|
||||
setStartMarkerId("");
|
||||
setEndMarkerId("");
|
||||
setMarkerSaveTitle("");
|
||||
setMarkerSaveDescription("");
|
||||
} catch (err) {
|
||||
setMarkerSaveError(err instanceof Error ? err.message : "Failed to save section");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteMarker = async (id: number) => {
|
||||
try {
|
||||
await deleteMarker(id);
|
||||
if (startMarkerId === String(id)) setStartMarkerId("");
|
||||
if (endMarkerId === String(id)) setEndMarkerId("");
|
||||
setSelected((prev) => prev.filter((current) => current !== id));
|
||||
refreshMarkers();
|
||||
} catch (err) {
|
||||
setMarkerError(err instanceof Error ? err.message : "Failed to delete marker");
|
||||
setMarkerError(err instanceof Error ? err.message : "Failed to remove the marker");
|
||||
}
|
||||
};
|
||||
|
||||
const handleChipClick = (id: number) => {
|
||||
const idStr = String(id);
|
||||
setMarkerSaveError(null);
|
||||
if (startMarkerId === idStr) { setStartMarkerId(""); return; }
|
||||
if (endMarkerId === idStr) { setEndMarkerId(""); return; }
|
||||
if (!startMarkerId) { setStartMarkerId(idStr); return; }
|
||||
if (!endMarkerId) { setEndMarkerId(idStr); return; }
|
||||
setEndMarkerId(idStr);
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-full text-gray-400 text-base">
|
||||
Please log in to control your stream.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const elapsed = streamDetails?.startDate
|
||||
? formatTime((now - stringToDate(streamDetails.startDate).getTime()) / 1000)
|
||||
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 startMarker = markers.find((m) => String(m.id) === startMarkerId);
|
||||
const endMarker = markers.find((m) => String(m.id) === endMarkerId);
|
||||
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="px-8 py-10 max-w-5xl mx-auto flex flex-col gap-5">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Stream Control</h1>
|
||||
<div className="mx-auto max-w-[1180px] px-13 pt-10 pb-18">
|
||||
<PageHeader kicker="Ingest" title="Stream panel" />
|
||||
|
||||
{isStreaming &&
|
||||
<Card className={"bg-card pb-10"}>
|
||||
<div className={"p-6"}>
|
||||
<p className={"pb-3 font-heading text-text-strong text-xl"}>Server credentials</p>
|
||||
<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={"font-body text-muted text-sm p-1"}>Ingest URL</p>
|
||||
<input
|
||||
type={"text"}
|
||||
contentEditable={"false"}
|
||||
className={"bg-fields rounded-xl focus:outline-none w-full p-3 text-text-strong text-sm font-data"}
|
||||
readOnly={true}
|
||||
defaultValue={"rtmp://vodsystem.ddns.net:1935/live"}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
}
|
||||
|
||||
{/* Hero status bar */}
|
||||
<Card className={clsx(
|
||||
"p-6 flex items-center justify-between gap-6 border-l-4",
|
||||
isStreaming ? "border-l-red-500" : "border-l-gray-200"
|
||||
)}>
|
||||
<div className="flex items-center gap-3.5">
|
||||
<span className="relative flex h-3.5 w-3.5">
|
||||
{isStreaming && <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75" />}
|
||||
<span className={clsx("relative inline-flex rounded-full h-3.5 w-3.5", isStreaming ? "bg-red-500" : "bg-gray-300")} />
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900 text-lg leading-tight">{isStreaming ? "Live" : "Offline"}</p>
|
||||
{isStreaming && streamDetails?.startDate ? (
|
||||
<p className="text-sm text-gray-500">Started {formatLocalDate(streamDetails.startDate)}</p>
|
||||
) : !isStreaming ? (
|
||||
<p className="text-sm text-gray-500">Point OBS at your stream key (see Profile) to go live.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isStreaming && elapsed ? (
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-mono font-semibold text-gray-900 tabular-nums">{elapsed}</p>
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wide">elapsed</p>
|
||||
</div>
|
||||
) : (
|
||||
<Radio size={28} className="text-gray-200" />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{isStreaming && (
|
||||
<>
|
||||
{/* Quick mark toolbar */}
|
||||
<Card className="p-4 flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Bookmark size={18} className="text-accent flex-shrink-0" />
|
||||
<p className={labelClass}>Ingest URL</p>
|
||||
<div className="flex items-center gap-2.5 pt-2">
|
||||
<input
|
||||
type="text"
|
||||
value={markerMessage}
|
||||
onChange={(e) => setMarkerMessage(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAddMarker()}
|
||||
placeholder="What just happened?"
|
||||
className={inputClass}
|
||||
readOnly
|
||||
value={ingestUrl ?? ""}
|
||||
placeholder="asking the server…"
|
||||
className={clsx(monoInputClass, "flex-1")}
|
||||
/>
|
||||
<BlueButton onClick={handleAddMarker} disabled={markerBusy || !markerMessage.trim()} className="whitespace-nowrap">
|
||||
Add Marker
|
||||
</BlueButton>
|
||||
<button
|
||||
className={clsx(chipButtonClass, !ingestUrl && "opacity-40 cursor-not-allowed")}
|
||||
disabled={!ingestUrl}
|
||||
onClick={() => ingestUrl && copy("url", ingestUrl)}
|
||||
>
|
||||
{copied === "url" ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
{markerError && <p className="text-sm text-red-500">{markerError}</p>}
|
||||
|
||||
{markers.length > 0 && (
|
||||
<div className="flex gap-2 overflow-x-auto pb-0.5 -mx-1 px-1 pt-1.5">
|
||||
{markers.map((m) => {
|
||||
const isStart = startMarkerId === String(m.id);
|
||||
const isEnd = endMarkerId === String(m.id);
|
||||
<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 (
|
||||
<div key={m.id} className="group relative flex-shrink-0">
|
||||
<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={() => handleChipClick(m.id)}
|
||||
onClick={() => toggleMarker(marker.id)}
|
||||
className={clsx(
|
||||
"flex flex-col items-start gap-0.5 rounded-lg border px-3 py-1.5 text-left transition-colors duration-150",
|
||||
isStart && "border-primary bg-primary/5 ring-1 ring-primary",
|
||||
isEnd && "border-accent bg-accent/5 ring-1 ring-accent",
|
||||
!isStart && !isEnd && "border-gray-200 hover:border-gray-300 hover:bg-gray-50"
|
||||
"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="text-xs font-medium text-gray-800 max-w-40 truncate">{m.message}</span>
|
||||
<span className="text-[11px] text-gray-400">
|
||||
{formatLocalDate(m.timestamp, { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
|
||||
{isStart && <span className="text-primary font-medium"> · start</span>}
|
||||
{isEnd && <span className="text-accent font-medium"> · end</span>}
|
||||
<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={(e) => { e.stopPropagation(); handleDeleteMarker(m.id); }}
|
||||
title="Delete marker"
|
||||
className="hidden group-hover:flex items-center justify-center absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-gray-400 hover:bg-red-500 text-white transition-colors duration-150"
|
||||
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={10} />
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Forefront actions */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5 items-stretch">
|
||||
<Card className="p-6 flex flex-col gap-3">
|
||||
<CardHeader icon={Scissors} title="Clip the last few seconds" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className={labelClass}>Duration (seconds)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={clipDuration}
|
||||
onChange={(e) => setClipDuration(Number(e.target.value))}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<input type="text" placeholder="Title" value={clipTitle} onChange={(e) => setClipTitle(e.target.value)} className={inputClass} />
|
||||
<textarea placeholder="Description" value={clipDescription} onChange={(e) => setClipDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} />
|
||||
{clipError && <p className="text-sm text-red-500">{clipError}</p>}
|
||||
<BlueButton onClick={handleClip} className="self-start mt-auto">Clip</BlueButton>
|
||||
</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>
|
||||
|
||||
<Card className="p-6 flex flex-col gap-3">
|
||||
<CardHeader icon={Film} title="Save between two markers" accent="accent" />
|
||||
{markers.length < 2 ? (
|
||||
<p className="text-sm text-gray-400">Add at least two markers above to save a section between them.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={clsx(
|
||||
"text-xs font-medium px-2.5 py-1 rounded-full border",
|
||||
startMarker ? "border-primary text-primary bg-primary/5" : "border-dashed border-gray-300 text-gray-400"
|
||||
)}>
|
||||
{startMarker ? startMarker.message : "Tap a marker to set start"}
|
||||
</span>
|
||||
<span className="text-gray-300">→</span>
|
||||
<span className={clsx(
|
||||
"text-xs font-medium px-2.5 py-1 rounded-full border",
|
||||
endMarker ? "border-accent text-accent bg-accent/5" : "border-dashed border-gray-300 text-gray-400"
|
||||
)}>
|
||||
{endMarker ? endMarker.message : "Tap a marker to set end"}
|
||||
</span>
|
||||
{/* ── 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>
|
||||
<input type="text" placeholder="Title" value={markerSaveTitle} onChange={(e) => setMarkerSaveTitle(e.target.value)} className={inputClass} />
|
||||
<textarea placeholder="Description" value={markerSaveDescription} onChange={(e) => setMarkerSaveDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} />
|
||||
{markerSaveError && <p className="text-sm text-red-500">{markerSaveError}</p>}
|
||||
</>
|
||||
)}
|
||||
<BlueButton onClick={handleSaveByMarkers} disabled={markers.length < 2} className="self-start mt-auto">Save</BlueButton>
|
||||
</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>
|
||||
|
||||
<Card className="p-5 flex flex-col gap-3">
|
||||
<CardHeader icon={Activity} title="Recent actions" />
|
||||
{jobs.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">Clip and save actions will show up here.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{jobs.map((job) => <JobStatus key={job.uuid} job={job} />)}
|
||||
<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>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user