add more info to media player
All checks were successful
CI / Maven unit tests (push) Successful in 13s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped

This commit is contained in:
2026-07-29 10:24:08 +01:00
parent 761f93e2a7
commit 19e51f7970
4 changed files with 360 additions and 50 deletions

View File

@@ -68,11 +68,25 @@ function App() {
<Route
path="/video/:id"
element={<MediaPlayer noun="Clip" getMediaUrl={getClipMediaUrl} fetchDetails={getClipById} />}
element={
<MediaPlayer
noun="Clip"
getMediaUrl={getClipMediaUrl}
fetchDetails={getClipById}
editPath={(id) => `/clips/${id}/edit`}
/>
}
/>
<Route
path="/vod/:id"
element={<MediaPlayer noun="VoD" getMediaUrl={getVodMediaUrl} fetchDetails={getVodById} />}
element={
<MediaPlayer
noun="VoD"
getMediaUrl={getVodMediaUrl}
fetchDetails={getVodById}
editPath={(id) => `/vods/${id}/edit`}
/>
}
/>
<Route

View File

@@ -323,6 +323,17 @@
.slider-onvideo::-moz-range-thumb { background: #fff; }
}
/* The player keeps a 16:9 box in the page but must fill the screen when
fullscreen, where the browser sizes the element itself. */
.player-shell:fullscreen {
aspect-ratio: auto;
max-height: none;
height: 100%;
width: 100%;
border: none;
border-radius: 0;
}
/* react-range-slider-input — trim range on the clip editor */
#range-slider {
height: 8px;

View File

@@ -99,7 +99,7 @@ const MainLayout = () => {
/>
<main key={pathname} className="flex-1 animate-fade overflow-y-auto">
<Outlet context={{ user, refreshUser: fetchUser }} />
<Outlet />
</main>
</div>
</div>

View File

@@ -1,22 +1,26 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Link, useNavigate, useParams } from "react-router-dom";
import {
ArrowLeft, Lock, Maximize, Minimize, Pause, Play,
RotateCcw, RotateCw, Volume1, Volume2, VolumeX,
ArrowLeft, CalendarDays, Check, Clock, Download, Gauge, Keyboard, Link2,
Lock, Maximize, Minimize, MonitorPlay, Pause, Pencil, PictureInPicture2,
Play, RotateCcw, RotateCw, Volume1, Volume2, VolumeX,
} from "lucide-react";
import clsx from "clsx";
import { AuthError } from "../utils/api/client.ts";
import Card from "../components/ui/Card.tsx";
import Button from "../components/ui/Button.tsx";
import Badge from "../components/ui/Badge.tsx";
import EmptyState from "../components/ui/EmptyState.tsx";
import Spinner from "../components/ui/Spinner.tsx";
import Menu, { MenuItem } from "../components/ui/Menu.tsx";
import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts";
// Both VoDs and clips share the same on-screen shape.
type MediaItem = {
title: string,
description?: string,
duration?: number,
createdAt: string,
};
@@ -28,20 +32,61 @@ type MediaPlayerProps = {
getMediaUrl: (id: string) => string,
/** Fetches the media metadata for the given id. */
fetchDetails: (id: string) => Promise<MediaItem | null>,
/** Where the "Edit details" action goes. */
editPath: (id: string) => string,
};
const ControlButton = ({ label, onClick, children }: { label: string; onClick: () => void; children: React.ReactNode }) => (
const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2];
const SHORTCUTS: [string, string][] = [
["Space / K", "Play or pause"],
["← / →", "Skip 5 seconds"],
["J / L", "Skip 10 seconds"],
["↑ / ↓", "Volume"],
["0 – 9", "Jump to 0–90%"],
["M", "Mute"],
["F", "Fullscreen"],
["P", "Picture in picture"],
];
/** Turns a frame height into the label people actually use for it. */
const qualityLabel = (height: number) => {
if (height >= 2160) return "4K";
if (height >= 1440) return "1440p";
if (height >= 1080) return "1080p";
if (height >= 720) return "720p";
if (height >= 480) return "480p";
return `${height}p`;
};
const Stat = ({ icon: Icon, label, value }: { icon: typeof Clock; label: string; value: string }) => (
<div className="flex items-center gap-2">
<Icon size={14} className="shrink-0 text-subtle" />
<span className="text-[11px] font-semibold uppercase tracking-[0.08em] text-subtle">{label}</span>
<span className="font-mono text-[12.5px] tabular-nums text-fg">{value}</span>
</div>
);
const ControlButton = ({ label, onClick, active = false, children }: {
label: string;
onClick: () => void;
active?: boolean;
children: React.ReactNode;
}) => (
<button
aria-label={label}
title={label}
onClick={onClick}
className="flex items-center justify-center rounded-lg p-1.5 text-white/90 transition-colors duration-150 hover:bg-white/15 hover:text-white"
className={clsx(
"flex items-center justify-center rounded-lg p-1.5 transition-colors duration-150 hover:bg-white/15 hover:text-white",
active ? "text-accent" : "text-white/90",
)}
>
{children}
</button>
);
const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
const MediaPlayer = ({ noun, getMediaUrl, fetchDetails, editPath }: MediaPlayerProps) => {
const { id } = useParams();
const navigate = useNavigate();
@@ -64,6 +109,8 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
const [duration, setDuration] = useState(0);
const [bufferedPct, setBufferedPct] = useState(0);
const [isWaiting, setIsWaiting] = useState(false);
const [speed, setSpeed] = useState(1);
const [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(null);
// Volume
const [volume, setVolume] = useState(1);
@@ -71,8 +118,13 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
// UI
const [isFullscreen, setIsFullscreen] = useState(false);
const [isPip, setIsPip] = useState(false);
const [controlsVisible, setControlsVisible] = useState(true);
const [isDragging, setIsDragging] = useState(false);
const [hoverTime, setHoverTime] = useState<number | null>(null);
const [showRemaining, setShowRemaining] = useState(false);
const [copied, setCopied] = useState(false);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
// ── Data loading ────────────────────────────────────────────────────────
// The video stream itself is loaded by the <video> element from `mediaUrl`,
@@ -109,7 +161,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
scheduleHide();
}, [scheduleHide]);
// ── Fullscreen ──────────────────────────────────────────────────────────
// ── Fullscreen & picture-in-picture ─────────────────────────────────────
useEffect(() => {
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onChange);
@@ -119,7 +171,33 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
const toggleFullscreen = useCallback(() => {
if (!containerRef.current) return;
if (document.fullscreenElement) document.exitFullscreen();
else containerRef.current.requestFullscreen();
else containerRef.current.requestFullscreen().catch(() => {});
}, []);
// React has no JSX props for these, so subscribe directly.
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const onEnter = () => setIsPip(true);
const onLeave = () => setIsPip(false);
video.addEventListener("enterpictureinpicture", onEnter);
video.addEventListener("leavepictureinpicture", onLeave);
return () => {
video.removeEventListener("enterpictureinpicture", onEnter);
video.removeEventListener("leavepictureinpicture", onLeave);
};
}, []);
const togglePip = useCallback(() => {
const video = videoRef.current;
if (!video || !document.pictureInPictureEnabled) return;
if (document.pictureInPictureElement) document.exitPictureInPicture().catch(() => {});
else video.requestPictureInPicture().catch(() => {});
}, []);
const changeSpeed = useCallback((rate: number) => {
if (videoRef.current) videoRef.current.playbackRate = rate;
setSpeed(rate);
}, []);
const skip = useCallback((seconds: number) => {
@@ -129,6 +207,14 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
showControls();
}, [showControls]);
const nudgeVolume = useCallback((delta: number) => {
const video = videoRef.current;
if (!video) return;
video.volume = Math.min(1, Math.max(0, video.volume + delta));
video.muted = video.volume === 0;
showControls();
}, [showControls]);
// ── Keyboard shortcuts ──────────────────────────────────────────────────
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -136,6 +222,14 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
const video = videoRef.current;
if (!video) return;
// Digits jump to that tenth of the timeline.
if (/^Digit[0-9]$/.test(e.code) && video.duration) {
e.preventDefault();
video.currentTime = (Number(e.code.slice(5)) / 10) * video.duration;
showControls();
return;
}
switch (e.code) {
case 'Space': case 'KeyK':
e.preventDefault();
@@ -143,37 +237,40 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
else video.pause();
showControls();
break;
case 'ArrowLeft':
e.preventDefault();
skip(-5);
break;
case 'ArrowRight':
e.preventDefault();
skip(5);
break;
case 'ArrowLeft': e.preventDefault(); skip(-5); break;
case 'ArrowRight': e.preventDefault(); skip(5); break;
case 'KeyJ': e.preventDefault(); skip(-10); break;
case 'KeyL': e.preventDefault(); skip(10); break;
case 'ArrowUp': e.preventDefault(); nudgeVolume(0.1); break;
case 'ArrowDown': e.preventDefault(); nudgeVolume(-0.1); break;
case 'KeyM':
video.muted = !video.muted;
setIsMuted(video.muted);
break;
case 'KeyF':
toggleFullscreen();
showControls();
break;
case 'KeyF': toggleFullscreen(); break;
case 'KeyP': togglePip(); break;
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [showControls, toggleFullscreen, skip]);
}, [showControls, toggleFullscreen, togglePip, skip, nudgeVolume]);
// ── Progress bar seeking ────────────────────────────────────────────────
const seekTo = useCallback((clientX: number) => {
const timeAtClientX = useCallback((clientX: number) => {
const rect = progressRef.current?.getBoundingClientRect();
const video = videoRef.current;
if (!rect || !video || !duration) return;
if (!rect || !duration) return null;
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
video.currentTime = ratio * duration;
setCurrentTime(ratio * duration);
return ratio * duration;
}, [duration]);
const seekTo = useCallback((clientX: number) => {
const time = timeAtClientX(clientX);
const video = videoRef.current;
if (time === null || !video) return;
video.currentTime = time;
setCurrentTime(time);
}, [timeAtClientX]);
useEffect(() => {
if (!isDragging) return;
const onMove = (e: MouseEvent) => seekTo(e.clientX);
@@ -198,8 +295,6 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = parseFloat(e.target.value);
setVolume(v);
setIsMuted(v === 0);
if (videoRef.current) {
videoRef.current.volume = v;
videoRef.current.muted = v === 0;
@@ -210,7 +305,6 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
const video = videoRef.current;
if (!video) return;
video.muted = !video.muted;
setIsMuted(video.muted);
};
const togglePlay = () => {
@@ -221,9 +315,17 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
showControls();
};
const handleCopyLink = () => {
navigator.clipboard.writeText(window.location.href);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
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;
const totalDuration = duration || item?.duration || 0;
if (notAuthenticated) {
return (
@@ -243,7 +345,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
}
return (
<div className="mx-auto flex h-full w-full max-w-6xl flex-col gap-4 px-4 py-5 sm:px-6">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-4 pb-12 pt-5 sm:px-6">
<Button
variant="ghost"
size="sm"
@@ -257,10 +359,11 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
{/* Player */}
<div
ref={containerRef}
className="relative min-h-0 flex-1 select-none overflow-hidden rounded-2xl border border-line bg-black shadow-raised"
className="player-shell relative aspect-video max-h-[68vh] w-full select-none overflow-hidden rounded-2xl border border-line bg-black shadow-raised"
onMouseMove={showControls}
onMouseLeave={() => isPlaying && setControlsVisible(false)}
onClick={togglePlay}
onDoubleClick={toggleFullscreen}
style={{ cursor: controlsVisible ? 'default' : 'none' }}
>
<video
@@ -278,9 +381,19 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
}}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={() => {
if (videoRef.current) setDuration(videoRef.current.duration);
const video = videoRef.current;
if (!video) return;
setDuration(video.duration);
setDimensions({ width: video.videoWidth, height: video.videoHeight });
setMediaLoaded(true);
}}
onVolumeChange={() => {
const video = videoRef.current;
if (!video) return;
setVolume(video.volume);
setIsMuted(video.muted);
}}
onRateChange={() => videoRef.current && setSpeed(videoRef.current.playbackRate)}
onWaiting={() => setIsWaiting(true)}
onCanPlay={() => setIsWaiting(false)}
onError={(e) => setError(e.currentTarget.error?.message || "An error occurred.")}
@@ -293,15 +406,22 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
</div>
)}
{/* Big centre play badge while paused */}
{/* Centre play badge while paused */}
{!isPlaying && mediaLoaded && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="flex h-16 w-16 items-center justify-center rounded-full bg-black/45 text-white backdrop-blur-sm">
<span className="flex h-16 w-16 animate-fade items-center justify-center rounded-full bg-black/45 text-white backdrop-blur-sm">
<Play size={26} fill="currentColor" className="ml-1" />
</span>
</div>
)}
{/* Speed badge, so a non-default rate is never a surprise */}
{speed !== 1 && (
<div className="pointer-events-none absolute right-3 top-3 rounded-lg bg-black/60 px-2 py-1 font-mono text-[12px] font-medium text-white backdrop-blur-sm">
{speed}×
</div>
)}
{/* Controls overlay — pointer-events-none so video clicks pass through */}
<div
className={clsx(
@@ -315,19 +435,37 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
<div
className="pointer-events-auto relative px-4 pb-4 pt-10"
onClick={(e) => e.stopPropagation()}
// Never fade the bar out from under a cursor that is using it.
onMouseEnter={() => {
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
setControlsVisible(true);
}}
onMouseLeave={() => isPlaying && scheduleHide()}
>
{/* Scrub bar */}
<div
ref={progressRef}
className="group/prog relative mb-3 h-1 cursor-pointer rounded-full bg-white/25 transition-[height] duration-150 hover:h-1.5"
onMouseDown={(e) => { setIsDragging(true); seekTo(e.clientX); }}
onMouseMove={(e) => setHoverTime(timeAtClientX(e.clientX))}
onMouseLeave={() => setHoverTime(null)}
>
<div className="absolute inset-y-0 left-0 rounded-full bg-white/35" style={{ width: `${bufferedPct}%` }} />
<div className="absolute inset-y-0 left-0 rounded-full bg-accent" style={{ width: `${progressPct}%` }} />
<div
className="absolute top-1/2 h-3.5 w-3.5 -translate-y-1/2 rounded-full bg-accent shadow opacity-0 transition-opacity group-hover/prog:opacity-100"
className="absolute top-1/2 h-3.5 w-3.5 -translate-y-1/2 rounded-full bg-accent opacity-0 shadow transition-opacity group-hover/prog:opacity-100"
style={{ left: `calc(${progressPct}% - 7px)` }}
/>
{/* Hovered timestamp */}
{hoverTime !== null && (
<div
className="pointer-events-none absolute bottom-5 -translate-x-1/2 rounded-md bg-black/85 px-1.5 py-0.5 font-mono text-[11px] tabular-nums text-white shadow"
style={{ left: `${hoverPct}%` }}
>
{formatTime(hoverTime)}
</div>
)}
</div>
{/* Buttons row */}
@@ -361,12 +499,56 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
</div>
</div>
<span className="ml-2 font-mono text-[12px] tabular-nums text-white/85">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
<button
onClick={() => setShowRemaining((v) => !v)}
title={showRemaining ? "Show elapsed time" : "Show remaining time"}
className="ml-2 font-mono text-[12px] tabular-nums text-white/85 transition-colors hover:text-white"
>
{showRemaining
? `-${formatTime(Math.max(0, duration - currentTime))}`
: formatTime(currentTime)}
<span className="text-white/50"> / {formatTime(duration)}</span>
</button>
<div className="flex-1" />
<Menu
placement="top"
align="right"
menuClassName="min-w-32"
trigger={() => (
<span
title="Playback speed"
className="flex items-center gap-1 rounded-lg px-2 py-1.5 text-[12px] font-medium text-white/90 transition-colors hover:bg-white/15 hover:text-white"
>
<Gauge size={16} />
{speed}×
</span>
)}
>
{(close) => (
<>
{SPEEDS.map((rate) => (
<MenuItem
key={rate}
onClick={() => { changeSpeed(rate); close(); }}
icon={rate === speed
? <Check size={14} className="text-accent" />
: <span className="w-3.5" />}
>
{rate === 1 ? "Normal" : `${rate}×`}
</MenuItem>
))}
</>
)}
</Menu>
{document.pictureInPictureEnabled && (
<ControlButton label="Picture in picture" onClick={togglePip} active={isPip}>
<PictureInPicture2 size={17} />
</ControlButton>
)}
<ControlButton
label={isFullscreen ? "Exit fullscreen" : "Fullscreen"}
onClick={toggleFullscreen}
@@ -379,25 +561,128 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
</div>
{error && (
<p className="shrink-0 rounded-xl border border-danger/25 bg-danger/8 px-4 py-2.5 text-[13px] text-danger">
<p className="rounded-xl border border-danger/25 bg-danger/8 px-4 py-2.5 text-[13px] text-danger">
{error}
</p>
)}
{!mediaLoaded && !error && (
<p className="flex shrink-0 items-center gap-2 text-[13px] text-muted">
<p className="flex items-center gap-2 text-[13px] text-muted">
<Spinner size={13} />
Loading {noun.toLowerCase()}…
</p>
)}
<Card className="shrink-0 p-5">
<h1 className="font-serif text-xl leading-tight text-fg">{item?.title || "Untitled"}</h1>
<p className="mt-1 text-[13px] text-subtle">{timeAgo}</p>
{item?.description && (
<p className="mt-3 whitespace-pre-line border-t border-line pt-3 text-sm leading-relaxed text-muted">
{/* Details */}
<Card className="overflow-hidden">
<div className="p-5 sm:p-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<Badge tone="accent">{noun}</Badge>
<h1 className="mt-2.5 font-serif text-2xl leading-tight text-fg">
{item?.title || "Untitled"}
</h1>
<p className="mt-1.5 text-[13px] text-subtle">
{timeAgo}
{item?.createdAt && (
<>
{" · "}
{stringToDate(item.createdAt).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
})}
</>
)}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="secondary"
size="sm"
icon={copied ? <Check size={14} /> : <Link2 size={14} />}
onClick={handleCopyLink}
>
{copied ? "Copied" : "Copy link"}
</Button>
{mediaUrl && (
<a href={mediaUrl} download={`${item?.title || noun}.mp4`}>
<Button variant="secondary" size="sm" icon={<Download size={14} />}>
Download
</Button>
</a>
)}
{id && (
<Link to={editPath(id)}>
<Button variant="secondary" size="sm" icon={<Pencil size={14} />}>
Edit
</Button>
</Link>
)}
</div>
</div>
{item?.description ? (
<p className="mt-5 whitespace-pre-line border-t border-line pt-5 text-sm leading-relaxed text-muted">
{item.description}
</p>
) : (
<p className="mt-5 border-t border-line pt-5 text-sm italic text-subtle">
No description yet.
</p>
)}
</div>
{/* Technical strip */}
<div className="flex flex-wrap items-center gap-x-6 gap-y-2.5 border-t border-line bg-sunken px-5 py-3.5 sm:px-6">
<Stat icon={Clock} label="Length" value={formatTime(totalDuration)} />
{dimensions && dimensions.height > 0 && (
<Stat
icon={MonitorPlay}
label="Video"
value={`${dimensions.width}×${dimensions.height} · ${qualityLabel(dimensions.height)}`}
/>
)}
<Stat icon={Gauge} label="Speed" value={`${speed}×`} />
{item?.createdAt && (
<Stat
icon={CalendarDays}
label="Added"
value={stringToDate(item.createdAt).toLocaleDateString(undefined, {
year: "numeric", month: "short", day: "numeric",
})}
/>
)}
{mediaLoaded && bufferedPct > 0 && bufferedPct < 99.5 && (
<Stat icon={Download} label="Buffered" value={`${Math.round(bufferedPct)}%`} />
)}
</div>
</Card>
{/* Shortcuts */}
<Card className="overflow-hidden">
<button
onClick={() => setShortcutsOpen((v) => !v)}
className="flex w-full items-center gap-2.5 px-5 py-3.5 text-left transition-colors hover:bg-surface-hover sm:px-6"
>
<Keyboard size={16} className="text-subtle" />
<span className="text-[13px] font-medium text-fg">Keyboard shortcuts</span>
<span className="ml-auto text-[12px] text-subtle">{shortcutsOpen ? "Hide" : "Show"}</span>
</button>
{shortcutsOpen && (
<div className="grid animate-rise grid-cols-1 gap-x-8 gap-y-2 border-t border-line px-5 py-4 sm:grid-cols-2 sm:px-6 lg:grid-cols-4">
{SHORTCUTS.map(([keys, action]) => (
<div key={keys} className="flex items-center justify-between gap-3">
<span className="text-[13px] text-muted">{action}</span>
<kbd className="shrink-0 rounded-md border border-line bg-sunken px-2 py-0.5 font-mono text-[11px] text-fg">
{keys}
</kbd>
</div>
))}
</div>
)}
</Card>
</div>