restyle the rest of the pages
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Playbar from "./../components/video/Playbar";
|
||||
import PlaybackSlider from "./../components/video/PlaybackSlider";
|
||||
import ClipRangeSlider from "./../components/video/ClipRangeSlider";
|
||||
import TrimTimeline, { type Range } from "../components/video/TrimTimeline.tsx";
|
||||
import ConfigBox from "../components/video/ConfigBox.tsx";
|
||||
import ExportWidget from "../components/video/ExportWidget.tsx";
|
||||
import { compress } from "../utils/api/media"
|
||||
@@ -10,6 +9,9 @@ import { getJob } from "../utils/api/jobs"
|
||||
import type { VideoMetadata } from "../utils/types.ts";
|
||||
import Card from "../components/Card.tsx";
|
||||
import MetadataBox from "../components/video/MetadataBox.tsx";
|
||||
import PageHeader from "../components/PageHeader.tsx";
|
||||
import { formatTime, formatTimecode } from "../utils/utils.ts";
|
||||
import { kickerClass } from "../utils/styles.ts";
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
@@ -23,6 +25,7 @@ const ClipEdit = () => {
|
||||
|
||||
const [metadata, setMetadata] = useState<VideoMetadata | null>(null);
|
||||
const [playbackValue, setPlaybackValue] = useState(0);
|
||||
const [range, setRange] = useState<Range>([0, 5]);
|
||||
const [outputMetadata, setOutputMetadata] = useState<VideoMetadata>({
|
||||
title: "",
|
||||
description: "",
|
||||
@@ -58,8 +61,30 @@ const ClipEdit = () => {
|
||||
fps: 30,
|
||||
fileSize: localFile?.size ?? 0,
|
||||
});
|
||||
// Start with the whole thing trimmed in — narrowing is the common move.
|
||||
setRange([0, video.duration]);
|
||||
setOutputMetadata(prev => ({ ...prev, startPoint: 0, duration: video.duration }));
|
||||
};
|
||||
|
||||
// Keep the playhead in step with the video while it plays.
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
const onTime = () => setPlaybackValue(video.currentTime);
|
||||
video.addEventListener("timeupdate", onTime);
|
||||
return () => video.removeEventListener("timeupdate", onTime);
|
||||
}, [videoUrl]);
|
||||
|
||||
const handleRangeChange = useCallback((next: Range) => {
|
||||
setRange(next);
|
||||
setOutputMetadata(prev => ({ ...prev, startPoint: next[0], duration: next[1] - next[0] }));
|
||||
}, []);
|
||||
|
||||
const handleSeek = useCallback((seconds: number) => {
|
||||
setPlaybackValue(seconds);
|
||||
if (videoRef.current) videoRef.current.currentTime = seconds;
|
||||
}, []);
|
||||
|
||||
const sendData = async () => {
|
||||
if (!localFile || uploading) return;
|
||||
|
||||
@@ -79,7 +104,7 @@ const ClipEdit = () => {
|
||||
const interval = setInterval(async () => await pollProgress(jobId, interval), 500);
|
||||
};
|
||||
|
||||
const pollProgress = async (jobId: string, intervalId: number) => {
|
||||
const pollProgress = async (jobId: string, intervalId: ReturnType<typeof setInterval>) => {
|
||||
getJob(jobId)
|
||||
.then((job) => {
|
||||
setProgress(job.progress);
|
||||
@@ -124,60 +149,78 @@ const ClipEdit = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"grid grid-cols-[7fr_3fr] gap-4 p-4"}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl}
|
||||
className={"w-full rounded-lg shadow-sm bg-black"}
|
||||
onLoadedMetadata={handleVideoMetadataLoaded}
|
||||
/>
|
||||
<div className="mx-auto max-w-[1180px] px-13 pt-10 pb-18">
|
||||
<PageHeader kicker="Upload" title="Trim and compress" />
|
||||
|
||||
<Card className={"w-4/5 h-full m-auto"}>
|
||||
<MetadataBox setMetadata={setOutputMetadata} />
|
||||
<ConfigBox setMetadata={setOutputMetadata} />
|
||||
</Card>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_340px] gap-[26px] items-start">
|
||||
<div className="flex flex-col gap-[26px]">
|
||||
{/* Player — the video and its transport read as one object. */}
|
||||
<Card className="overflow-hidden">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl}
|
||||
className="w-full aspect-video bg-player object-contain"
|
||||
onLoadedMetadata={handleVideoMetadataLoaded}
|
||||
/>
|
||||
{metadata &&
|
||||
<Playbar
|
||||
video={videoRef.current}
|
||||
videoMetadata={metadata}
|
||||
className="w-full px-5 pt-3.5"
|
||||
/>
|
||||
}
|
||||
</Card>
|
||||
|
||||
{metadata &&
|
||||
<Card className={"mt-4 p-5"}>
|
||||
<Playbar
|
||||
video={videoRef.current}
|
||||
videoMetadata={metadata}
|
||||
className={"w-full accent-primary"}
|
||||
/>
|
||||
{metadata &&
|
||||
<Card className="p-6">
|
||||
<div className="flex items-baseline justify-between gap-4 pb-4">
|
||||
<p className={kickerClass}>Trim</p>
|
||||
<p className="font-data text-[12px] text-faint-mono tabular-nums">
|
||||
in {formatTimecode(range[0])} · out {formatTimecode(range[1])} ·{" "}
|
||||
{formatTime(range[1] - range[0])} long
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PlaybackSlider
|
||||
videoRef={videoRef.current}
|
||||
videoMetadata={metadata}
|
||||
sliderValue={playbackValue}
|
||||
setSliderValue={setPlaybackValue}
|
||||
className={"w-full accent-primary"}
|
||||
/>
|
||||
<TrimTimeline
|
||||
duration={metadata.duration}
|
||||
range={range}
|
||||
onRangeChange={handleRangeChange}
|
||||
currentTime={playbackValue}
|
||||
onSeek={handleSeek}
|
||||
/>
|
||||
|
||||
<ClipRangeSlider
|
||||
videoRef={videoRef.current}
|
||||
videoMetadata={metadata}
|
||||
setSliderValue={setPlaybackValue}
|
||||
setMetadata={setOutputMetadata}
|
||||
className={"w-full mt-2 bg-primary"}
|
||||
/>
|
||||
<p className="pt-3 font-body text-[12px] text-muted">
|
||||
Scroll over the timeline to zoom, drag the outlined window above to pan.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className={"text-red-600 text-center mt-2"}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
}
|
||||
{error && (
|
||||
<p className="font-body text-[13px] text-live-red pt-3">{error}</p>
|
||||
)}
|
||||
</Card>
|
||||
}
|
||||
</div>
|
||||
|
||||
<Card className={"flex flex-col gap-2 w-4/5 m-auto mt-4 p-5"}>
|
||||
<ExportWidget
|
||||
dataSend={sendData}
|
||||
handleDownload={handleDownload}
|
||||
downloadable={downloadable}
|
||||
progress={progress}
|
||||
uploading={uploading}
|
||||
/>
|
||||
</Card>
|
||||
<aside className="flex flex-col gap-[26px] lg:sticky lg:top-10">
|
||||
<Card className="p-6 flex flex-col gap-4">
|
||||
<p className={kickerClass}>Details</p>
|
||||
<MetadataBox setMetadata={setOutputMetadata} />
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<ConfigBox metadata={outputMetadata} setMetadata={setOutputMetadata} />
|
||||
</Card>
|
||||
|
||||
<div className="bg-panel rounded-panel shadow-panel p-6">
|
||||
<ExportWidget
|
||||
dataSend={sendData}
|
||||
handleDownload={handleDownload}
|
||||
downloadable={downloadable}
|
||||
progress={progress}
|
||||
uploading={uploading}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,40 +1,89 @@
|
||||
import {useState} from "react";
|
||||
import {useNavigate} from "react-router-dom";
|
||||
import BlueButton from "../components/buttons/BlueButton.tsx";
|
||||
import Card from "../components/Card.tsx";
|
||||
import { useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import clsx from "clsx";
|
||||
import Button from "../components/buttons/Button.tsx";
|
||||
import PageHeader from "../components/PageHeader.tsx";
|
||||
import { kickerClass } from "../utils/styles.ts";
|
||||
|
||||
const formatSize = (bytes: number): string => {
|
||||
const mb = bytes / 1_000_000;
|
||||
return mb >= 1000 ? `${(mb / 1000).toFixed(2)} GB` : `${mb.toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const ClipUpload = () => {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [error, setError] = useState<null | string>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const choose = (selected: File | null) => {
|
||||
setFile(selected);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const press = () => {
|
||||
if (!file) {
|
||||
setError("Please choose a file");
|
||||
setError("Pick a file first.");
|
||||
return;
|
||||
}
|
||||
navigate('/create/new', { state: { file } });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-start justify-center p-8 min-h-full">
|
||||
<Card className="flex flex-col gap-4 p-6 w-full max-w-md">
|
||||
<h1 className="text-lg font-semibold text-gray-900">Upload Clip</h1>
|
||||
<div className="mx-auto max-w-180 px-13 pt-10 pb-18">
|
||||
<PageHeader kicker="Upload" title="Bring in a file" />
|
||||
|
||||
<label
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
choose(e.dataTransfer.files?.[0] ?? null);
|
||||
}}
|
||||
className={clsx(
|
||||
"flex aspect-16/7 cursor-pointer flex-col items-center justify-center gap-2 rounded-hero px-8 text-center transition-colors duration-150",
|
||||
dragging ? "bg-armed inset-shadow-armed" : "bg-dropzone inset-shadow-dashed hover:bg-fields"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
onChange={(e) => {
|
||||
const selected = e.target.files?.[0] ?? null;
|
||||
setFile(selected);
|
||||
setError(null);
|
||||
}}
|
||||
className="h-36 cursor-pointer rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-4 text-center text-sm text-gray-500 hover:bg-gray-100 hover:border-primary transition-colors duration-150"
|
||||
accept="video/*"
|
||||
className="hidden"
|
||||
onChange={(e) => choose(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
|
||||
<BlueButton onClick={press}>Upload</BlueButton>
|
||||
{file ? (
|
||||
<>
|
||||
<p className="font-heading text-[25px]">{file.name}</p>
|
||||
<p className="font-data text-[12px] text-faint-mono">{formatSize(file.size)}</p>
|
||||
<p className="font-body text-[13px] text-muted pt-1">Click to pick a different one.</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className={kickerClass}>Video file</p>
|
||||
<p className="font-heading text-[25px]">Drop a file here</p>
|
||||
<p className="font-body text-[14.5px] text-text-body max-w-[42ch] text-pretty">
|
||||
Or click anywhere in this box to browse. You'll trim and compress it on the next screen.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{error && <p className="text-sm text-red-500 text-center">{error}</p>}
|
||||
</Card>
|
||||
<div className="flex items-center gap-4 pt-6">
|
||||
<Button onClick={press} disabled={!file} className="px-6 py-3">
|
||||
{file ? "Trim and compress" : "Waiting for a file"}
|
||||
</Button>
|
||||
{file && (
|
||||
<Button variant="ghost" onClick={() => { choose(null); if (inputRef.current) inputRef.current.value = ""; }}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="font-body text-[13px] text-live-red pt-3">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
};
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, Check } from "lucide-react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { getClipById, patchClip } from "../utils/api/clips.ts";
|
||||
import Card from "../components/Card.tsx";
|
||||
import BlueButton from "../components/buttons/BlueButton.tsx";
|
||||
|
||||
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";
|
||||
import Button from "../components/buttons/Button.tsx";
|
||||
import PageHeader from "../components/PageHeader.tsx";
|
||||
import { inputClass, labelClass, quietLinkClass } from "../utils/styles.ts";
|
||||
|
||||
const EditClip = () => {
|
||||
const { id } = useParams();
|
||||
@@ -47,57 +48,49 @@ const EditClip = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-8 py-10 max-w-2xl mx-auto">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 mb-6 transition-colors duration-150"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<div className="mx-auto max-w-[720px] px-13 pt-10 pb-18">
|
||||
<button onClick={() => navigate(-1)} className={clsx(quietLinkClass, "flex items-center gap-1.5 pb-5")}>
|
||||
<ArrowLeft size={15} />
|
||||
Back
|
||||
</button>
|
||||
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Edit Clip</h1>
|
||||
<PageHeader kicker="Clip" title="Edit details" />
|
||||
|
||||
{loading ? (
|
||||
<div className="text-gray-400 text-sm">Loading...</div>
|
||||
<p className="font-data text-[13px] text-faint-mono">loading…</p>
|
||||
) : (
|
||||
<Card className="p-6 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-gray-700">Title</label>
|
||||
<Card className="p-7 flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className={labelClass} htmlFor="clip-title">Title</label>
|
||||
<input
|
||||
id="clip-title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Enter title"
|
||||
placeholder="Cat walks across the patch bay"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-gray-700">Description</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className={labelClass} htmlFor="clip-description">Description</label>
|
||||
<textarea
|
||||
id="clip-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Enter description"
|
||||
placeholder="What's worth remembering about it"
|
||||
rows={4}
|
||||
className={inputClass + " resize-none"}
|
||||
className={clsx(inputClass, "resize-none leading-[1.65]")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
{error && <p className="font-body text-[13px] text-live-red">{error}</p>}
|
||||
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<BlueButton onClick={handleSave} disabled={saving} className="w-28">
|
||||
{saving ? "Saving…" : saved
|
||||
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span>
|
||||
: "Save"}
|
||||
</BlueButton>
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button onClick={handleSave} disabled={saving} className="px-6">
|
||||
{saving ? "Saving…" : saved ? "Saved" : "Save changes"}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => navigate(-1)}>Cancel</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, Check } from "lucide-react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { getVodById, patchVod } from "../utils/api/vods.ts";
|
||||
import Card from "../components/Card.tsx";
|
||||
import BlueButton from "../components/buttons/BlueButton.tsx";
|
||||
|
||||
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";
|
||||
import Button from "../components/buttons/Button.tsx";
|
||||
import PageHeader from "../components/PageHeader.tsx";
|
||||
import { inputClass, labelClass, quietLinkClass } from "../utils/styles.ts";
|
||||
|
||||
const EditVod = () => {
|
||||
const { id } = useParams();
|
||||
@@ -47,57 +48,49 @@ const EditVod = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-8 py-10 max-w-2xl mx-auto">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 mb-6 transition-colors duration-150"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<div className="mx-auto max-w-[720px] px-13 pt-10 pb-18">
|
||||
<button onClick={() => navigate(-1)} className={clsx(quietLinkClass, "flex items-center gap-1.5 pb-5")}>
|
||||
<ArrowLeft size={15} />
|
||||
Back
|
||||
</button>
|
||||
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Edit VoD</h1>
|
||||
<PageHeader kicker="VoD" title="Edit details" />
|
||||
|
||||
{loading ? (
|
||||
<div className="text-gray-400 text-sm">Loading...</div>
|
||||
<p className="font-data text-[13px] text-faint-mono">loading…</p>
|
||||
) : (
|
||||
<Card className="p-6 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-gray-700">Title</label>
|
||||
<Card className="p-7 flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className={labelClass} htmlFor="vod-title">Title</label>
|
||||
<input
|
||||
id="vod-title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Enter title"
|
||||
placeholder="Tuesday night, patch bay walkthrough"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-gray-700">Description</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className={labelClass} htmlFor="vod-description">Description</label>
|
||||
<textarea
|
||||
id="vod-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Enter description"
|
||||
placeholder="What's worth remembering about it"
|
||||
rows={4}
|
||||
className={inputClass + " resize-none"}
|
||||
className={clsx(inputClass, "resize-none leading-[1.65]")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
{error && <p className="font-body text-[13px] text-live-red">{error}</p>}
|
||||
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<BlueButton onClick={handleSave} disabled={saving} className="w-28">
|
||||
{saving ? "Saving…" : saved
|
||||
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span>
|
||||
: "Save"}
|
||||
</BlueButton>
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button onClick={handleSave} disabled={saving} className="px-6">
|
||||
{saving ? "Saving…" : saved ? "Saved" : "Save changes"}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => navigate(-1)}>Cancel</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {useAuth} from "../utils/AuthContext.tsx";
|
||||
import NoAccessPage from "../components/NoAccessPage.tsx";
|
||||
import Landing from "./Landing.tsx";
|
||||
import {Link, useNavigate} from "react-router-dom";
|
||||
import Card from "../components/Card.tsx";
|
||||
import {useEffect, useState} from "react";
|
||||
@@ -7,10 +7,11 @@ import IsLivePulse from "../components/IsLivePulse.tsx";
|
||||
import type {Clip, Marker, StreamStatus, Vod} from "../utils/types.ts";
|
||||
import {getCurrentStream} from "../utils/api/stream.ts";
|
||||
import VideoCard from "../components/video/VideoCard.tsx";
|
||||
import {getMarkers} from "../utils/api/markers.ts";
|
||||
import {createMarker, getMarkers} from "../utils/api/markers.ts";
|
||||
import clsx from "clsx";
|
||||
import {getVods} from "../utils/api/vods.ts";
|
||||
import {getClips} from "../utils/api/clips.ts";
|
||||
import DarkButton from "../components/buttons/DarkButton.tsx";
|
||||
import Button from "../components/buttons/Button.tsx";
|
||||
|
||||
type TimeOfDay = "morning" | "afternoon" | "evening" | "night";
|
||||
|
||||
@@ -55,7 +56,7 @@ function getGreetingSubtext(date: Date = new Date()): string {
|
||||
|
||||
function getTimeFromISOString(date: string | null): string {
|
||||
if (date == null) {
|
||||
throw new Error("API has returned no start date");
|
||||
return "--:--";
|
||||
}
|
||||
|
||||
const d = new Date(date);
|
||||
@@ -66,6 +67,22 @@ function getTimeFromISOString(date: string | null): string {
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
type SectionHeaderProps = { title: string; to: string; count: number };
|
||||
|
||||
const SectionHeader = ({ title, to, count }: SectionHeaderProps) => (
|
||||
<div className={"flex flex-row items-baseline justify-between gap-4 pb-4"}>
|
||||
<h2 className={"font-heading text-2xl"}>{title}</h2>
|
||||
{count > 0 && (
|
||||
<Link
|
||||
to={to}
|
||||
className={"font-body text-[13.5px] text-accent hover:text-accent-hover transition-colors duration-150"}
|
||||
>
|
||||
All {count} <span aria-hidden={true}>→</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const Home = () => {
|
||||
const now = new Date();
|
||||
const formatted: string = new Intl.DateTimeFormat('en-IE', {
|
||||
@@ -80,6 +97,11 @@ const Home = () => {
|
||||
const [ markers, setMarkers ] = useState<Marker[]>([])
|
||||
const [ clips, setClips ] = useState<Clip[]>([]);
|
||||
const [ vods, setVods ] = useState<Vod[]>([]);
|
||||
const [ counts, setCounts ] = useState({ vods: 0, clips: 0 });
|
||||
const [ markerBusy, setMarkerBusy ] = useState(false);
|
||||
const [ markerNote, setMarkerNote ] = useState<string | null>(null);
|
||||
|
||||
const isStreaming = streamStatus?.isStreaming ?? false;
|
||||
|
||||
const [ greeting ] = useState<string>(getGreetingSubtext(new Date()));
|
||||
const navigate = useNavigate();
|
||||
@@ -87,17 +109,7 @@ const Home = () => {
|
||||
// Fetch Markers & Stream information
|
||||
useEffect(() => {
|
||||
getCurrentStream()
|
||||
.then(streamStatus => {
|
||||
setStreamStatus(streamStatus)
|
||||
|
||||
// ---- FOR TESTING ONLY, REMOVE AT THE END -----
|
||||
const stream: StreamStatus = {
|
||||
isStreaming : true,
|
||||
id : 49,
|
||||
startDate : "2026-07-30T18:23:01.123456Z"
|
||||
}
|
||||
setStreamStatus(stream);
|
||||
})
|
||||
.then(setStreamStatus)
|
||||
|
||||
getMarkers()
|
||||
.then(data => {
|
||||
@@ -111,6 +123,7 @@ const Home = () => {
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.slice(0, 4);
|
||||
setClips(topSix);
|
||||
setCounts(prev => ({ ...prev, clips: data.length }));
|
||||
})
|
||||
|
||||
// Get top 3 vods
|
||||
@@ -120,9 +133,24 @@ const Home = () => {
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.slice(0, 3);
|
||||
setVods(topSix);
|
||||
setCounts(prev => ({ ...prev, vods: data.length }));
|
||||
})
|
||||
}, []);
|
||||
|
||||
const handleDropMarker = async () => {
|
||||
if (markerBusy) return;
|
||||
setMarkerBusy(true);
|
||||
try {
|
||||
await createMarker(`Marker at ${elapsed}`);
|
||||
setMarkers(await getMarkers());
|
||||
setMarkerNote(`dropped at ${elapsed}`);
|
||||
} catch (err) {
|
||||
setMarkerNote(err instanceof Error ? err.message : "Couldn't drop that marker");
|
||||
} finally {
|
||||
setMarkerBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (streamStatus?.startDate == null) {
|
||||
setElapsed('00:00:00');
|
||||
@@ -145,7 +173,7 @@ const Home = () => {
|
||||
}, [streamStatus?.startDate]);
|
||||
|
||||
if (user == null) {
|
||||
return (<NoAccessPage />)
|
||||
return (<Landing />)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -161,47 +189,89 @@ const Home = () => {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{streamStatus?.isStreaming &&
|
||||
<Card className={"bg-live-card p-8 mb-8"}>
|
||||
<div className={"flex flex-row items-center gap-2"}>
|
||||
<IsLivePulse />
|
||||
<p className={"text-xs font-data text-live-red"}>LIVE NOW</p>
|
||||
</div>
|
||||
{/* Live status — always present, so the page has the same shape either way. */}
|
||||
<Card className={clsx("p-8 mb-8", isStreaming ? "bg-live-card" : "bg-card")}>
|
||||
<div className={"flex flex-row items-center gap-2"}>
|
||||
{isStreaming
|
||||
? <IsLivePulse />
|
||||
: <span className={"h-2 w-2 rounded-full bg-inactive-strong"} />}
|
||||
<p className={clsx(
|
||||
"text-xs font-data tracking-[0.12em]",
|
||||
isStreaming ? "text-live-red" : "text-faint-mono"
|
||||
)}>
|
||||
{isStreaming ? "LIVE NOW" : "OFF AIR"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={"p-1"}></div>
|
||||
<div className={"p-1"}></div>
|
||||
|
||||
<h2 className={"font-heading text-3xl"}>
|
||||
Session {String(streamStatus.id).padStart(5, '0')} {' '}
|
||||
recording since {getTimeFromISOString(streamStatus.startDate)}.</h2>
|
||||
{isStreaming ? (
|
||||
<>
|
||||
<h2 className={"font-heading text-3xl"}>
|
||||
Session {String(streamStatus?.id).padStart(5, '0')} {' '}
|
||||
recording since {getTimeFromISOString(streamStatus?.startDate ?? null)}.</h2>
|
||||
|
||||
<div className={"flex flex-row items-center gap-10 pt-4"}>
|
||||
<div className={"flex flex-col"}>
|
||||
<h2 className={"font-data text-xl"}>{elapsed}</h2>
|
||||
<p className={"text-xs text-muted"}>elapsed</p>
|
||||
<div className={"flex flex-row items-center gap-10 pt-4"}>
|
||||
<div className={"flex flex-col"}>
|
||||
<h2 className={"font-data text-xl"}>{elapsed}</h2>
|
||||
<p className={"text-xs text-muted"}>elapsed</p>
|
||||
</div>
|
||||
|
||||
<div className={"flex flex-col"}>
|
||||
<h2 className={"font-data text-xl"}>{markers.length}</h2>
|
||||
<p className={"text-xs text-muted"}>markers dropped</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={"flex flex-col"}>
|
||||
<h2 className={"font-data text-xl"}>{markers.length}</h2>
|
||||
<p className={"text-xs text-muted"}>markers dropped</p>
|
||||
<div className={"mt-4 flex items-center gap-4"}>
|
||||
<Button onClick={handleDropMarker} disabled={markerBusy}>+ Drop marker</Button>
|
||||
{markerNote && <p className={"font-data text-[12px] text-muted"}>{markerNote}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className={"font-heading text-3xl"}>Nothing is streaming right now.</h2>
|
||||
<p className={"pt-2 font-body text-[14.5px] leading-[1.65] text-text-body max-w-[62ch] text-pretty"}>
|
||||
Point OBS at the ingest URL with your stream key and this block turns over to
|
||||
elapsed time and markers.
|
||||
</p>
|
||||
|
||||
<div className={"mt-4"}>
|
||||
<DarkButton>+ Drop Marker</DarkButton>
|
||||
</div>
|
||||
</Card>
|
||||
}
|
||||
<div className={"flex flex-row items-center gap-10 pt-4"}>
|
||||
<div className={"flex flex-col"}>
|
||||
<h2 className={"font-data text-xl"}>{counts.vods}</h2>
|
||||
<p className={"text-xs text-muted"}>vods saved</p>
|
||||
</div>
|
||||
|
||||
<div className={"flex flex-col"}>
|
||||
<h2 className={"font-data text-xl"}>{counts.clips}</h2>
|
||||
<p className={"text-xs text-muted"}>clips cut</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={"mt-4"}>
|
||||
<Link to={"/stream"}>
|
||||
<Button variant={"secondary"}>Open the stream panel</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className={"flex flex-col"}>
|
||||
<h2 className={"font-heading text-2xl"}>Recent VoDs</h2>
|
||||
<SectionHeader title={"Recent VoDs"} to={"/my-vods"} count={counts.vods} />
|
||||
|
||||
<div className={"grid grid-cols-3 gap-4"}>
|
||||
<div className={"grid grid-cols-3 gap-5.5"}>
|
||||
{vods.map((vod) => (
|
||||
<VideoCard
|
||||
key={vod.id}
|
||||
id={vod.id}
|
||||
title={vod.title}
|
||||
duration={vod.duration}
|
||||
createdAt={vod.createdAt}
|
||||
width={vod.width}
|
||||
height={vod.height}
|
||||
fps={vod.fps}
|
||||
fileSize={vod.fileSize}
|
||||
mediaApiPath="/api/v1/vods"
|
||||
playerPath="/vod"
|
||||
itemLabel="vod"
|
||||
@@ -211,16 +281,21 @@ const Home = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={"flex flex-col mt-4"}>
|
||||
<h2 className={"font-heading text-2xl"}>Recent Clips</h2>
|
||||
<div className={"flex flex-col mt-9"}>
|
||||
<SectionHeader title={"Recent Clips"} to={"/my-clips"} count={counts.clips} />
|
||||
|
||||
<div className={"grid grid-cols-4 gap-4"}>
|
||||
<div className={"grid grid-cols-4 gap-4.5"}>
|
||||
{clips.map((clip) => (
|
||||
<VideoCard
|
||||
key={clip.id}
|
||||
id={clip.id}
|
||||
title={clip.title}
|
||||
duration={clip.duration}
|
||||
createdAt={clip.createdAt}
|
||||
width={clip.width}
|
||||
height={clip.height}
|
||||
fps={clip.fps}
|
||||
fileSize={clip.fileSize}
|
||||
mediaApiPath="/api/v1/clips"
|
||||
playerPath="/clip"
|
||||
itemLabel="clip"
|
||||
|
||||
108
frontend/src/pages/Landing.tsx
Normal file
108
frontend/src/pages/Landing.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import Card from "../components/Card.tsx";
|
||||
import GoogleSignIn from "../components/GoogleSignIn.tsx";
|
||||
import { kickerClass } from "../utils/styles.ts";
|
||||
|
||||
const steps = [
|
||||
{
|
||||
number: "01",
|
||||
title: "Point OBS at it",
|
||||
body: "One ingest URL and one stream key. Everything you send lands on disk as it arrives — no upload step, no waiting at the end.",
|
||||
},
|
||||
{
|
||||
number: "02",
|
||||
title: "Mark it as it happens",
|
||||
body: "Drop a marker the moment something is worth keeping. Each one remembers its timecode so you don't have to scrub for it later.",
|
||||
},
|
||||
{
|
||||
number: "03",
|
||||
title: "Cut it up afterwards",
|
||||
body: "Tick two markers to pull the stretch between them out as a VoD, or take the last minute as a clip. Cutting runs in the background while you keep streaming.",
|
||||
},
|
||||
];
|
||||
|
||||
/** Stand-in for the marker list on the stream panel — the shape of the thing, in miniature. */
|
||||
const previewMarkers = [
|
||||
{ at: "00:10:12", message: "Patch bay walkthrough begins", selected: false },
|
||||
{ at: "00:49:56", message: "Filter finally tracks", selected: true },
|
||||
{ at: "00:50:37", message: "…and it stops tracking", selected: true },
|
||||
{ at: "01:05:01", message: "Cat on the desk", selected: false },
|
||||
];
|
||||
|
||||
const Landing = () => (
|
||||
<div className="mx-auto max-w-[1180px] px-13 pt-16 pb-18">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_380px] gap-12 items-start pb-16">
|
||||
<div className="flex flex-col gap-5">
|
||||
<p className={kickerClass}>Personal stream archive</p>
|
||||
|
||||
<h1 className="font-heading text-5xl tracking-[-0.015em] leading-[1.1] max-w-[16ch]">
|
||||
Keep the good bits of everything you stream.
|
||||
</h1>
|
||||
|
||||
<p className="font-body text-[16px] leading-[1.65] text-text-body max-w-[58ch] text-pretty">
|
||||
The VoD System sits between your encoder and your hard drive. It records
|
||||
every session in full, lets you mark the moments worth keeping while
|
||||
they're happening, and turns those marks into VoDs and clips afterwards.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-3 pt-2">
|
||||
<GoogleSignIn shape="rectangular" size="large" width="260" />
|
||||
<p className="font-body text-[12.5px] text-muted">
|
||||
Sign in with Google to reach your library.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* A quiet look at what's behind the login. */}
|
||||
<Card className="p-6 flex flex-col gap-3">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<p className={kickerClass}>Live markers</p>
|
||||
<p className="font-data text-[12px] text-faint-mono">t = 01:33:35</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{previewMarkers.map((marker) => (
|
||||
<div
|
||||
key={marker.at}
|
||||
className={`flex items-center gap-3 rounded-tile px-3 py-2.5 ${
|
||||
marker.selected ? "bg-armed inset-shadow-armed" : "bg-fields"
|
||||
}`}
|
||||
>
|
||||
<span className={`h-[14px] w-[14px] flex-shrink-0 rounded-[4px] ${
|
||||
marker.selected ? "bg-accent" : "bg-inactive"
|
||||
}`} />
|
||||
<span className="font-data text-[12px] text-accent flex-shrink-0">{marker.at}</span>
|
||||
<span className="font-body text-[12.5px] text-text-strong truncate">{marker.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="font-body text-[12.5px] text-muted pt-1">
|
||||
Two marks selected — that's a 41-second VoD, one click away.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-[22px]">
|
||||
{steps.map((step) => (
|
||||
<Card key={step.number} className="p-6 flex flex-col gap-2.5">
|
||||
<p className="font-data text-[13px] text-accent">{step.number}</p>
|
||||
<h2 className="font-heading text-[22px] leading-snug">{step.title}</h2>
|
||||
<p className="font-body text-[14px] leading-[1.65] text-text-body text-pretty">
|
||||
{step.body}
|
||||
</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-panel rounded-panel p-6 mt-[26px] flex flex-wrap items-baseline justify-between gap-x-8 gap-y-2">
|
||||
<p className="font-body text-[14px] text-text-body">
|
||||
Built for one person and their own machine. No tiers, no transcoding queue you share with strangers.
|
||||
</p>
|
||||
<p className="font-data text-[11px] uppercase tracking-[0.12em] text-faint-mono">
|
||||
rtmp in · mp4 out
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Landing;
|
||||
@@ -1,14 +1,21 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, Lock, Maximize, Minimize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react";
|
||||
import { ArrowLeft, FastForward, Lock, Maximize, Minimize, Pause, Play, Rewind, Volume1, Volume2, VolumeX } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { AuthError } from "../utils/api/client.ts";
|
||||
import Card from "../components/Card.tsx";
|
||||
import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts";
|
||||
import { dateToTimeAgo, formatFileSize, formatTime, stringToDate } from "../utils/utils.ts";
|
||||
import { kickerClass, quietLinkClass } from "../utils/styles.ts";
|
||||
|
||||
// Both VoDs and clips share the same on-screen shape.
|
||||
type MediaItem = {
|
||||
title: string,
|
||||
createdAt: string,
|
||||
width?: number | null,
|
||||
height?: number | null,
|
||||
fps?: number | null,
|
||||
/** Size on disk, in bytes. */
|
||||
fileSize?: number | null,
|
||||
};
|
||||
|
||||
type MediaPlayerProps = {
|
||||
@@ -53,6 +60,13 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [controlsVisible, setControlsVisible] = useState(true);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [hoverTime, setHoverTime] = useState<number | null>(null);
|
||||
/** Transient badge that flashes in the middle of the frame on play/pause/seek. */
|
||||
const [flash, setFlash] = useState<{ id: number; kind: "play" | "pause" | "back" | "forward" } | null>(null);
|
||||
|
||||
const showFlash = useCallback((kind: "play" | "pause" | "back" | "forward") => {
|
||||
setFlash({ id: Date.now(), kind });
|
||||
}, []);
|
||||
|
||||
// ── Data loading ────────────────────────────────────────────────────────
|
||||
// The video stream itself is loaded by the <video> element from `mediaUrl`,
|
||||
@@ -119,11 +133,13 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
video.currentTime = Math.max(0, video.currentTime - 5);
|
||||
showFlash('back');
|
||||
showControls();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
video.currentTime = Math.min(video.duration, video.currentTime + 5);
|
||||
showFlash('forward');
|
||||
showControls();
|
||||
break;
|
||||
case 'KeyM':
|
||||
@@ -137,7 +153,15 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [showControls, toggleFullscreen]);
|
||||
}, [showControls, showFlash, toggleFullscreen]);
|
||||
|
||||
// Each flash clears itself; keying the node on `id` restarts the animation
|
||||
// when the same action fires twice in a row.
|
||||
useEffect(() => {
|
||||
if (!flash) return;
|
||||
const timer = setTimeout(() => setFlash(null), 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [flash]);
|
||||
|
||||
// ── Progress bar seeking ────────────────────────────────────────────────
|
||||
const seekTo = useCallback((clientX: number) => {
|
||||
@@ -191,29 +215,40 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
||||
const togglePlay = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
showFlash(video.paused ? 'play' : 'pause');
|
||||
video.paused ? video.play() : video.pause();
|
||||
showControls();
|
||||
};
|
||||
|
||||
const progressPct = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
const hoverPct = hoverTime !== null && duration > 0 ? (hoverTime / duration) * 100 : 0;
|
||||
const effectiveVolume = isMuted ? 0 : volume;
|
||||
const VolumeIcon = effectiveVolume === 0 ? VolumeX : effectiveVolume < 0.5 ? Volume1 : Volume2;
|
||||
|
||||
// Facts worth showing under the player, skipping anything the API didn't send.
|
||||
const facts = [
|
||||
item?.width && item?.height ? `${item.width}×${item.height}` : null,
|
||||
item?.fps ? `${Math.round(item.fps)} fps` : null,
|
||||
duration > 0 ? formatTime(duration) : null,
|
||||
item?.fileSize ? formatFileSize(item.fileSize) : null,
|
||||
].filter(Boolean);
|
||||
|
||||
const flashIcons = { play: Play, pause: Pause, back: Rewind, forward: FastForward } as const;
|
||||
const FlashIcon = flash ? flashIcons[flash.kind] : null;
|
||||
|
||||
if (notAuthenticated) {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-4 text-center p-8">
|
||||
<div className="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<Lock size={24} className="text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-gray-900">Not authenticated</p>
|
||||
<p className="text-sm text-gray-500 mt-1">You don't have access to this {noun.toLowerCase()}.</p>
|
||||
</div>
|
||||
<div className="h-full flex flex-col items-center justify-center gap-3 text-center p-8">
|
||||
<Lock className="w-8 h-8 text-faint-mono/60 mb-1" strokeWidth={1.5} />
|
||||
<h2 className="font-heading text-[27px] text-faint-mono">Not yours to watch</h2>
|
||||
<p className="font-body text-sm text-faint-mono/70 max-w-xs text-pretty">
|
||||
You don't have access to this {noun.toLowerCase()}.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
|
||||
className={clsx(quietLinkClass, "flex items-center gap-1.5 pt-1")}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<ArrowLeft size={15} />
|
||||
Go back
|
||||
</button>
|
||||
</div>
|
||||
@@ -221,19 +256,19 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col p-4 gap-3">
|
||||
<div className="h-full flex flex-col px-13 py-8 gap-4">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 flex-shrink-0 transition-colors duration-150"
|
||||
className={clsx(quietLinkClass, "flex items-center gap-1.5 flex-shrink-0")}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<ArrowLeft size={15} />
|
||||
Back
|
||||
</button>
|
||||
|
||||
{/* Player */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative bg-black rounded-xl overflow-hidden flex-1 min-h-0 select-none"
|
||||
className="relative bg-player rounded-thumb shadow-player overflow-hidden flex-1 min-h-0 select-none"
|
||||
onMouseMove={showControls}
|
||||
onMouseLeave={() => isPlaying && setControlsVisible(false)}
|
||||
onClick={togglePlay}
|
||||
@@ -269,11 +304,34 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action flash — a badge that swells and fades on play, pause and seek. */}
|
||||
{flash && FlashIcon && (
|
||||
<div key={flash.id} className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="animate-flash-badge flex h-[74px] w-[74px] items-center justify-center rounded-full bg-player/55 backdrop-blur-[2px] text-on-dark">
|
||||
<FlashIcon size={30} fill="currentColor" strokeWidth={1} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls overlay — pointer-events-none so video clicks pass through */}
|
||||
<div className={`absolute inset-0 flex flex-col justify-end transition-opacity duration-300 pointer-events-none ${controlsVisible ? 'opacity-100' : 'opacity-0'}`}>
|
||||
{/* Gradient */}
|
||||
{/* Gradients — top one carries the title, bottom one the controls */}
|
||||
<div className="absolute inset-x-0 top-0 h-28 bg-gradient-to-b from-black/55 to-transparent" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/10 to-transparent" />
|
||||
|
||||
{/* Title plate */}
|
||||
<div className={clsx(
|
||||
"absolute inset-x-0 top-0 px-5 pt-4 flex flex-col gap-0.5 transition-transform duration-300",
|
||||
controlsVisible ? "translate-y-0" : "-translate-y-2"
|
||||
)}>
|
||||
<p className="font-data text-[10.5px] uppercase tracking-[0.14em] text-on-dark/55">
|
||||
{noun}
|
||||
</p>
|
||||
<p className="font-heading text-[22px] leading-tight text-on-dark truncate">
|
||||
{item?.title || "(No title)"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Controls bar — pointer-events-auto only here */}
|
||||
<div
|
||||
className="relative px-4 pb-4 pt-10 pointer-events-auto"
|
||||
@@ -284,13 +342,35 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
||||
ref={progressRef}
|
||||
className="relative h-1 rounded-full bg-white/25 cursor-pointer mb-3 group/prog hover:h-1.5 transition-[height] duration-150"
|
||||
onMouseDown={(e) => { setIsDragging(true); seekTo(e.clientX); }}
|
||||
onMouseMove={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
setHoverTime(ratio * duration);
|
||||
}}
|
||||
onMouseLeave={() => setHoverTime(null)}
|
||||
>
|
||||
<div className="absolute inset-y-0 left-0 bg-white/35 rounded-full" style={{ width: `${bufferedPct}%` }} />
|
||||
<div className="absolute inset-y-0 left-0 bg-primary rounded-full" style={{ width: `${progressPct}%` }} />
|
||||
|
||||
{/* Ghost fill up to the cursor, so the scrub reads before you commit */}
|
||||
{hoverTime !== null && (
|
||||
<div className="absolute inset-y-0 left-0 bg-white/25 rounded-full" style={{ width: `${hoverPct}%` }} />
|
||||
)}
|
||||
|
||||
<div className="absolute inset-y-0 left-0 bg-accent-light rounded-full" style={{ width: `${progressPct}%` }} />
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow opacity-0 group-hover/prog:opacity-100 transition-opacity"
|
||||
style={{ left: `calc(${progressPct}% - 6px)` }}
|
||||
/>
|
||||
|
||||
{/* Time under the cursor */}
|
||||
{hoverTime !== null && (
|
||||
<div
|
||||
className="absolute bottom-4 -translate-x-1/2 rounded-[6px] bg-player/85 px-1.5 py-0.5 font-data text-[11px] text-on-dark tabular-nums pointer-events-none"
|
||||
style={{ left: `${hoverPct}%` }}
|
||||
>
|
||||
{formatTime(hoverTime)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Buttons row */}
|
||||
@@ -317,7 +397,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="text-xs font-mono tabular-nums text-white/80">
|
||||
<span className="font-data text-xs tabular-nums text-on-dark/80">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
|
||||
@@ -331,17 +411,25 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-red-500 text-sm mt-3">{error}</div>}
|
||||
{error && <p className="font-body text-[13px] text-live-red">{error}</p>}
|
||||
{!mediaLoaded && !error && (
|
||||
<div className="text-gray-400 text-sm mt-3 flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full border-2 border-gray-300 border-t-gray-500 animate-spin" />
|
||||
Loading video...
|
||||
</div>
|
||||
<p className="font-data text-[12px] text-faint-mono">loading video…</p>
|
||||
)}
|
||||
|
||||
<Card className="p-4 flex-shrink-0">
|
||||
<p className="text-xl font-semibold text-gray-900">{item?.title || "(No Title)"}</p>
|
||||
<p className="text-sm text-gray-500 mt-1">{timeAgo}</p>
|
||||
<Card className="p-5 flex-shrink-0 flex flex-wrap items-end justify-between gap-x-8 gap-y-3">
|
||||
<div className="min-w-0">
|
||||
<p className={kickerClass}>{noun}</p>
|
||||
<p className="font-heading text-[25px] tracking-[-0.015em] leading-tight pt-1">
|
||||
{item?.title || "(No title)"}
|
||||
</p>
|
||||
<p className="font-body text-[13px] text-muted pt-1">{timeAgo}</p>
|
||||
</div>
|
||||
|
||||
{facts.length > 0 && (
|
||||
<p className="font-data text-[12px] text-faint-mono tabular-nums">
|
||||
{facts.join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import VideoCard from "../components/video/VideoCard";
|
||||
import PageHeader from "../components/PageHeader";
|
||||
import NoAccessPage from "../components/NoAccessPage";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { getClips, deleteClip } from "../utils/api/clips";
|
||||
import { useAuth } from "../utils/AuthContext";
|
||||
import type { Clip } from "../utils/types";
|
||||
|
||||
const MyClips = () => {
|
||||
const { user, loading } = useAuth();
|
||||
const [clips, setClips] = useState<Clip[]>([]);
|
||||
const [error, setError] = useState<null | string>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
getClips()
|
||||
.then((data) => setClips(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())))
|
||||
.catch((err) => setError(err));
|
||||
}, []);
|
||||
.catch((err) => setError(err instanceof Error ? err.message : "Failed to load clips"));
|
||||
}, [user]);
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
@@ -24,11 +29,16 @@ const MyClips = () => {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mb-6">My Clips</h1>
|
||||
if (loading) return null;
|
||||
if (!user) return <NoAccessPage />;
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
return (
|
||||
<div className="mx-auto max-w-[1180px] px-13 pt-10 pb-18">
|
||||
<PageHeader kicker="Library" title="Clips">
|
||||
<p className="font-data text-[13px] text-faint-mono pt-6">{clips.length} cut</p>
|
||||
</PageHeader>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-[18px]">
|
||||
{clips.map((clip) => (
|
||||
<VideoCard
|
||||
key={clip.id}
|
||||
@@ -36,6 +46,10 @@ const MyClips = () => {
|
||||
title={clip.title}
|
||||
duration={clip.duration}
|
||||
createdAt={clip.createdAt}
|
||||
width={clip.width}
|
||||
height={clip.height}
|
||||
fps={clip.fps}
|
||||
fileSize={clip.fileSize}
|
||||
onEdit={() => navigate(`/clips/${clip.id}/edit`)}
|
||||
onDelete={() => handleDelete(clip.id)}
|
||||
/>
|
||||
@@ -43,13 +57,17 @@ const MyClips = () => {
|
||||
</div>
|
||||
|
||||
{clips.length === 0 && !error && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||
<p className="text-base">No clips yet.</p>
|
||||
<p className="text-sm mt-1">Upload your first clip to get started.</p>
|
||||
<div className="flex flex-col items-center justify-center gap-1.5 py-20 text-center">
|
||||
<h2 className="font-heading text-[25px]">No clips yet.</h2>
|
||||
<p className="font-body text-[14.5px] text-muted max-w-[42ch] text-pretty">
|
||||
Cut one from the stream panel, or{" "}
|
||||
<Link to="/create" className="text-accent hover:text-accent-hover">upload a file</Link>{" "}
|
||||
to trim it down here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-red-500 mt-4">{error.toString()}</p>}
|
||||
{error && <p className="font-body text-[13px] text-live-red mt-6">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import VideoCard from "../components/video/VideoCard";
|
||||
import PageHeader from "../components/PageHeader";
|
||||
import NoAccessPage from "../components/NoAccessPage";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { getVods, deleteVod } from "../utils/api/vods";
|
||||
import { useAuth } from "../utils/AuthContext";
|
||||
import type { Vod } from "../utils/types";
|
||||
|
||||
const MyVods = () => {
|
||||
const { user, loading } = useAuth();
|
||||
const [vods, setVods] = useState<Vod[]>([]);
|
||||
const [error, setError] = useState<null | string>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
getVods()
|
||||
.then((data) => setVods(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : "Failed to load vods"));
|
||||
}, []);
|
||||
}, [user]);
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
@@ -24,11 +29,16 @@ const MyVods = () => {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mb-6">My VoDs</h1>
|
||||
if (loading) return null;
|
||||
if (!user) return <NoAccessPage />;
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
return (
|
||||
<div className="mx-auto max-w-[1180px] px-13 pt-10 pb-18">
|
||||
<PageHeader kicker="Library" title="VoDs">
|
||||
<p className="font-data text-[13px] text-faint-mono pt-6">{vods.length} saved</p>
|
||||
</PageHeader>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-[22px]">
|
||||
{vods.map((vod) => (
|
||||
<VideoCard
|
||||
key={vod.id}
|
||||
@@ -36,6 +46,10 @@ const MyVods = () => {
|
||||
title={vod.title}
|
||||
duration={vod.duration}
|
||||
createdAt={vod.createdAt}
|
||||
width={vod.width}
|
||||
height={vod.height}
|
||||
fps={vod.fps}
|
||||
fileSize={vod.fileSize}
|
||||
mediaApiPath="/api/v1/vods"
|
||||
playerPath="/vod"
|
||||
itemLabel="vod"
|
||||
@@ -46,13 +60,15 @@ const MyVods = () => {
|
||||
</div>
|
||||
|
||||
{vods.length === 0 && !error && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||
<p className="text-base">No VoDs yet.</p>
|
||||
<p className="text-sm mt-1">Your stream recordings will appear here.</p>
|
||||
<div className="flex flex-col items-center justify-center gap-1.5 py-20 text-center">
|
||||
<h2 className="font-heading text-[25px]">Nothing saved yet.</h2>
|
||||
<p className="font-body text-[14.5px] text-muted max-w-[42ch] text-pretty">
|
||||
Ranges you save from the stream panel land here, full length and untouched.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-red-500 mt-4">{error}</p>}
|
||||
{error && <p className="font-body text-[13px] text-live-red mt-6">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,81 +1,65 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Eye, EyeOff, Copy, Check } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import Card from "../components/Card.tsx";
|
||||
import { getUser } from "../utils/api/users.ts";
|
||||
import type { User } from "../utils/types.ts";
|
||||
import PageHeader from "../components/PageHeader.tsx";
|
||||
import NoAccessPage from "../components/NoAccessPage.tsx";
|
||||
import { useAuth } from "../utils/AuthContext.tsx";
|
||||
import { formatLocalDate } from "../utils/utils.ts";
|
||||
import { labelClass, chipButtonClass } from "../utils/styles.ts";
|
||||
|
||||
const Profile = () => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const { user, loading } = useAuth();
|
||||
const [keyVisible, setKeyVisible] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getUser().then(setUser).catch(console.error);
|
||||
}, []);
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!user?.streamKey) return;
|
||||
navigator.clipboard.writeText(user.streamKey);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
setTimeout(() => setCopied(false), 1600);
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-full text-gray-400 text-base">
|
||||
Please log in to view your profile.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (loading) return null;
|
||||
if (!user) return <NoAccessPage />;
|
||||
|
||||
return (
|
||||
<div className="px-8 py-10 max-w-2xl mx-auto">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Profile</h1>
|
||||
<div className="mx-auto max-w-[720px] px-13 pt-10 pb-18">
|
||||
<PageHeader kicker="Account" title="Profile" />
|
||||
|
||||
<Card className="p-6 mb-4 flex items-center gap-5">
|
||||
<Card className="p-7 mb-[26px] flex items-center gap-5">
|
||||
<img
|
||||
src={user.profilePictureUrl}
|
||||
referrerPolicy="no-referrer"
|
||||
alt="Profile picture"
|
||||
className="w-20 h-20 rounded-full shadow-sm"
|
||||
alt=""
|
||||
className="w-[72px] h-[72px] rounded-full shadow-card"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-gray-900">{user.name}</p>
|
||||
<p className="text-sm text-gray-500">@{user.username}</p>
|
||||
<p className="text-sm text-gray-500">{user.email}</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Member since {formatLocalDate(user.createdAt)}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<p className="font-heading text-[25px] leading-tight">{user.name}</p>
|
||||
<p className="font-body text-[13px] text-text-secondary">@{user.username} · {user.email}</p>
|
||||
<p className="font-data text-[11px] text-faint-mono pt-1">
|
||||
member since {formatLocalDate(user.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<h2 className="text-sm font-semibold text-gray-800 mb-1">Stream Key</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Use this key in OBS or any RTMP-compatible software to start streaming.
|
||||
Keep it private — anyone with this key can stream to your account.
|
||||
<Card className="p-7">
|
||||
<h2 className="font-heading text-[22px] pb-1.5">Stream key</h2>
|
||||
<p className="font-body text-[14.5px] leading-[1.65] text-text-body max-w-[62ch] text-pretty pb-5">
|
||||
Point OBS — or anything else that speaks RTMP — at the ingest URL with this key.
|
||||
Keep it private: anyone holding it can stream to your account.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-gray-50 border border-gray-200 rounded-md px-4 py-2 font-mono text-sm text-gray-700 overflow-hidden">
|
||||
{keyVisible ? user.streamKey : "•".repeat(user.streamKey.length)}
|
||||
<p className={labelClass}>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
|
||||
onClick={() => setKeyVisible(v => !v)}
|
||||
title={keyVisible ? "Hide key" : "Show key"}
|
||||
className="p-2 rounded-md bg-gray-100 hover:bg-gray-200 transition-colors duration-150 text-gray-600"
|
||||
>
|
||||
{keyVisible ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
<button className={chipButtonClass} onClick={() => setKeyVisible(visible => !visible)}>
|
||||
{keyVisible ? "Hide" : "Reveal"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
title="Copy key"
|
||||
className="p-2 rounded-md bg-gray-100 hover:bg-gray-200 transition-colors duration-150 text-gray-600"
|
||||
>
|
||||
{copied ? <Check size={16} className="text-green-600" /> : <Copy size={16} />}
|
||||
<button className={chipButtonClass} onClick={handleCopy}>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -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