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 <Route
path="/video/:id" 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 <Route
path="/vod/:id" 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 <Route

View File

@@ -323,6 +323,17 @@
.slider-onvideo::-moz-range-thumb { background: #fff; } .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 */ /* react-range-slider-input — trim range on the clip editor */
#range-slider { #range-slider {
height: 8px; height: 8px;

View File

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

View File

@@ -1,22 +1,26 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
import { import {
ArrowLeft, Lock, Maximize, Minimize, Pause, Play, ArrowLeft, CalendarDays, Check, Clock, Download, Gauge, Keyboard, Link2,
RotateCcw, RotateCw, Volume1, Volume2, VolumeX, Lock, Maximize, Minimize, MonitorPlay, Pause, Pencil, PictureInPicture2,
Play, RotateCcw, RotateCw, Volume1, Volume2, VolumeX,
} from "lucide-react"; } from "lucide-react";
import clsx from "clsx"; import clsx from "clsx";
import { AuthError } from "../utils/api/client.ts"; import { AuthError } from "../utils/api/client.ts";
import Card from "../components/ui/Card.tsx"; import Card from "../components/ui/Card.tsx";
import Button from "../components/ui/Button.tsx"; import Button from "../components/ui/Button.tsx";
import Badge from "../components/ui/Badge.tsx";
import EmptyState from "../components/ui/EmptyState.tsx"; import EmptyState from "../components/ui/EmptyState.tsx";
import Spinner from "../components/ui/Spinner.tsx"; import Spinner from "../components/ui/Spinner.tsx";
import Menu, { MenuItem } from "../components/ui/Menu.tsx";
import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts"; import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts";
// Both VoDs and clips share the same on-screen shape. // Both VoDs and clips share the same on-screen shape.
type MediaItem = { type MediaItem = {
title: string, title: string,
description?: string, description?: string,
duration?: number,
createdAt: string, createdAt: string,
}; };
@@ -28,20 +32,61 @@ type MediaPlayerProps = {
getMediaUrl: (id: string) => string, getMediaUrl: (id: string) => string,
/** Fetches the media metadata for the given id. */ /** Fetches the media metadata for the given id. */
fetchDetails: (id: string) => Promise<MediaItem | null>, 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 <button
aria-label={label} aria-label={label}
title={label} title={label}
onClick={onClick} 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} {children}
</button> </button>
); );
const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => { const MediaPlayer = ({ noun, getMediaUrl, fetchDetails, editPath }: MediaPlayerProps) => {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -64,6 +109,8 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
const [duration, setDuration] = useState(0); const [duration, setDuration] = useState(0);
const [bufferedPct, setBufferedPct] = useState(0); const [bufferedPct, setBufferedPct] = useState(0);
const [isWaiting, setIsWaiting] = useState(false); const [isWaiting, setIsWaiting] = useState(false);
const [speed, setSpeed] = useState(1);
const [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(null);
// Volume // Volume
const [volume, setVolume] = useState(1); const [volume, setVolume] = useState(1);
@@ -71,8 +118,13 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
// UI // UI
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const [isPip, setIsPip] = useState(false);
const [controlsVisible, setControlsVisible] = useState(true); const [controlsVisible, setControlsVisible] = useState(true);
const [isDragging, setIsDragging] = useState(false); 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 ──────────────────────────────────────────────────────── // ── Data loading ────────────────────────────────────────────────────────
// The video stream itself is loaded by the <video> element from `mediaUrl`, // The video stream itself is loaded by the <video> element from `mediaUrl`,
@@ -109,7 +161,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
scheduleHide(); scheduleHide();
}, [scheduleHide]); }, [scheduleHide]);
// ── Fullscreen ────────────────────────────────────────────────────────── // ── Fullscreen & picture-in-picture ─────────────────────────────────────
useEffect(() => { useEffect(() => {
const onChange = () => setIsFullscreen(!!document.fullscreenElement); const onChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onChange); document.addEventListener('fullscreenchange', onChange);
@@ -119,7 +171,33 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
const toggleFullscreen = useCallback(() => { const toggleFullscreen = useCallback(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
if (document.fullscreenElement) document.exitFullscreen(); 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) => { const skip = useCallback((seconds: number) => {
@@ -129,6 +207,14 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
showControls(); showControls();
}, [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 ────────────────────────────────────────────────── // ── Keyboard shortcuts ──────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
@@ -136,6 +222,14 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
const video = videoRef.current; const video = videoRef.current;
if (!video) return; 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) { switch (e.code) {
case 'Space': case 'KeyK': case 'Space': case 'KeyK':
e.preventDefault(); e.preventDefault();
@@ -143,37 +237,40 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
else video.pause(); else video.pause();
showControls(); showControls();
break; break;
case 'ArrowLeft': case 'ArrowLeft': e.preventDefault(); skip(-5); break;
e.preventDefault(); case 'ArrowRight': e.preventDefault(); skip(5); break;
skip(-5); case 'KeyJ': e.preventDefault(); skip(-10); break;
break; case 'KeyL': e.preventDefault(); skip(10); break;
case 'ArrowRight': case 'ArrowUp': e.preventDefault(); nudgeVolume(0.1); break;
e.preventDefault(); case 'ArrowDown': e.preventDefault(); nudgeVolume(-0.1); break;
skip(5);
break;
case 'KeyM': case 'KeyM':
video.muted = !video.muted; video.muted = !video.muted;
setIsMuted(video.muted); showControls();
break;
case 'KeyF':
toggleFullscreen();
break; break;
case 'KeyF': toggleFullscreen(); break;
case 'KeyP': togglePip(); break;
} }
}; };
document.addEventListener('keydown', onKey); document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey);
}, [showControls, toggleFullscreen, skip]); }, [showControls, toggleFullscreen, togglePip, skip, nudgeVolume]);
// ── Progress bar seeking ──────────────────────────────────────────────── // ── Progress bar seeking ────────────────────────────────────────────────
const seekTo = useCallback((clientX: number) => { const timeAtClientX = useCallback((clientX: number) => {
const rect = progressRef.current?.getBoundingClientRect(); const rect = progressRef.current?.getBoundingClientRect();
const video = videoRef.current; if (!rect || !duration) return null;
if (!rect || !video || !duration) return;
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
video.currentTime = ratio * duration; return ratio * duration;
setCurrentTime(ratio * duration);
}, [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(() => { useEffect(() => {
if (!isDragging) return; if (!isDragging) return;
const onMove = (e: MouseEvent) => seekTo(e.clientX); const onMove = (e: MouseEvent) => seekTo(e.clientX);
@@ -198,8 +295,6 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = parseFloat(e.target.value); const v = parseFloat(e.target.value);
setVolume(v);
setIsMuted(v === 0);
if (videoRef.current) { if (videoRef.current) {
videoRef.current.volume = v; videoRef.current.volume = v;
videoRef.current.muted = v === 0; videoRef.current.muted = v === 0;
@@ -210,7 +305,6 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
const video = videoRef.current; const video = videoRef.current;
if (!video) return; if (!video) return;
video.muted = !video.muted; video.muted = !video.muted;
setIsMuted(video.muted);
}; };
const togglePlay = () => { const togglePlay = () => {
@@ -221,9 +315,17 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
showControls(); showControls();
}; };
const handleCopyLink = () => {
navigator.clipboard.writeText(window.location.href);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const progressPct = duration > 0 ? (currentTime / duration) * 100 : 0; const progressPct = duration > 0 ? (currentTime / duration) * 100 : 0;
const hoverPct = hoverTime !== null && duration > 0 ? (hoverTime / duration) * 100 : 0;
const effectiveVolume = isMuted ? 0 : volume; const effectiveVolume = isMuted ? 0 : volume;
const VolumeIcon = effectiveVolume === 0 ? VolumeX : effectiveVolume < 0.5 ? Volume1 : Volume2; const VolumeIcon = effectiveVolume === 0 ? VolumeX : effectiveVolume < 0.5 ? Volume1 : Volume2;
const totalDuration = duration || item?.duration || 0;
if (notAuthenticated) { if (notAuthenticated) {
return ( return (
@@ -243,7 +345,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
} }
return ( 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 <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -257,10 +359,11 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
{/* Player */} {/* Player */}
<div <div
ref={containerRef} 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} onMouseMove={showControls}
onMouseLeave={() => isPlaying && setControlsVisible(false)} onMouseLeave={() => isPlaying && setControlsVisible(false)}
onClick={togglePlay} onClick={togglePlay}
onDoubleClick={toggleFullscreen}
style={{ cursor: controlsVisible ? 'default' : 'none' }} style={{ cursor: controlsVisible ? 'default' : 'none' }}
> >
<video <video
@@ -278,9 +381,19 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
}} }}
onTimeUpdate={handleTimeUpdate} onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={() => { 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); 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)} onWaiting={() => setIsWaiting(true)}
onCanPlay={() => setIsWaiting(false)} onCanPlay={() => setIsWaiting(false)}
onError={(e) => setError(e.currentTarget.error?.message || "An error occurred.")} onError={(e) => setError(e.currentTarget.error?.message || "An error occurred.")}
@@ -293,15 +406,22 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
</div> </div>
)} )}
{/* Big centre play badge while paused */} {/* Centre play badge while paused */}
{!isPlaying && mediaLoaded && ( {!isPlaying && mediaLoaded && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center"> <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" /> <Play size={26} fill="currentColor" className="ml-1" />
</span> </span>
</div> </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 */} {/* Controls overlay — pointer-events-none so video clicks pass through */}
<div <div
className={clsx( className={clsx(
@@ -315,19 +435,37 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
<div <div
className="pointer-events-auto relative px-4 pb-4 pt-10" className="pointer-events-auto relative px-4 pb-4 pt-10"
onClick={(e) => e.stopPropagation()} 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 */} {/* Scrub bar */}
<div <div
ref={progressRef} 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" 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); }} 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-white/35" style={{ width: `${bufferedPct}%` }} />
<div className="absolute inset-y-0 left-0 rounded-full bg-accent" style={{ width: `${progressPct}%` }} /> <div className="absolute inset-y-0 left-0 rounded-full bg-accent" style={{ width: `${progressPct}%` }} />
<div <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)` }} 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> </div>
{/* Buttons row */} {/* Buttons row */}
@@ -361,12 +499,56 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
</div> </div>
</div> </div>
<span className="ml-2 font-mono text-[12px] tabular-nums text-white/85"> <button
{formatTime(currentTime)} / {formatTime(duration)} onClick={() => setShowRemaining((v) => !v)}
</span> 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" /> <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 <ControlButton
label={isFullscreen ? "Exit fullscreen" : "Fullscreen"} label={isFullscreen ? "Exit fullscreen" : "Fullscreen"}
onClick={toggleFullscreen} onClick={toggleFullscreen}
@@ -379,25 +561,128 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
</div> </div>
{error && ( {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} {error}
</p> </p>
)} )}
{!mediaLoaded && !error && ( {!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} /> <Spinner size={13} />
Loading {noun.toLowerCase()}… Loading {noun.toLowerCase()}…
</p> </p>
)} )}
<Card className="shrink-0 p-5"> {/* Details */}
<h1 className="font-serif text-xl leading-tight text-fg">{item?.title || "Untitled"}</h1> <Card className="overflow-hidden">
<p className="mt-1 text-[13px] text-subtle">{timeAgo}</p> <div className="p-5 sm:p-6">
{item?.description && ( <div className="flex flex-wrap items-start justify-between gap-4">
<p className="mt-3 whitespace-pre-line border-t border-line pt-3 text-sm leading-relaxed text-muted"> <div className="min-w-0">
{item.description} <Badge tone="accent">{noun}</Badge>
</p> <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> </Card>
</div> </div>