add more info to media player
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user