restyle the rest of the pages
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped

This commit is contained in:
2026-08-02 16:32:00 +01:00
parent 11809a7da8
commit eab3f8be20
46 changed files with 2022 additions and 857 deletions

View File

@@ -18,3 +18,4 @@ FRONTEND_URL=
BACKEND_URL=
STREAM_RING_LENGTH_SECOND=28800
STREAM_INGEST_URL=

View File

@@ -15,7 +15,6 @@
"lucide-react": "^0.511.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-range-slider-input": "^3.2.1",
"react-router-dom": "^7.8.0"
},
"devDependencies": {
@@ -2974,17 +2973,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/core-js": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
@@ -4365,29 +4353,6 @@
"react": "^19.2.8"
}
},
"node_modules/react-range-slider-input": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/react-range-slider-input/-/react-range-slider-input-3.3.5.tgz",
"integrity": "sha512-HkGjaq+q7u42K5WzFPD67duugPyD2m7EWVCJm139EqfR9AgSdEUxSFgRj6SW+nzGB5y8/6Jw75euxbeiuTT0cQ==",
"license": "MIT",
"dependencies": {
"clsx": "^1.1.1",
"core-js": "^3.22.4"
},
"peerDependencies": {
"react": ">=17 || >=18",
"react-dom": ">=17 || >=18"
}
},
"node_modules/react-range-slider-input/node_modules/clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",

View File

@@ -18,7 +18,6 @@
"lucide-react": "^0.511.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-range-slider-input": "^3.2.1",
"react-router-dom": "^7.8.0"
},
"devDependencies": {

View File

@@ -6,7 +6,7 @@ type Props = {
};
const Card = ({ children, className }: Props) => (
<div className={clsx("bg-card shadow-md rounded-lg", className)}>
<div className={clsx("bg-card shadow-card rounded-panel", className)}>
{children}
</div>
);

View File

@@ -9,7 +9,7 @@ type DropdownItemProps = {
const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => (
<li
className={clsx("cursor-pointer hover:bg-gray-50 px-3 py-2 text-sm text-gray-700", className)}
className={clsx("cursor-pointer hover:bg-fields px-3 py-2 font-body text-[13px] text-text-control transition-colors duration-150", className)}
onClick={() => onClick(item)}
>
{item}
@@ -40,12 +40,12 @@ const Dropdown = ({ label, children, className }: DropdownProps) => {
<div className={clsx(className, "relative inline-block text-left")} ref={ref}>
<button
onClick={() => setIsOpen(v => !v)}
className="inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 transition-colors duration-150"
className="inline-flex items-center gap-1 rounded-btn px-3 py-2 font-body text-[13.5px] text-text-control hover:bg-fields transition-colors duration-150"
>
{label}
</button>
{isOpen && (
<ul className="absolute right-0 mt-1 w-36 origin-top-right rounded-lg bg-white border border-gray-200 shadow-md z-50 py-1 overflow-hidden">
<ul className="absolute right-0 mt-1 w-36 origin-top-right rounded-chip bg-card inset-shadow-hairline shadow-card z-50 py-1 overflow-hidden">
{children}
</ul>
)}

View File

@@ -0,0 +1,38 @@
import { type CredentialResponse, GoogleLogin } from "@react-oauth/google";
import { useAuth } from "../utils/AuthContext.tsx";
import { getUser, login } from "../utils/api/users.ts";
type Props = {
/** Google's own sizing knobs — the sidebar wants a small pill, the landing page a wide one. */
shape?: "rectangular" | "pill";
size?: "small" | "medium" | "large";
width?: string;
};
/** Google's button plus the exchange for our own session. Used by the sidebar and the landing page. */
const GoogleSignIn = ({ shape = "pill", size = "medium", width }: Props) => {
const { setUser } = useAuth();
const handleLogin = (response: CredentialResponse) => {
if (!response.credential) {
console.error("No credential received from Google login.");
return;
}
login(response.credential)
.then(() => getUser().then(setUser))
.catch((error) => console.error("Login failed:", error));
};
return (
<GoogleLogin
shape={shape}
size={size}
width={width}
useOneTap={false}
onSuccess={handleLogin}
/>
);
};
export default GoogleSignIn;

View File

@@ -2,7 +2,7 @@ const IsLivePulse = () => {
return (
<span className={"relative flex h-2 w-2"}>
<span
className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 bg-live-red"
className="animate-live-pulse absolute inline-flex h-full w-full rounded-full bg-live-red"
></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-live-red"
></span>

View File

@@ -0,0 +1,57 @@
import clsx from "clsx";
import type { JobResponse } from "../utils/types.ts";
type TrackedJob = {
uuid: string;
label: string;
progress: number;
state: JobResponse['state'];
errorOutput: string | null;
};
const isSettled = (job: TrackedJob) => job.state === 'SUCCEEDED' || job.state === 'FAILED';
// queued → reading source → encoding → writing file → complete
const stageOf = (job: TrackedJob): string => {
if (job.state === 'FAILED') return "failed";
if (job.state === 'SUCCEEDED') return "complete";
if (job.state === 'READY') return "queued";
if (job.progress < 0.05) return "reading source";
if (job.progress < 0.95) return "encoding";
return "writing file";
};
const JobProgress = ({ job, className }: { job: TrackedJob; className?: string }) => {
const failed = job.state === 'FAILED';
const done = job.state === 'SUCCEEDED';
const pct = done ? 100 : Math.round(Math.min(1, Math.max(0, job.progress)) * 100);
return (
<div className={clsx("flex flex-col gap-1.5", className)}>
<p className="font-heading text-[15px] leading-snug">{job.label}</p>
<div className="h-[3px] w-full rounded-full bg-chip overflow-hidden">
<div
className={clsx(
"h-full rounded-full transition-[width] duration-500 ease-out",
failed ? "bg-live-red" : done ? "bg-olive-light" : "bg-accent-light"
)}
style={{ width: `${failed ? 100 : pct}%` }}
/>
</div>
<div className="flex items-baseline justify-between gap-3">
<p className={clsx("font-data text-[11px]", failed ? "text-live-red" : "text-muted")}>
{stageOf(job)}
</p>
<p className="font-data text-[11px] text-faint-mono">
{failed ? (job.errorOutput ?? "unknown error") : done ? "done" : `${pct}%`}
</p>
</div>
</div>
);
};
export default JobProgress;
export { isSettled };
export type { TrackedJob };

View File

@@ -0,0 +1,24 @@
import type React from "react";
import clsx from "clsx";
import { kickerClass } from "../utils/styles.ts";
type Props = {
/** Uppercase mono eyebrow, e.g. "INGEST" or "LIBRARY". */
kicker: string;
title: string;
/** Optional trailing action, right-aligned against the title. */
children?: React.ReactNode;
className?: string;
};
const PageHeader = ({ kicker, title, children, className }: Props) => (
<header className={clsx("flex flex-row items-start justify-between gap-6 pb-8", className)}>
<div className="flex flex-col gap-1">
<p className={kickerClass}>{kicker}</p>
<h1 className="font-heading text-4xl tracking-[-0.015em] leading-tight">{title}</h1>
</div>
{children}
</header>
);
export default PageHeader;

View File

@@ -5,11 +5,11 @@ type Props = {
label: string;
};
// Label on the left, control on the right — the shape used for export settings.
const Selector = ({ children, label }: Props) => (
<div className="flex items-center gap-2">
<label className="w-full text-sm text-gray-600">{label}</label>
<div className="w-px h-5 bg-gray-200 mx-2" />
{children}
<div className="flex items-center justify-between gap-4">
<label className="font-body text-[13px] text-text-body">{label}</label>
<div className="w-32 flex-shrink-0">{children}</div>
</div>
);

View File

@@ -2,8 +2,8 @@ import clsx from "clsx";
import SidebarButton from "./buttons/SidebarButton.tsx";
import { Plus, Film, Home, Video, Radio } from 'lucide-react';
import {useAuth} from "../utils/AuthContext.tsx";
import {type CredentialResponse, GoogleLogin} from "@react-oauth/google";
import {getUser, login, logout} from "../utils/api/users.ts";
import GoogleSignIn from "./GoogleSignIn.tsx";
import {logout} from "../utils/api/users.ts";
type props = {
className?: string
@@ -14,21 +14,6 @@ const Divider = () => <hr className="w-full my-1.5 mx-2 border-hover" />;
const Sidebar = ({ className }: props) => {
const { user, setUser } = useAuth()
const handleLogin = (response: CredentialResponse) => {
if (!response.credential) {
console.error("No credential received from Google login.");
return;
}
login(response.credential)
.then(() => {
getUser().then(userData => setUser(userData))
})
.catch((error) => {
console.error("Login failed:", error);
});
}
const handleLogout = () => {
logout()
.then(() =>
@@ -96,13 +81,7 @@ const Sidebar = ({ className }: props) => {
) : (
<div>
<Divider />
<GoogleLogin
shape={"pill"}
useOneTap={false}
onSuccess={
(credentialResponse) => handleLogin(credentialResponse
)}
/>
<GoogleSignIn />
</div>
)}
</div>

View File

@@ -1,14 +0,0 @@
import React from "react";
type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;
const BlueButton: React.FC<Props> = ({ className = "", ...props }) => {
return (
<button
className={`bg-primary text-white font-medium rounded-lg hover:bg-primary-pressed transition-colors duration-150 h-10 px-4 ${className}`}
{...props}
/>
);
};
export default BlueButton;

View File

@@ -0,0 +1,32 @@
import React from "react";
import clsx from "clsx";
type Variant = "primary" | "olive" | "dark" | "secondary" | "ghost";
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: Variant;
};
// Hover shifts one step darker within the same family — no transforms.
const variants: Record<Variant, string> = {
primary: "bg-accent text-on-accent shadow-terracotta hover:bg-accent-hover disabled:bg-accent-disabled disabled:shadow-none",
olive: "bg-olive text-on-accent shadow-olive hover:bg-olive-hover disabled:bg-inactive-strong disabled:shadow-none",
dark: "bg-player text-on-dark hover:bg-player-stripe disabled:bg-inactive-strong",
secondary: "bg-panel text-text-control hover:bg-chip-hover",
ghost: "text-muted hover:text-text-strong",
};
const Button = ({ variant = "primary", className, ...props }: Props) => (
<button
className={clsx(
"font-body text-[13.5px] rounded-btn px-4 py-2.5 transition-colors duration-150",
"disabled:cursor-not-allowed",
variants[variant],
className
)}
{...props}
/>
);
export default Button;
export type { Variant };

View File

@@ -1,14 +0,0 @@
import React from "react";
type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;
const DarkButton: React.FC<Props> = ({ className = "", ...props }) => {
return (
<button
className={`bg-accent text-on-dark font-body rounded-lg hover:bg-accent-hover transition-colors duration-150 h-10 px-4 ${className}`}
{...props}
/>
);
};
export default DarkButton;

View File

@@ -1,42 +0,0 @@
import RangeSlider from 'react-range-slider-input';
import 'react-range-slider-input/dist/style.css';
import { useRef, type Dispatch, type SetStateAction } from "react";
import type { VideoMetadata } from "../../utils/types.ts";
type Props = {
videoRef: HTMLVideoElement | null;
videoMetadata: VideoMetadata;
setSliderValue: Dispatch<SetStateAction<number>>;
setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
className?: string;
};
export default function ClipRangeSlider({ videoRef, videoMetadata, setSliderValue, setMetadata, className }: Props) {
const previousRange = useRef<[number, number]>([0, 0]);
const handleRangeInput = (val: [number, number]) => {
if (!videoRef) return;
if (previousRange.current[0] !== val[0]) {
videoRef.currentTime = val[0];
setSliderValue(val[0]);
} else if (previousRange.current[1] !== val[1]) {
videoRef.currentTime = val[1];
setSliderValue(val[1]);
}
setMetadata(prev => ({ ...prev, startPoint: val[0], duration: val[1] - val[0] }));
previousRange.current = val;
};
return (
<RangeSlider
min={0}
max={videoMetadata.duration}
step={0.1}
onInput={handleRangeInput}
className={className}
id="range-slider"
/>
);
}

View File

@@ -2,15 +2,49 @@ import { type Dispatch, type SetStateAction } from "react";
import type { VideoMetadata } from "../../utils/types.ts";
import Selector from "../Selector.tsx";
import clsx from "clsx";
import { inputClass, monoInputClass, kickerClass } from "../../utils/styles.ts";
type Props = {
metadata: VideoMetadata;
setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
className?: string;
};
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";
// Mirrors CompressionService.buildBitrate so the number shown is the number used:
// 90% of the raw budget, audio capped at 128 kbit/s, video takes the rest.
const BUDGET_MULTIPLIER = 0.9;
const AUDIO_RATIO = 0.15;
const MAX_AUDIO_BITRATE = 128;
export default function ConfigBox({ setMetadata, className }: Props) {
type Estimate = { total: number; audio: number; video: number; bpp: number | null };
const estimateBitrate = (metadata: VideoMetadata): Estimate | null => {
const { fileSize, duration, width, height, fps } = metadata;
if (!fileSize || !duration || duration <= 0) return null;
const total = ((fileSize * 8) / duration) * BUDGET_MULTIPLIER;
const audio = Math.min(total * AUDIO_RATIO, MAX_AUDIO_BITRATE);
const video = total - audio;
// Bits per pixel per frame — the usual way to judge whether a bitrate suits a resolution.
const bpp = width && height && fps ? (video * 1000) / (width * height * fps) : null;
return { total, audio, video, bpp };
};
const formatBitrate = (kbps: number): string =>
kbps >= 1000 ? `${(kbps / 1000).toFixed(1)} Mbit/s` : `${Math.round(kbps)} kbit/s`;
const verdictOf = (estimate: Estimate): { word: string; tone: string } => {
if (estimate.video <= 0) return { word: "not enough room", tone: "text-live-red" };
if (estimate.bpp === null) return { word: "—", tone: "text-muted" };
if (estimate.bpp >= 0.15) return { word: "crisp", tone: "text-olive" };
if (estimate.bpp >= 0.09) return { word: "clean", tone: "text-olive" };
if (estimate.bpp >= 0.05) return { word: "soft", tone: "text-muted" };
return { word: "very soft", tone: "text-live-red" };
};
export default function ConfigBox({ metadata, setMetadata, className }: Props) {
const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => {
const [width, height] = e.target.value.split(",").map(Number);
setMetadata(prev => ({ ...prev, width, height }));
@@ -21,15 +55,19 @@ export default function ConfigBox({ setMetadata, className }: Props) {
};
const updateFileSize = (e: React.ChangeEvent<HTMLInputElement>) => {
setMetadata(prev => ({ ...prev, fileSize: parseInt(e.target.value) * 1000 }));
setMetadata(prev => ({ ...prev, fileSize: Math.max(1, parseInt(e.target.value) || 1) * 1000 }));
};
const sizeMb = Math.round(metadata.fileSize / 1000);
const estimate = estimateBitrate(metadata);
const verdict = estimate && verdictOf(estimate);
return (
<div className={clsx("flex flex-col gap-3 p-6", className)}>
<h2 className="text-sm font-semibold text-gray-800 mb-1">Export Settings</h2>
<div className={clsx("flex flex-col gap-4", className)}>
<p className={kickerClass}>Export settings</p>
<Selector label="Resolution">
<select id="resolution" name="resolution" defaultValue="1280,720"
<select id="resolution" name="resolution" value={`${metadata.width},${metadata.height}`}
onChange={updateRes} className={inputClass}>
<option value="3840,2160">2160p</option>
<option value="2560,1440">1440p</option>
@@ -40,8 +78,8 @@ export default function ConfigBox({ setMetadata, className }: Props) {
</select>
</Selector>
<Selector label="FPS">
<select id="fps" name="fps" defaultValue="30"
<Selector label="Frames per second">
<select id="fps" name="fps" value={String(metadata.fps)}
onChange={updateFps} className={inputClass}>
<option value="60">60</option>
<option value="30">30</option>
@@ -49,11 +87,35 @@ export default function ConfigBox({ setMetadata, className }: Props) {
</select>
</Selector>
<Selector label="File Size Limit (MB)">
<input type="number" min="1" defaultValue="10"
onChange={updateFileSize} className={inputClass}
<Selector label="Size limit (MB)">
<input type="number" min="1" value={sizeMb}
onChange={updateFileSize} className={monoInputClass}
/>
</Selector>
{/* What that budget actually buys, at this length and resolution. */}
<div className="flex flex-col gap-2 bg-fields rounded-field p-3.5">
<div className="flex items-baseline justify-between gap-3">
<p className={kickerClass}>Estimated bitrate</p>
{verdict && <p className={clsx("font-body text-[12px]", verdict.tone)}>{verdict.word}</p>}
</div>
{estimate ? (
<>
<p className="font-data text-[15px] text-text-primary tabular-nums">
{formatBitrate(Math.max(0, estimate.video))}
</p>
<p className="font-body text-[12px] text-muted">
video, plus {formatBitrate(estimate.audio)} audio — {sizeMb} MB
across {metadata.duration.toFixed(1)}s
</p>
</>
) : (
<p className="font-body text-[12px] text-muted">
Pick a trim range to see what the size limit buys you.
</p>
)}
</div>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import BlueButton from "../buttons/BlueButton.tsx";
import React from "react";
import Button from "../buttons/Button.tsx";
import { kickerClass } from "../../utils/styles.ts";
type props = {
dataSend: React.MouseEventHandler<HTMLButtonElement>;
@@ -9,43 +10,44 @@ type props = {
uploading: boolean;
}
const Spinner = () => (
<svg
className="animate-spin h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
);
// queued → reading source → encoding → writing file → complete
const stageOf = (progress: number, uploading: boolean, downloadable: boolean): string => {
if (downloadable) return "complete";
if (!uploading) return "waiting";
if (progress < 0.05) return "reading source";
if (progress < 0.95) return "encoding";
return "writing file";
};
const ExportWidget = ({ dataSend, handleDownload, downloadable, progress, uploading }: props) => {
return (
<div className={"flex flex-col gap-3"}>
<BlueButton
onClick={dataSend}
disabled={uploading}
className={uploading ? "opacity-60 cursor-not-allowed flex items-center justify-center gap-2" : ""}
>
{uploading && <Spinner />}
{uploading ? "Uploading..." : "Export"}
</BlueButton>
const pct = downloadable ? 100 : Math.round(Math.min(1, Math.max(0, progress)) * 100);
<div className="h-10 flex items-center">
{ downloadable ?
(<BlueButton className="w-full" onClick={handleDownload}>
Download
</BlueButton>)
:(
<progress
value={progress}
className="w-full h-1.5 rounded bg-gray-200">
</progress>
)
}
return (
<div className="flex flex-col gap-4">
<p className={kickerClass}>Export</p>
<Button onClick={dataSend} disabled={uploading} className="w-full py-3">
{uploading ? "Compressing…" : "Compress and save"}
</Button>
<div className="flex flex-col gap-1.5">
<div className="h-[3px] w-full rounded-full bg-chip overflow-hidden">
<div
className={`h-full rounded-full transition-[width] duration-500 ease-out ${downloadable ? "bg-olive-light" : "bg-accent-light"}`}
style={{ width: `${pct}%` }}
/>
</div>
<div className="flex items-baseline justify-between gap-3">
<p className="font-data text-[11px] text-muted">{stageOf(progress, uploading, downloadable)}</p>
<p className="font-data text-[11px] text-faint-mono">{downloadable ? "done" : `${pct}%`}</p>
</div>
</div>
{downloadable && (
<Button variant="olive" className="w-full py-3" onClick={handleDownload}>
Download the file
</Button>
)}
</div>
)
}

View File

@@ -1,6 +1,7 @@
import clsx from "clsx";
import { type Dispatch, type SetStateAction } from "react";
import type { VideoMetadata } from "../../utils/types.ts";
import { inputClass, labelClass } from "../../utils/styles.ts";
type Props = {
setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
@@ -8,13 +9,14 @@ type Props = {
};
const MetadataBox = ({ setMetadata, className }: Props) => (
<div className={clsx("flex flex-col content-between p-6 gap-2", className)}>
<p className="w-full font-semibold text-gray-800 text-sm">Title</p>
<div className={clsx("flex flex-col gap-2", className)}>
<label className={labelClass} htmlFor="clip-title">Title</label>
<input
id="clip-title"
type="text"
placeholder="Enter title"
placeholder="Cat walks across the patch bay"
onChange={(e) => setMetadata(prev => ({ ...prev, title: e.target.value }))}
className="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"
className={inputClass}
/>
</div>
);

View File

@@ -1,37 +0,0 @@
import { useEffect, type Dispatch, type SetStateAction } from "react";
import type { VideoMetadata } from "../../utils/types.ts";
type Props = {
videoRef: HTMLVideoElement | null;
videoMetadata: VideoMetadata;
sliderValue: number;
setSliderValue: Dispatch<SetStateAction<number>>;
className?: string;
};
export default function PlaybackSlider({ videoRef, videoMetadata, sliderValue, setSliderValue, className }: Props) {
const updateVideo = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!videoRef) return;
videoRef.currentTime = e.target.valueAsNumber;
setSliderValue(e.target.valueAsNumber);
};
useEffect(() => {
if (!videoRef) return;
const updateSlider = () => setSliderValue(videoRef.currentTime);
videoRef.addEventListener("timeupdate", updateSlider);
return () => videoRef.removeEventListener("timeupdate", updateSlider);
}, [videoRef]);
return (
<input
type="range"
min={0}
max={videoMetadata.duration}
value={sliderValue}
onChange={updateVideo}
step={0.1}
className={className}
/>
);
}

View File

@@ -48,25 +48,25 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
}, [video]);
return (
<div className={clsx("flex justify-between items-center mb-2", className)}>
<div className="flex gap-1">
<Icon size={24} />
<div className={clsx("flex justify-between items-center mb-3", className)}>
<div className="flex items-center gap-2 text-text-control">
<Icon size={20} />
<input
type="range"
min={0}
max={100}
value={volume}
onChange={updateVolume}
className="w-20"
className="w-20 accent-accent"
/>
</div>
<button
onClick={togglePlay}
className="hover:bg-gray-100 rounded-full p-1.5 transition-colors duration-150 text-gray-700"
className="rounded-full p-2 text-text-primary hover:bg-fields transition-colors duration-150"
>
{isPlaying ? <Pause size={24} /> : <Play size={24} />}
{isPlaying ? <Pause size={22} /> : <Play size={22} />}
</button>
<span>
<span className="font-data text-[13px] text-text-secondary tabular-nums">
{formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)}
</span>
</div>

View File

@@ -0,0 +1,283 @@
import { useCallback, useEffect, useRef, useState } from "react";
import clsx from "clsx";
import { formatTime } from "../../utils/utils.ts";
type Range = [number, number];
type Props = {
/** Full length of the source video, in seconds. */
duration: number;
/** The trim window, in seconds. */
range: Range;
onRangeChange: (range: Range) => void;
/** Playhead position, in seconds. */
currentTime: number;
onSeek: (seconds: number) => void;
className?: string;
};
/** Never zoom in past this, or the handles have nothing left to grab. */
const MIN_VIEW_SECONDS = 0.5;
/** The shortest trim the handles will allow between them. */
const MIN_GAP = 0.1;
const ZOOM_STEP = 1.6;
const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));
/** A readable tick interval that yields roughly 6 labels across the view. */
const tickInterval = (span: number): number => {
const steps = [0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600];
return steps.find((step) => span / step <= 6) ?? 3600;
};
const TrimTimeline = ({ duration, range, onRangeChange, currentTime, onSeek, className }: Props) => {
const [view, setView] = useState<Range>([0, duration]);
// Which thing the pointer is currently dragging, if any.
const [dragging, setDragging] = useState<"in" | "out" | "playhead" | "window" | null>(null);
const trackRef = useRef<HTMLDivElement>(null);
const overviewRef = useRef<HTMLDivElement>(null);
const dragOffset = useRef(0);
// A new source resets the view to the whole thing.
useEffect(() => setView([0, duration]), [duration]);
const [viewStart, viewEnd] = view;
const viewSpan = Math.max(viewEnd - viewStart, MIN_VIEW_SECONDS);
const zoomFactor = duration / viewSpan;
/** Seconds → percentage across the zoomed track. */
const toPct = (seconds: number) => ((seconds - viewStart) / viewSpan) * 100;
/** Pointer x → seconds, within the zoomed track. */
const toSeconds = (clientX: number, element: HTMLElement | null, span: Range): number => {
const rect = element?.getBoundingClientRect();
if (!rect || rect.width === 0) return span[0];
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1);
return span[0] + ratio * (span[1] - span[0]);
};
/** Re-centre the view on `focus`, keeping it inside the source. */
const applyZoom = useCallback((nextSpan: number, focus: number) => {
const span = clamp(nextSpan, MIN_VIEW_SECONDS, duration);
const ratio = duration === 0 ? 0 : clamp((focus - view[0]) / (view[1] - view[0] || 1), 0, 1);
let start = focus - span * ratio;
start = clamp(start, 0, Math.max(0, duration - span));
setView([start, start + span]);
}, [duration, view]);
const zoomBy = (factor: number) => {
const focus = clamp(currentTime, viewStart, viewEnd);
applyZoom(viewSpan / factor, focus);
};
/** Zoom the view down to the trim range, with a little air on each side. */
const zoomToRange = () => {
const span = Math.max(range[1] - range[0], MIN_VIEW_SECONDS);
const padded = Math.min(duration, span * 1.25);
const centre = (range[0] + range[1]) / 2;
const start = clamp(centre - padded / 2, 0, Math.max(0, duration - padded));
setView([start, start + padded]);
};
// Wheel over the track zooms around the pointer rather than scrolling the page.
useEffect(() => {
const track = trackRef.current;
if (!track) return;
const onWheel = (event: WheelEvent) => {
if (event.deltaY === 0) return;
event.preventDefault();
const focus = toSeconds(event.clientX, track, view);
applyZoom(event.deltaY > 0 ? viewSpan * ZOOM_STEP : viewSpan / ZOOM_STEP, focus);
};
track.addEventListener("wheel", onWheel, { passive: false });
return () => track.removeEventListener("wheel", onWheel);
}, [applyZoom, view, viewSpan]);
// The move handler reads through refs, so it always sees the current view and
// range without having to be torn down and rebuilt on every frame of a drag.
const latest = useRef({ view, range, onRangeChange, onSeek, duration });
latest.current = { view, range, onRangeChange, onSeek, duration };
// Listeners are attached on mousedown rather than in an effect: a fast drag
// would otherwise lose its first frames waiting for a re-render.
const beginDrag = (kind: "in" | "out" | "playhead" | "window", event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
setDragging(kind);
const onMove = (move: MouseEvent) => {
const state = latest.current;
const span = state.view[1] - state.view[0];
if (kind === "window") {
const at = toSeconds(move.clientX, overviewRef.current, [0, state.duration]);
const start = clamp(at - dragOffset.current, 0, Math.max(0, state.duration - span));
setView([start, start + span]);
return;
}
const at = toSeconds(move.clientX, trackRef.current, state.view);
if (kind === "playhead") {
state.onSeek(at);
} else if (kind === "in") {
state.onRangeChange([Math.min(at, state.range[1] - MIN_GAP), state.range[1]]);
state.onSeek(at);
} else {
state.onRangeChange([state.range[0], Math.max(at, state.range[0] + MIN_GAP)]);
state.onSeek(at);
}
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
setDragging(null);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
};
if (duration <= 0) return null;
const interval = tickInterval(viewSpan);
const firstTick = Math.ceil(viewStart / interval) * interval;
const ticks: number[] = [];
for (let at = firstTick; at <= viewEnd; at += interval) ticks.push(at);
const windowLeft = (viewStart / duration) * 100;
const windowWidth = (viewSpan / duration) * 100;
return (
<div className={clsx("flex flex-col gap-3 select-none", className)}>
{/* Overview — the whole source, with the zoom window drawn on top. */}
<div
ref={overviewRef}
onMouseDown={(event) => {
const at = toSeconds(event.clientX, overviewRef.current, [0, duration]);
// Grabbing inside the window drags it; clicking outside jumps it here.
dragOffset.current = at >= viewStart && at <= viewEnd ? at - viewStart : viewSpan / 2;
const start = clamp(at - dragOffset.current, 0, Math.max(0, duration - viewSpan));
setView([start, start + viewSpan]);
beginDrag("window", event);
}}
className="relative h-8 rounded-chip bg-fields cursor-grab active:cursor-grabbing overflow-hidden"
>
<div
className="absolute inset-y-0 bg-olive-light/30"
style={{ left: `${(range[0] / duration) * 100}%`, width: `${((range[1] - range[0]) / duration) * 100}%` }}
/>
<div
className="absolute inset-y-0 w-px bg-accent"
style={{ left: `${(currentTime / duration) * 100}%` }}
/>
<div
className="absolute inset-y-0 rounded-[4px] shadow-[inset_0_0_0_1.5px_#8E8577]"
style={{ left: `${windowLeft}%`, width: `${windowWidth}%` }}
/>
</div>
{/* Zoom controls */}
<div className="flex items-center gap-2">
<button
onClick={() => zoomBy(1 / ZOOM_STEP)}
disabled={viewSpan >= duration}
title="Zoom out"
className="bg-chip hover:bg-chip-hover disabled:opacity-40 disabled:cursor-not-allowed text-text-control rounded-chip w-7 h-7 font-data text-[13px] transition-colors duration-150"
>
−
</button>
<button
onClick={() => zoomBy(ZOOM_STEP)}
disabled={viewSpan <= MIN_VIEW_SECONDS}
title="Zoom in"
className="bg-chip hover:bg-chip-hover disabled:opacity-40 disabled:cursor-not-allowed text-text-control rounded-chip w-7 h-7 font-data text-[13px] transition-colors duration-150"
>
+
</button>
<span className="font-data text-[11px] text-faint-mono tabular-nums">
{zoomFactor < 1.02 ? "whole clip" : `${zoomFactor.toFixed(1)}×`}
{" · "}
{formatTime(viewStart)} – {formatTime(viewEnd)}
</span>
<div className="flex-1" />
<button
onClick={zoomToRange}
className="font-body text-[12px] text-muted hover:text-text-strong whitespace-nowrap transition-colors duration-150"
>
Fit to trim
</button>
<button
onClick={() => setView([0, duration])}
disabled={viewSpan >= duration}
className="font-body text-[12px] text-muted hover:text-text-strong disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap transition-colors duration-150"
>
Whole clip
</button>
</div>
{/* Zoomed track */}
<div
ref={trackRef}
onMouseDown={(event) => {
onSeek(toSeconds(event.clientX, trackRef.current, view));
beginDrag("playhead", event);
}}
className="relative h-14 rounded-chip bg-fields cursor-pointer"
>
{ticks.map((at) => (
<div key={at} className="absolute inset-y-0 flex flex-col justify-end" style={{ left: `${toPct(at)}%` }}>
<div className="absolute inset-y-0 w-px bg-inactive/70" />
<span className="relative pl-1 pb-1 font-data text-[10px] text-faint-mono whitespace-nowrap">
{formatTime(at)}
</span>
</div>
))}
{/* Trim band */}
<div
className="absolute inset-y-0 bg-olive-light/35"
style={{
left: `${clamp(toPct(range[0]), 0, 100)}%`,
width: `${clamp(toPct(range[1]), 0, 100) - clamp(toPct(range[0]), 0, 100)}%`,
}}
/>
{/* Playhead */}
<div
className="absolute inset-y-0 w-[2px] bg-accent pointer-events-none"
style={{ left: `${clamp(toPct(currentTime), 0, 100)}%` }}
/>
{/* In / out handles — only grabbable while inside the view */}
{([["in", range[0]], ["out", range[1]]] as const).map(([edge, at]) => {
const pct = toPct(at);
if (pct < -2 || pct > 102) return null;
return (
<div
key={edge}
onMouseDown={(event) => beginDrag(edge, event)}
title={`${edge === "in" ? "In" : "Out"} — ${formatTime(at)}`}
className={clsx(
"absolute top-0 bottom-0 w-3 -ml-1.5 flex items-center justify-center cursor-ew-resize",
dragging === edge && "z-10"
)}
style={{ left: `${pct}%` }}
>
<div className="h-full w-[3px] bg-olive rounded-full" />
<div className="absolute h-4 w-4 rounded-full bg-olive shadow-olive" />
</div>
);
})}
</div>
</div>
);
};
export default TrimTimeline;
export type { Range };

View File

@@ -1,5 +1,5 @@
import clsx from "clsx";
import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
import { formatTime, formatFileSize, formatLocalDate, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
import { Link } from "react-router-dom";
import React, { useEffect, useRef, useState } from "react";
import { isThumbnailAvailable } from "../../utils/api/client.ts";
@@ -10,6 +10,11 @@ type VideoCardProps = {
title: string,
duration: number,
createdAt: string,
width?: number | null,
height?: number | null,
fps?: number | null,
/** Size on disk, in bytes. */
fileSize?: number | null,
onEdit?: () => void,
onDelete?: () => void,
className?: string,
@@ -18,23 +23,36 @@ type VideoCardProps = {
itemLabel?: string,
}
const fallbackThumbnail = "../../../public/default_thumbnail.png";
const API_URL = import.meta.env.VITE_API_URL;
const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className, mediaApiPath = '/api/v1/clips', playerPath = '/video', itemLabel = 'clip' }: VideoCardProps) => {
const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt)));
// Stand-in for a missing thumbnail: warm 135° stripes rather than a broken image.
const placeholders: Record<string, React.CSSProperties> = {
vod: { backgroundImage: "repeating-linear-gradient(135deg, #E3DACB 0 9px, #DAD0BF 9px 18px)" },
clip: { backgroundImage: "repeating-linear-gradient(135deg, #DFE0D2 0 9px, #D6D8C7 9px 18px)" },
};
const VideoCard = ({ id, title, duration, createdAt, width, height, fps, fileSize, onEdit, onDelete, className, mediaApiPath = '/api/v1/clips', playerPath = '/video', itemLabel = 'clip' }: VideoCardProps) => {
const [thumbnailAvailable, setThumbnailAvailable] = useState(true);
const [menuOpen, setMenuOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const thumbnailUrl = `${API_URL}${mediaApiPath}/${id}/thumbnail`;
useEffect(() => {
const interval = setInterval(() => {
setTimeAgo(dateToTimeAgo(stringToDate(createdAt)));
}, 1000);
return () => clearInterval(interval);
}, [createdAt]);
// VoDs read as 16/9 tiles with a serif title; clips are smaller 4/3 tiles.
const isVod = itemLabel === 'vod';
const slug = `${itemLabel}_${String(id).padStart(4, '0')}`;
// "27 Jul · 3.4 GB" — the two facts you actually scan a library by.
const meta = [
formatLocalDate(createdAt, { day: 'numeric', month: 'short' }),
fileSize ? formatFileSize(fileSize) : null,
].filter(Boolean).join(" · ");
// The technical detail, in mono, for when it matters.
const facts = [
width && height ? (isVod ? `${width}×${height}` : `${height}p`) : null,
isVod && fps ? `${Math.round(fps)} fps` : null,
].filter(Boolean);
useEffect(() => {
isThumbnailAvailable(thumbnailUrl)
@@ -62,15 +80,28 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
return (
<Link to={`${playerPath}/${id}`}>
<div className={clsx("flex flex-col group cursor-pointer", className)}>
<div className="relative overflow-hidden rounded-lg hover:shadow-xl transition-transform duration-300">
<div className={clsx(
"group flex flex-col bg-card rounded-tile overflow-hidden shadow-card hover:shadow-card-hover transition-shadow duration-200",
className
)}>
<div
className={clsx("relative", isVod ? "aspect-video" : "aspect-4/3")}
style={thumbnailAvailable ? undefined : placeholders[isVod ? 'vod' : 'clip']}
>
{thumbnailAvailable && (
<img
src={thumbnailAvailable ? thumbnailUrl : fallbackThumbnail}
alt="Video Thumbnail"
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200"
src={thumbnailUrl}
alt=""
className="w-full h-full object-cover"
onError={() => setThumbnailAvailable(false)}
/>
)}
<span className="absolute top-1.5 left-1.5 bg-black/70 text-white px-1.5 py-0.5 rounded text-xs font-medium pointer-events-none">
<span className="absolute top-2.5 left-2.5 bg-card/85 text-text-secondary px-2 py-1 rounded-md font-data text-[11px] pointer-events-none">
{slug}
</span>
<span className="absolute bottom-2.5 right-2.5 bg-player/85 text-on-dark px-2 py-1 rounded-md font-data text-[11px] pointer-events-none">
{formatTime(duration)}
</span>
@@ -82,18 +113,18 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
>
<button
onClick={(e) => stopAndRun(e, () => { setMenuOpen(v => !v); setConfirmDelete(false); })}
className="bg-black/70 hover:bg-black/90 text-white rounded p-0.5 transition-colors"
className="bg-player/40 hover:bg-player/85 text-on-dark rounded-md p-1 transition-colors duration-150"
>
<MoreVertical size={14} />
</button>
{menuOpen && (
<div className="absolute right-0 top-7 w-32 bg-white rounded-lg border border-gray-200 shadow-md z-50 py-1 overflow-hidden">
<div className="absolute right-0 top-8 w-36 bg-card rounded-chip inset-shadow-hairline shadow-card z-50 py-1 overflow-hidden">
{!confirmDelete && (<>
{onEdit && (
<button
onClick={(e) => stopAndRun(e, onEdit)}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
className="flex items-center gap-2 w-full px-3 py-2 font-body text-[13px] text-text-control hover:bg-fields transition-colors duration-150"
>
<Pencil size={13} /> Edit
</button>
@@ -101,7 +132,7 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
{onDelete && (
<button
onClick={(e) => stopAndRun(e, () => setConfirmDelete(true))}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-red-500 hover:bg-red-50"
className="flex items-center gap-2 w-full px-3 py-2 font-body text-[13px] text-live-red hover:bg-armed transition-colors duration-150"
>
<Trash2 size={13} /> Delete
</button>
@@ -109,17 +140,17 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
</>)}
{confirmDelete && (
<div className="px-3 py-2">
<p className="text-xs text-gray-600 mb-2">Delete this {itemLabel}?</p>
<p className="font-body text-[12px] text-text-body mb-2">Delete this {itemLabel}?</p>
<div className="flex gap-1.5">
<button
onClick={(e) => stopAndRun(e, onDelete!)}
className="flex-1 text-xs bg-red-500 text-white rounded px-2 py-1 hover:bg-red-600 transition-colors"
className="flex-1 font-body text-[12px] bg-live-red text-on-accent rounded-md px-2 py-1 hover:bg-accent-hover transition-colors duration-150"
>
Delete
</button>
<button
onClick={(e) => stopAndRun(e, () => setConfirmDelete(false))}
className="flex-1 text-xs bg-gray-100 text-gray-600 rounded px-2 py-1 hover:bg-gray-200 transition-colors"
className="flex-1 font-body text-[12px] bg-chip text-text-control rounded-md px-2 py-1 hover:bg-chip-hover transition-colors duration-150"
>
Cancel
</button>
@@ -132,11 +163,28 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
)}
</div>
<div className="flex flex-col p-2">
<p className="font-medium text-gray-900 text-sm leading-snug line-clamp-2">
{title === "" ? "(No Title)" : title}
<div className={clsx("flex flex-col gap-1", isVod ? "px-4 pt-3.5 pb-4" : "px-3 pt-3 pb-3.5")}>
<p className={clsx(
"line-clamp-2",
isVod
? "font-heading text-[17.5px] leading-[1.3]"
: "font-body text-[13.5px] text-text-strong leading-snug"
)}>
{title === "" ? "(No title)" : title}
</p>
<p className="text-gray-500 text-xs mt-0.5">{timeAgo}</p>
<p
className="font-body text-[12.5px] text-muted"
title={`${dateToTimeAgo(stringToDate(createdAt))} — ${formatLocalDate(createdAt)}`}
>
{meta}
</p>
{facts.length > 0 && (
<p className="font-data text-[10.5px] text-faint-mono truncate">
{facts.join(" · ")}
</p>
)}
</div>
</div>
</Link>

View File

@@ -2,33 +2,93 @@
@plugin "flowbite-react/plugin/tailwindcss";
@theme {
/* Fonts */
/*--font-heading: "Newsreader", serif; */
--font-body: "IBM Plex Sans", sans-serif;
/* ── Fonts ───────────────────────────────────────────────────────────── */
--font-body: "IBM Plex Sans", system-ui, sans-serif;
--font-data: "IBM Plex Mono", monospace;
/* Colours */
/* ── Surfaces ────────────────────────────────────────────────────────── */
--color-background: #F7F4EE;
--color-sidebar: #F2EEE6;
--color-card: #FDFBF7;
--color-accent: #A85A38;
--color-accent-hover: #94492A;
--color-live-card: #F0EBE1;
--color-fields: #F4F0E8;
--color-panel: #F1ECE3;
--color-dropzone: #F9F6EF;
--color-armed: #F6EFE6;
--color-live-card: #F0EBE1;
--color-player: #2B2620;
--color-player-stripe: #3A342C;
/* Colour Fonts */
/* ── Ink ─────────────────────────────────────────────────────────────── */
--color-text-primary: #2B2620;
--color-text-strong: #35302A;
--color-faint-mono: #A79C8E;
--color-text-body: #554D44;
--color-text-control: #4A423A;
--color-text-secondary: #6E6559;
--color-text-table: #7A7166;
--color-muted: #9A9084;
--color-hover: #E7E0D2;
--color-hairline: #F0EBE2;
--color-live-red: #B5432E;
--color-faint-mono: #A79C8E;
--color-on-dark: #FBF8F2;
--color-on-accent: #FDFBF7;
/* ── Accents ─────────────────────────────────────────────────────────── */
--color-accent: #A85A38;
--color-accent-hover: #94492A;
--color-accent-light: #C87A52;
--color-accent-disabled: #C9AE9C;
--color-olive: #6E7A50;
--color-olive-hover: #5D6843;
--color-olive-light: #8A9668;
--color-live-red: #B5432E;
/* ── Lines, chips, placeholders ──────────────────────────────────────── */
--color-hairline: #F0EBE2;
--color-hover: #E7E0D2;
--color-chip: #EAE3D7;
--color-chip-hover: #DAD1C0;
--color-ring-dashed: #E2DACB;
--color-inactive: #DED7CA;
--color-inactive-strong: #C4BAA9;
/* ── Elevation — soft shadows instead of borders ─────────────────────── */
--shadow-card: 0 1px 2px rgba(60, 48, 32, .05), 0 10px 26px -18px rgba(60, 48, 32, .35);
--shadow-card-hover: 0 2px 4px rgba(60, 48, 32, .07), 0 18px 34px -18px rgba(60, 48, 32, .45);
--shadow-panel: 0 1px 2px rgba(60, 48, 32, .05), 0 16px 36px -22px rgba(60, 48, 32, .3);
--shadow-player: 0 1px 2px rgba(60, 48, 32, .08), 0 22px 46px -26px rgba(60, 48, 32, .5);
--shadow-terracotta: 0 1px 2px rgba(90, 40, 20, .2), 0 12px 22px -14px rgba(90, 40, 20, .55);
--shadow-olive: 0 1px 2px rgba(50, 60, 30, .18), 0 10px 20px -14px rgba(50, 60, 30, .55);
--inset-shadow-hairline: inset 0 0 0 1px rgba(60, 48, 32, .08);
--inset-shadow-armed: inset 0 0 0 1.5px #A85A38;
--inset-shadow-focus: inset 0 0 0 1.5px #C87A52;
--inset-shadow-dashed: inset 0 0 0 1.5px #E2DACB;
/* ── Shape ───────────────────────────────────────────────────────────── */
--radius-hero: 16px;
--radius-panel: 15px;
--radius-tile: 13px;
--radius-thumb: 12px;
--radius-field: 10px;
--radius-btn: 9px;
--radius-chip: 8px;
/* ── Motion — the live dot, plus the player's one-shot action badge ──── */
--animate-live-pulse: livePulse 2.2s ease-in-out infinite;
--animate-flash-badge: flashBadge 500ms ease-out forwards;
@keyframes livePulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: .45; transform: scale(1.35); }
}
@keyframes flashBadge {
0% { opacity: 0; transform: scale(.75); }
25% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(1.4); }
}
}
:root {
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-family: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
@@ -39,7 +99,8 @@
body {
background-color: var(--color-background);
font-family: "IBM Plex Sans", sans-serif;
font-family: "IBM Plex Sans", system-ui, sans-serif;
color: var(--color-text-body);
}
@utility font-heading {

View File

@@ -7,10 +7,10 @@ const MainLayout = () => {
const [sidebarToggled] = useState(false);
return (
<div className={`transition-all duration-300 grid h-screen ${sidebarToggled ? "grid-cols-[0px_1fr]" : "grid-cols-[280px_1fr]"} grid-rows-[auto_1fr]`}>
<div className={`transition-all duration-300 grid h-screen ${sidebarToggled ? "grid-cols-[0px_1fr]" : "grid-cols-[252px_1fr]"} grid-rows-[auto_1fr]`}>
<Sidebar
className={`row-span-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${sidebarToggled ? "-translate-x-full" : "translate-x-0"}`}/>
<div>
<div className="row-span-2 overflow-y-auto">
<Outlet />
</div>
</div>

View File

@@ -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,52 +149,68 @@ const ClipEdit = () => {
};
return (
<div className={"grid grid-cols-[7fr_3fr] gap-4 p-4"}>
<div className="mx-auto max-w-[1180px] px-13 pt-10 pb-18">
<PageHeader kicker="Upload" title="Trim and compress" />
<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 rounded-lg shadow-sm bg-black"}
className="w-full aspect-video bg-player object-contain"
onLoadedMetadata={handleVideoMetadataLoaded}
/>
<Card className={"w-4/5 h-full m-auto"}>
<MetadataBox setMetadata={setOutputMetadata} />
<ConfigBox setMetadata={setOutputMetadata} />
</Card>
{metadata &&
<Card className={"mt-4 p-5"}>
<Playbar
video={videoRef.current}
videoMetadata={metadata}
className={"w-full accent-primary"}
className="w-full px-5 pt-3.5"
/>
}
</Card>
{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>
<TrimTimeline
duration={metadata.duration}
range={range}
onRangeChange={handleRangeChange}
currentTime={playbackValue}
onSeek={handleSeek}
/>
<PlaybackSlider
videoRef={videoRef.current}
videoMetadata={metadata}
sliderValue={playbackValue}
setSliderValue={setPlaybackValue}
className={"w-full accent-primary"}
/>
<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>
<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"}>
<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}
@@ -177,7 +218,9 @@ const ClipEdit = () => {
progress={progress}
uploading={uploading}
/>
</Card>
</div>
</aside>
</div>
</div>
);
};

View File

@@ -1,40 +1,89 @@
import {useState} from "react";
import { useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import BlueButton from "../components/buttons/BlueButton.tsx";
import Card from "../components/Card.tsx";
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" />
<input
type="file"
onChange={(e) => {
const selected = e.target.files?.[0] ?? null;
setFile(selected);
setError(null);
<label
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
choose(e.dataTransfer.files?.[0] ?? 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"
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"
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>
)
};

View File

@@ -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>
)}

View File

@@ -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>
)}

View File

@@ -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,18 +189,27 @@ const Home = () => {
</Link>
</div>
{streamStatus?.isStreaming &&
<Card className={"bg-live-card p-8 mb-8"}>
{/* 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"}>
<IsLivePulse />
<p className={"text-xs font-data text-live-red"}>LIVE NOW</p>
{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>
{isStreaming ? (
<>
<h2 className={"font-heading text-3xl"}>
Session {String(streamStatus.id).padStart(5, '0')} {' '}
recording since {getTimeFromISOString(streamStatus.startDate)}.</h2>
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"}>
@@ -186,22 +223,55 @@ const Home = () => {
</div>
</div>
<div className={"mt-4"}>
<DarkButton>+ Drop Marker</DarkButton>
<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>
</>
) : (
<>
<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={"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>
</Card>
}
<div className={"flex flex-col"}>
<h2 className={"font-heading text-2xl"}>Recent VoDs</h2>
<h2 className={"font-data text-xl"}>{counts.clips}</h2>
<p className={"text-xs text-muted"}>clips cut</p>
</div>
</div>
<div className={"grid grid-cols-3 gap-4"}>
<div className={"mt-4"}>
<Link to={"/stream"}>
<Button variant={"secondary"}>Open the stream panel</Button>
</Link>
</div>
</>
)}
</Card>
<div className={"flex flex-col"}>
<SectionHeader title={"Recent VoDs"} to={"/my-vods"} count={counts.vods} />
<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"

View 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;

View File

@@ -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>
);

View File

@@ -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>
);
}

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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 copy = (key: string, text: string) => {
navigator.clipboard.writeText(text);
setCopied(key);
setTimeout(() => setCopied((current) => (current === key ? null : current)), 1600);
};
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 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);
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>
{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>
<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>
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;
}
{/* 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>
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);
}
};
{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>
const clearSelection = () => {
setSelected([]);
setInValue("");
setOutValue("");
setRangeName("");
setRangeError(null);
};
const handleClip = async () => {
setClipError(null);
try {
const uuid = await clipSection(clipDuration, clipName || undefined);
addJob(uuid, clipName || `Cutting the last ${clipDuration}s`);
setClipName("");
} catch (err) {
setClipError(err instanceof Error ? err.message : "Failed to cut the clip");
}
};
// ── Timeline ────────────────────────────────────────────────────────────
const gridlines = useMemo(() => {
if (elapsedSeconds < GRID_INTERVAL_SECONDS) return [];
return Array.from(
{ length: Math.floor(elapsedSeconds / GRID_INTERVAL_SECONDS) },
(_, index) => ((index + 1) * GRID_INTERVAL_SECONDS) / elapsedSeconds * 100
);
}, [elapsedSeconds]);
const runningJobs = jobs.filter((job) => !isSettled(job)).length;
if (loading) return null;
if (!user) return <NoAccessPage />;
return (
<div className="mx-auto max-w-[1180px] px-13 pt-10 pb-18">
<PageHeader kicker="Ingest" title="Stream panel" />
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_340px] gap-[26px] items-start">
{/* ── Main column ───────────────────────────────────────── */}
<div className="flex flex-col gap-[26px]">
<Card className="p-7">
<div className="flex items-baseline justify-between gap-4 pb-5">
<h2 className="font-heading text-[22px]">Server credentials</h2>
{isStreaming ? (
<div className="flex items-center gap-2">
<IsLivePulse />
<p className="font-data text-[11px] uppercase tracking-[0.12em] text-live-red">Live now</p>
</div>
) : (
<Radio size={28} className="text-gray-200" />
<p className="font-data text-[11px] uppercase tracking-[0.12em] text-faint-mono">Off air</p>
)}
</Card>
</div>
{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>
</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);
return (
<div key={m.id} className="group relative flex-shrink-0">
<button
onClick={() => handleChipClick(m.id)}
className={clsx(chipButtonClass, !ingestUrl && "opacity-40 cursor-not-allowed")}
disabled={!ingestUrl}
onClick={() => ingestUrl && copy("url", ingestUrl)}
>
{copied === "url" ? "Copied" : "Copy"}
</button>
</div>
<p className={clsx(labelClass, "pt-5 block")}>Stream key</p>
<div className="flex items-center gap-2.5 pt-2">
<div className="flex-1 bg-fields rounded-field px-3 py-2.5 font-data text-sm text-text-strong truncate">
{keyVisible ? user.streamKey : "•".repeat(Math.min(user.streamKey.length, 28))}
</div>
<button className={chipButtonClass} onClick={() => setKeyVisible((visible) => !visible)}>
{keyVisible ? "Hide" : "Reveal"}
</button>
<button className={chipButtonClass} onClick={() => copy("key", user.streamKey)}>
{copied === "key" ? "Copied" : "Copy"}
</button>
</div>
<p className="pt-3 font-body text-[13px] text-muted">
Keep this private — anyone holding the key can stream to your account.
</p>
</Card>
{isStreaming ? (
<Card className="p-7">
<div className="flex items-baseline justify-between gap-4 pb-5">
<h2 className="font-heading text-[22px]">Live markers</h2>
<p className="font-data text-[13px] text-faint-mono tabular-nums">t = {elapsed}</p>
</div>
<div className="flex items-center gap-3">
<input
ref={markerInputRef}
type="text"
value={markerMessage}
onChange={(event) => setMarkerMessage(event.target.value)}
onKeyDown={(event) => event.key === "Enter" && handleAddMarker()}
placeholder="What just happened? (optional)"
className={clsx(inputClass, "flex-1")}
/>
<Button
variant="dark"
onClick={handleAddMarker}
disabled={markerBusy}
className="whitespace-nowrap px-5 py-3"
>
+ Drop marker at {elapsed}
</Button>
</div>
<p className="pt-2.5 font-body text-[13px] text-muted">
or press{" "}
<kbd className="bg-chip rounded-[5px] px-1.5 py-0.5 font-data text-[11px] text-text-control">M</kbd>
{" "}to jump here
</p>
{markerError && <p className="pt-2 font-body text-[13px] text-live-red">{markerError}</p>}
{/* Timeline overview */}
<div className="relative mt-5 h-[74px] rounded-chip bg-fields overflow-hidden">
{gridlines.map((left, index) => (
<span
key={index}
className="absolute inset-y-0 w-px bg-inactive/60"
style={{ left: `${left}%` }}
/>
))}
{markers.map((marker) => {
const left = elapsedSeconds > 0
? Math.min(100, Math.max(0, (offsetOf(marker) / elapsedSeconds) * 100))
: 0;
return (
<span
key={marker.id}
title={marker.message}
className={clsx(
"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"
"absolute inset-y-0 w-[2px]",
selected.includes(marker.id) ? "bg-accent" : "bg-olive-light"
)}
style={{ left: `${left}%` }}
/>
);
})}
<span className="absolute bottom-2 left-3 font-data text-[10px] text-faint-mono">00:00:00</span>
<span className="absolute bottom-2 right-3 font-data text-[10px] text-faint-mono tabular-nums">{elapsed}</span>
</div>
{/* Marker list */}
<div className="flex flex-col gap-2 pt-5">
{markers.length === 0 && (
<p className="font-body text-[13px] text-muted py-2">
No markers yet. Drop one the moment something happens — you can cut around it later.
</p>
)}
{markers.map((marker) => {
const isSelected = selected.includes(marker.id);
const offset = offsetOf(marker);
return (
<div key={marker.id} className="group flex items-center gap-1">
<button
onClick={() => toggleMarker(marker.id)}
className={clsx(
"flex flex-1 items-center gap-4 rounded-tile px-4 py-3 text-left transition-colors duration-150 min-w-0",
isSelected
? "bg-armed inset-shadow-armed"
: "bg-card inset-shadow-hairline hover:bg-fields"
)}
>
<span className="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>
) : (
<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>
{/* 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" />
{/* ── 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}>Duration (seconds)</label>
<label className={labelClass} htmlFor="range-in">In</label>
<input
type="number"
min={1}
value={clipDuration}
onChange={(e) => setClipDuration(Number(e.target.value))}
className={inputClass}
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={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-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>
<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>
<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>
<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>
<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>
)}
</Card>
</>
)}
{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>
);
};

View File

@@ -18,6 +18,18 @@ const getCurrentStream = async (): Promise<StreamStatus> => {
return result.data;
};
// The RTMP endpoint to point OBS at. Comes from the server so it stays right
// across environments rather than being baked into the bundle.
const getIngestUrl = async (): Promise<string> => {
const response = await fetch(API_URL + '/api/v1/stream/ingest', { credentials: 'include' });
if (!response.ok) throw new Error(`Failed to fetch ingest URL: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to fetch ingest URL: ${result.message}`);
return result.data.url;
};
const getStreamHistory = async (userId: number): Promise<StreamHistoryItem[]> => {
const response = await fetch(API_URL + `/api/v1/stream/history/${userId}`, { credentials: 'include' });
if (!response.ok) throw new Error(`Failed to fetch stream history: ${response.status}`);
@@ -28,4 +40,4 @@ const getStreamHistory = async (userId: number): Promise<StreamHistoryItem[]> =>
return result.data;
};
export { getStreamStatus, getCurrentStream, getStreamHistory };
export { getStreamStatus, getCurrentStream, getIngestUrl, getStreamHistory };

View File

@@ -0,0 +1,27 @@
// Shared class strings for the recurring bits of chrome. Anything used on more
// than one page lives here so the pages stay readable and stay in step.
/** Field well — no border, a soft terracotta ring on focus. */
const inputClass =
"bg-fields rounded-field w-full px-3 py-2.5 text-sm font-body text-text-strong " +
"placeholder:text-faint-mono focus:outline-none focus:inset-shadow-focus transition-shadow duration-150";
/** Same well, for timecodes and other machine facts. */
const monoInputClass = inputClass.replace("font-body", "font-data");
/** Field label — sits 6–10px above its field. */
const labelClass = "text-[11.5px] font-body text-muted";
/** Kicker / eyebrow above a page title or inside a muted panel. */
const kickerClass = "font-data text-[11px] uppercase tracking-[0.12em] text-muted";
/** Small chip button: copy, reveal, toggles. */
const chipButtonClass =
"bg-chip hover:bg-chip-hover text-text-control font-body text-[13px] rounded-chip " +
"px-3.5 py-2.5 transition-colors duration-150 whitespace-nowrap";
/** Quiet text link, used for back links and cancels. */
const quietLinkClass =
"font-body text-sm text-muted hover:text-text-strong transition-colors duration-150";
export { inputClass, monoInputClass, labelClass, kickerClass, chipButtonClass, quietLinkClass };

View File

@@ -32,6 +32,11 @@ type Clip = {
description: string,
duration: number,
createdAt: string,
width: number | null,
height: number | null,
fps: number | null,
// Bytes, as stored on disk.
fileSize: number | null,
}
type Vod = {
@@ -41,6 +46,11 @@ type Vod = {
description: string,
duration: number,
createdAt: string,
width: number | null,
height: number | null,
fps: number | null,
// Bytes, as stored on disk.
fileSize: number | null,
}
type JobResponse = {

View File

@@ -7,6 +7,45 @@ function formatTime(seconds: number): string {
return `${m}:${pad(s)}`;
}
// Sizes in MB/GB, as the design calls for. Input is bytes.
function formatFileSize(bytes: number): string {
const mb = bytes / 1_000_000;
if (mb >= 1000) return `${(mb / 1000).toFixed(1)} GB`;
if (mb >= 10) return `${Math.round(mb)} MB`;
return `${mb.toFixed(1)} MB`;
}
// Always hh:mm:ss, zero-padded — the shape used for stream timecodes.
function formatTimecode(seconds: number): string {
const safe = Math.max(0, Math.floor(seconds));
const pad = (n: number) => n.toString().padStart(2, '0');
return `${pad(Math.floor(safe / 3600))}:${pad(Math.floor((safe % 3600) / 60))}:${pad(safe % 60)}`;
}
// Reads a typed timecode back into seconds. Accepts hh:mm:ss, mm:ss or ss, so a
// half-typed field doesn't reject the user mid-keystroke.
function parseTimecode(value: string): number | null {
const trimmed = value.trim();
if (!/^\d{1,2}(:\d{1,2}){0,2}$/.test(trimmed)) return null;
const parts = trimmed.split(':').map(Number);
if (parts.some(isNaN)) return null;
// Pad on the left so a bare "45" reads as seconds and "5:00" as minutes.
const [h, m, s] = [0, 0, 0].concat(parts).slice(-3);
if (m > 59 || s > 59) return null;
return h * 3600 + m * 60 + s;
}
// "83m ago" as the compact "−83m" used beside markers. Stays in minutes for the
// first few hours — that's the scale you actually think in mid-stream.
function toRelativeOffset(secondsAgo: number): string {
const m = Math.round(Math.max(0, secondsAgo) / 60);
if (m < 180) return `−${m}m`;
return `−${Math.floor(m / 60)}h${(m % 60).toString().padStart(2, '0')}`;
}
// Parses a UTC date string, appending Z if no timezone offset is present so
// the browser always interprets the value as UTC rather than local time.
function stringToDate(dateString: string): Date {
@@ -40,4 +79,4 @@ function dateToTimeAgo(date: Date): string {
return `${s} second${s !== 1 ? 's' : ''} ago`;
}
export { formatTime, stringToDate, dateToTimeAgo, formatLocalDate };
export { formatTime, formatTimecode, formatFileSize, parseTimecode, toRelativeOffset, stringToDate, dateToTimeAgo, formatLocalDate };

View File

@@ -116,7 +116,11 @@ public class ClipController {
clip.getTitle(),
clip.getDescription(),
clip.getDuration(),
clip.getCreatedAt()
clip.getCreatedAt(),
clip.getWidth(),
clip.getHeight(),
clip.getFps(),
clip.getFileSize()
);
}
}

View File

@@ -1,14 +1,17 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.controllers.dto.IngestResponse;
import com.ddf.vodsystem.controllers.dto.StreamResponse;
import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.entities.Stream;
import com.ddf.vodsystem.services.StreamService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.util.List;
import java.util.Optional;
@@ -18,8 +21,18 @@ public class StreamController {
private static final Logger logger = LoggerFactory.getLogger(StreamController.class);
private static final String SUCCESS = "success";
/** Matches the port and application name in nginx/default.conf.template. */
private static final int RTMP_PORT = 1935;
private static final String RTMP_APPLICATION = "live";
private final StreamService streamService;
@Value("${stream.ingest.url:}")
private String configuredIngestUrl;
@Value("${frontend.url:}")
private String frontendUrl;
public StreamController(StreamService streamService) {
this.streamService = streamService;
}
@@ -53,6 +66,49 @@ public class StreamController {
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream history retrieved", history));
}
@GetMapping("/ingest")
public ResponseEntity<APIResponse<IngestResponse>> getIngestEndpoint() {
return ResponseEntity.ok(new APIResponse<>(SUCCESS,
"Ingest endpoint retrieved",
new IngestResponse(resolveIngestUrl())));
}
/**
* The RTMP endpoint to publish to. Set {@code STREAM_INGEST_URL} to state it outright —
* needed when the RTMP port is reached through a different host or a non-default port.
* Otherwise it is derived from the frontend's host, which is where docker-compose
* publishes the nginx-rtmp container.
*/
private String resolveIngestUrl() {
if (configuredIngestUrl != null && !configuredIngestUrl.isBlank()) {
return configuredIngestUrl.trim();
}
String host = hostOf(frontendUrl);
return "rtmp://" + (host == null ? "localhost" : host) + ":" + RTMP_PORT + "/" + RTMP_APPLICATION;
}
private static String hostOf(String url) {
if (url == null || url.isBlank()) {
return null;
}
String trimmed = url.trim();
try {
String host = URI.create(trimmed).getHost();
if (host != null && !host.isBlank()) {
return host;
}
} catch (IllegalArgumentException e) {
logger.warn("Could not parse frontend.url ({})", trimmed);
}
// A bare "example.net" or "example.net:8080" parses as a path, not a host.
String bare = trimmed.split("/", 2)[0].split(":", 2)[0];
return bare.isBlank() ? null : bare;
}
@GetMapping("/current")
public ResponseEntity<APIResponse<StreamResponse>> getCurrentStream() {
Optional<Stream> stream = streamService.getActiveStream();

View File

@@ -93,7 +93,11 @@ public class VodController {
vod.getTitle(),
vod.getDescription(),
vod.getDuration(),
vod.getCreatedAt()
vod.getCreatedAt(),
vod.getWidth(),
vod.getHeight(),
vod.getFps(),
vod.getFileSize()
);
}
}

View File

@@ -8,5 +8,9 @@ public record ClipResponse(
String title,
String description,
Float duration,
Instant createdAt
Instant createdAt,
Integer width,
Integer height,
Float fps,
Long fileSize
) {}

View File

@@ -0,0 +1,4 @@
package com.ddf.vodsystem.controllers.dto;
/** Where OBS (or anything else speaking RTMP) should publish to. */
public record IngestResponse(String url) {}

View File

@@ -8,5 +8,9 @@ public record VodResponse(
String title,
String description,
Float duration,
Instant createdAt
Instant createdAt,
Integer width,
Integer height,
Float fps,
Long fileSize
) {}

View File

@@ -4,6 +4,7 @@ spring.application.name=vodSystem
spring.servlet.multipart.max-file-size=2GB
spring.servlet.multipart.max-request-size=2GB
stream.max-length=${STREAM_RING_LENGTH_SECOND}
stream.ingest.url=${STREAM_INGEST_URL:}
# Database
spring.datasource.url=${POSTGRES_URL}