feat(player): clip mode - mark a range of what is playing and cut it into a download

Both players get a scissors in their control row (Plex: the control row; YouTube: the actions and
the docked bar), `c`, and `[` / `]` to mark at the playhead. The clip panel lives in the player, so
the watched video is the preview: an overview of the whole item and a zoomed strip around the
selection whose ends are sliders (arrows step a tenth, Shift a second), Start / End fields with
nudges and "to the playhead", the length, a looping Play selection, a name, Cut. Escape closes the
panel before the player and the focus goes back to the scissors. A cut is a toast with a Downloads
link plus the usual Ready notification.

- ui/RangeTrack + RulerTicks: the shared, domain-free time track (regions, keyboard-steppable
  handles with a 1.5rem hit area, a playhead, a background slot, over a window of the item). The
  Downloads editor's cuts moved onto it, so they are keyboard-steppable now.
- lib/clipRange holds the marking rules (1 s to 10 min, the server's), lib/timecode the editing
  timecode the editor's private tc/parseTc became.
- keymap: a symbol typed through AltGr answers its bare-symbol row - on the HU layout `[` is
  AltGr+F, which arrives as Ctrl+Alt; letter and Mod rows still never match Ctrl+Alt.
- The YouTube stage sits in a wrapper with the panel, so the maximised stage's blur escape matches
  at any depth.
- media-scope: fields on the always-dark player chrome read white in a light theme too.
This commit is contained in:
2026-10-10 03:58:27 +02:00
parent f221c1962a
commit 50157cc2fd
28 changed files with 2121 additions and 602 deletions
+259 -147
View File
@@ -28,6 +28,7 @@ import {
Minimize2,
PanelRightClose,
PanelRightOpen,
Scissors,
Settings,
SkipBack,
SkipForward,
@@ -50,6 +51,8 @@ import {
} from "../lib/layerStack";
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
import DownloadButton from "./DownloadButton";
import ClipPanel from "./ClipPanel";
import { DEFAULT_CLIP_S, markIn, markOut, openAt, type ClipRange } from "../lib/clipRange";
import { api, type Video, type VideoStatus } from "../lib/api";
import type { YTPlayer } from "../lib/youtube";
import {
@@ -338,6 +341,56 @@ export default function PlayerModal({
const [currentVideoId, setCurrentVideoId] = useState(active.id);
const currentIdRef = useRef(active.id);
const navigated = currentVideoId !== active.id;
// Clip mode: the range being marked of the video that is playing, or null. Its panel sits under
// the video, which is its preview; another video starts without one.
const [clip, setClip] = useState<ClipRange | null>(null);
const [clipFor, setClipFor] = useState(currentVideoId);
if (clipFor !== currentVideoId) {
setClipFor(currentVideoId);
setClip(null);
}
const clipOpen = clip != null;
// The player's clock, read while the panel is open: the iframe reports no time of its own.
const [clipClock, setClipClock] = useState({ now: 0, duration: 0 });
useEffect(() => {
if (!clipOpen) return;
const read = () => {
const p = playerRef.current;
if (!p || typeof p.getCurrentTime !== "function") return;
const now = p.getCurrentTime() || 0;
const duration = p.getDuration() || 0;
// Paused, the clock stands still: no new state, no re-render of the whole modal.
setClipClock((c) => (c.now === now && c.duration === duration ? c : { now, duration }));
};
read();
const iv = window.setInterval(read, 200);
return () => window.clearInterval(iv);
}, [clipOpen]);
// The scissors that opened the panel gets the focus back when it closes.
const clipButtonRef = useRef<HTMLButtonElement>(null);
const refocusClip = useRef(false);
const closeClip = () => {
refocusClip.current = true;
setClip(null);
};
useEffect(() => {
if (clipOpen || !refocusClip.current) return;
refocusClip.current = false;
clipButtonRef.current?.focus();
}, [clipOpen]);
/** Mark where the player is now (`mark` gets the range so far, or null when the panel is shut). The
* panel lives under the video, so a big view steps back to the card first. */
const markClip = (mark: (r: ClipRange | null, t: number, d: number) => ClipRange) => {
const p = playerRef.current;
if (!p || typeof p.getCurrentTime !== "function") return;
const t = p.getCurrentTime() || 0;
const d = p.getDuration() || 0;
if (d <= 0) return;
if (maximized || isFullscreen) exitBigView();
setClipClock({ now: t, duration: d });
setClip((r) => mark(r, t, d));
};
const openClip = () => markClip((_r, t, d) => openAt(t, d));
// Title/author of a navigated-to video, read from the player (free, no API call).
const [liveData, setLiveData] = useState<{ title?: string; author?: string } | null>(null);
// What is playing, by name: the panel's title, and the dialog's name (`titleId`) in either layout.
@@ -601,6 +654,13 @@ export default function PlayerModal({
return;
}
dispatch(e, "ytPlayer", {
clip: () => {
if (clip) closeClip();
else openClip();
},
markIn: () => markClip((r, t, d) => markIn(r ?? openAt(t, d), t, d)),
markOut: () =>
markClip((r, t, d) => markOut(r ?? openAt(Math.max(0, t - DEFAULT_CLIP_S), d), t, d)),
close: (ev) => {
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an
// anchored popover): it takes the Escape and we do nothing — so we don't also step out of
@@ -609,6 +669,12 @@ export default function PlayerModal({
// stack synchronously, so `anyLayerAbove` alone would miss it and close the player too.
if (escapeHandled(ev)) return;
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
// The clip panel is the player's own layer: it closes before the player does.
if (clip) {
ev.preventDefault();
closeClip();
return;
}
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
if (fullscreenElement()) return;
// Same for our own maximise: Esc steps back out of it before it closes the player.
@@ -746,32 +812,35 @@ export default function PlayerModal({
}`}
onClick={(e) => e.stopPropagation()}
>
<div
ref={stageRef}
// The wheel over the video is its volume, even inside a stacked card that scrolls.
data-wheel-volume=""
className={`player-stage relative aspect-video w-full bg-black overflow-hidden ${
wide ? "flex-1 min-w-0 self-start rounded-l-2xl" : "rounded-t-2xl"
} ${maximized ? "player-stage--max" : ""}`}
>
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed
{/* The video and, in clip mode, the clip panel under it. Always this wrapper, so opening the
panel never moves the stage to another parent (that would re-create the YouTube iframe). */}
<div className={wide ? "flex-1 min-w-0 self-start" : undefined}>
<div
ref={stageRef}
// The wheel over the video is its volume, even inside a stacked card that scrolls.
data-wheel-volume=""
className={`player-stage relative aspect-video w-full bg-black overflow-hidden ${
wide ? (clipOpen ? "rounded-tl-2xl" : "rounded-l-2xl") : "rounded-t-2xl"
} ${maximized ? "player-stage--max" : ""}`}
>
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed
through our overlay. */}
{/* In the big views the video area is inset by the control bar's height (`bottom-14`) so
{/* In the big views the video area is inset by the control bar's height (`bottom-14`) so
the bar below never overlaps it. The inset lives on THIS wrapper, not on `mountRef`:
YT.Player REPLACES the mount element with its iframe (width/height 100% of the parent),
which would drop any class on mountRef itself — the iframe then sizes to this wrapper. */}
<div
className={
maximized || isFullscreen
? "absolute inset-x-0 top-0 bottom-14"
: "relative w-full h-full"
}
>
<div
ref={mountRef}
className={`w-full h-full ${playerError != null ? "invisible" : ""}`}
/>
{/* Interaction layer over the CENTRE of the video: catches click (play/pause) + wheel
className={
maximized || isFullscreen
? "absolute inset-x-0 top-0 bottom-14"
: "relative w-full h-full"
}
>
<div
ref={mountRef}
className={`w-full h-full ${playerError != null ? "invisible" : ""}`}
/>
{/* Interaction layer over the CENTRE of the video: catches click (play/pause) + wheel
volume (the wheel event bubbles from here to the modal's listener — over the bare
cross-origin iframe it would be swallowed) and stops the iframe stealing keyboard focus.
It leaves the top AND bottom edges uncovered so YouTube's edge controls stay reachable,
@@ -782,25 +851,25 @@ export default function PlayerModal({
gap) — it only yields to `nativeControls`, i.e. once the user opens YouTube's own
settings menu (focus moves into the iframe), so that menu is navigable end to end.
Hidden on error so the "Open on YouTube" CTA is clickable. */}
{playerError == null &&
YT_CLICK_LAYER.map((box, i) => (
<div
key={i}
data-testid="yt-click-layer"
onClick={() => {
focusModal();
togglePlay();
}}
title={t("player.shortcutsHint")}
aria-hidden="true"
style={box}
className={`absolute z-base cursor-pointer ${
nativeControls ? "pointer-events-none" : ""
}`}
/>
))}
</div>
{/* Our own control bar for the big views (maximise / our fullscreen), where the card's
{playerError == null &&
YT_CLICK_LAYER.map((box, i) => (
<div
key={i}
data-testid="yt-click-layer"
onClick={() => {
focusModal();
togglePlay();
}}
title={t("player.shortcutsHint")}
aria-hidden="true"
style={box}
className={`absolute z-base cursor-pointer ${
nativeControls ? "pointer-events-none" : ""
}`}
/>
))}
</div>
{/* Our own control bar for the big views (maximise / our fullscreen), where the card's
controls are off-screen. It's IN the stage (so it survives true fullscreen, where a
<body> portal would not) and DOCKED in its own strip: the video area above is inset by
this bar's height (see the iframe wrapper's `bottom-14`), so the bar NEVER overlaps YouTube's seek
@@ -809,124 +878,154 @@ export default function PlayerModal({
seek bar over the same pixels. stopPropagation so a click here doesn't toggle playback;
the exit button also re-arms our shortcuts. Both of its dialogs work in true fullscreen:
add-to-playlist's popover and download's Modal portal INTO the fullscreen element. */}
{isFullscreen && <Toaster overlay order={overlayOrder} />}
{(maximized || isFullscreen) && (
<div
onClick={(e) => e.stopPropagation()}
className="absolute inset-x-0 bottom-0 z-menu flex h-14 items-center justify-start gap-x-3 overflow-x-auto border-t border-white/10 bg-media-chrome/95 px-3 text-sm text-white"
>
{hasQueue && (
<>
<PlayerTransportControls
variant="docked"
index={index}
queueLength={queue!.length}
autoMode={autoMode}
loopMode={loopMode}
onPrev={goPrev}
onNext={goNext}
onCycleAuto={cycleAuto}
onCycleLoop={cycleLoop}
/>
<span className="h-4 w-px bg-white/20" />
</>
)}
{/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both
{isFullscreen && <Toaster overlay order={overlayOrder} />}
{(maximized || isFullscreen) && (
<div
onClick={(e) => e.stopPropagation()}
className="absolute inset-x-0 bottom-0 z-menu flex h-14 items-center justify-start gap-x-3 overflow-x-auto border-t border-white/10 bg-media-chrome/95 px-3 text-sm text-white"
>
{hasQueue && (
<>
<PlayerTransportControls
variant="docked"
index={index}
queueLength={queue!.length}
autoMode={autoMode}
loopMode={loopMode}
onPrev={goPrev}
onNext={goNext}
onCycleAuto={cycleAuto}
onCycleLoop={cycleLoop}
/>
<span className="h-4 w-px bg-white/20" />
</>
)}
{/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both
portal into the fullscreen element rather than <body> (a body-portaled node is
painted underneath the element holding the lock, i.e. invisible). */}
{!navigated && <AddToPlaylist videoId={active.id} variant="overlay" />}
{!navigated && (
<DownloadButton videoId={active.id} title={active.title} variant="overlay" />
)}
{!navigated && (
{!navigated && <AddToPlaylist videoId={active.id} variant="overlay" />}
{!navigated && (
<DownloadButton videoId={active.id} title={active.title} variant="overlay" />
)}
{/* The clip panel lives under the video in the card: this steps out of the big view. */}
{playerError == null && (
<IconButton variant="overlay" onClick={openClip} label={t("clip.open")}>
<Scissors />
</IconButton>
)}
{!navigated && (
<Button
variant="overlay"
size="sm"
pressed={watched}
onClick={() => setWatched(!watched)}
label={watched ? t("player.watchedUnmark") : t("player.markWatched")}
icon={watched ? <CheckCheck /> : <Check />}
>
<span className="hidden md:inline">
{watched ? t("player.watched") : t("player.markWatched")}
</span>
</Button>
)}
<span className="h-4 w-px bg-white/20" />
{/* Absent where the browser cannot fullscreen an element (Brave on an iPad): there
the window-filling view is the biggest, and YouTube's own button opens the
system player. */}
{canFullscreenElements() && (
<IconButton
variant="overlay"
onClick={toggleTrueFullscreen}
label={isFullscreen ? t("player.exitFullscreen") : t("player.fullscreen")}
>
{isFullscreen ? <Minimize /> : <Maximize />}
</IconButton>
)}
<Button
variant="overlay"
size="sm"
pressed={watched}
onClick={() => setWatched(!watched)}
label={watched ? t("player.watchedUnmark") : t("player.markWatched")}
icon={watched ? <CheckCheck /> : <Check />}
onClick={(e) => {
e.stopPropagation();
exitBigView();
setNativeControls(false);
focusModal();
}}
label={t("player.exitMaximize")}
icon={<Minimize2 />}
>
<span className="hidden md:inline">
{watched ? t("player.watched") : t("player.markWatched")}
</span>
<span className="hidden sm:inline">{t("player.exitMaximize")}</span>
</Button>
)}
<span className="h-4 w-px bg-white/20" />
{/* Absent where the browser cannot fullscreen an element (Brave on an iPad): there
the window-filling view is the biggest, and YouTube's own button opens the
system player. */}
{canFullscreenElements() && (
<IconButton
variant="overlay"
onClick={toggleTrueFullscreen}
label={isFullscreen ? t("player.exitFullscreen") : t("player.fullscreen")}
>
{isFullscreen ? <Minimize /> : <Maximize />}
</IconButton>
)}
<Button
variant="overlay"
size="sm"
onClick={(e) => {
e.stopPropagation();
exitBigView();
setNativeControls(false);
focusModal();
}}
label={t("player.exitMaximize")}
icon={<Minimize2 />}
>
<span className="hidden sm:inline">{t("player.exitMaximize")}</span>
</Button>
</div>
)}
{/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */}
{playerError == null && nativeControls && (
<div className="pointer-events-none absolute top-3 left-3 z-menu flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-2xs text-white/90 shadow-lg backdrop-blur-xs">
<Settings className="h-3.5 w-3.5" />
{t("player.nativeControls")}
</div>
)}
{/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */}
{volumeUi != null && (
<div className="pointer-events-none absolute top-3 left-1/2 -translate-x-1/2 z-menu flex items-center gap-2 rounded-full bg-black/70 px-3 py-1.5 text-xs text-white shadow-lg">
{volumeUi === 0 ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
<div className="h-1.5 w-28 overflow-hidden rounded-full bg-white/25">
<div
className="h-full rounded-full bg-white transition-all"
style={{ width: `${volumeUi}%` }}
/>
</div>
<span className="w-7 text-right tabular-nums">{volumeUi}</span>
</div>
)}
{playerError != null && (
<div className="absolute inset-0 grid place-items-center bg-bg p-6 text-center">
<div className="max-w-sm">
<AlertTriangle className="w-8 h-8 text-warn mx-auto mb-3" />
<h3 className="text-base font-semibold">{t("player.unavailableTitle")}</h3>
<p className="text-sm text-muted mt-1.5 mb-4">
{playerError === YT_API_LOAD_FAILED
? t("player.apiLoadFailedBody")
: playerError === 101 || playerError === 150
? t("player.embedDisabledBody")
: t("player.unavailableBody")}
</p>
<LinkButton
href={`https://www.youtube.com/watch?v=${currentVideoId}`}
target="_blank"
rel="noreferrer"
variant="primary"
icon={<ExternalLink />}
>
{t("player.openOnYoutube")}
</LinkButton>
)}
{/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */}
{playerError == null && nativeControls && (
<div className="pointer-events-none absolute top-3 left-3 z-menu flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-2xs text-white/90 shadow-lg backdrop-blur-xs">
<Settings className="h-3.5 w-3.5" />
{t("player.nativeControls")}
</div>
</div>
)}
{/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */}
{volumeUi != null && (
<div className="pointer-events-none absolute top-3 left-1/2 -translate-x-1/2 z-menu flex items-center gap-2 rounded-full bg-black/70 px-3 py-1.5 text-xs text-white shadow-lg">
{volumeUi === 0 ? (
<VolumeX className="h-4 w-4" />
) : (
<Volume2 className="h-4 w-4" />
)}
<div className="h-1.5 w-28 overflow-hidden rounded-full bg-white/25">
<div
className="h-full rounded-full bg-white transition-all"
style={{ width: `${volumeUi}%` }}
/>
</div>
<span className="w-7 text-right tabular-nums">{volumeUi}</span>
</div>
)}
{playerError != null && (
<div className="absolute inset-0 grid place-items-center bg-bg p-6 text-center">
<div className="max-w-sm">
<AlertTriangle className="w-8 h-8 text-warn mx-auto mb-3" />
<h3 className="text-base font-semibold">{t("player.unavailableTitle")}</h3>
<p className="text-sm text-muted mt-1.5 mb-4">
{playerError === YT_API_LOAD_FAILED
? t("player.apiLoadFailedBody")
: playerError === 101 || playerError === 150
? t("player.embedDisabledBody")
: t("player.unavailableBody")}
</p>
<LinkButton
href={`https://www.youtube.com/watch?v=${currentVideoId}`}
target="_blank"
rel="noreferrer"
variant="primary"
icon={<ExternalLink />}
>
{t("player.openOnYoutube")}
</LinkButton>
</div>
</div>
)}
</div>
{clip && !maximized && !isFullscreen && (
<ClipPanel
src={{ source: "youtube", ref: currentVideoId }}
clip={clip}
onChange={setClip}
duration={clipClock.duration}
current={clipClock.now}
onSeek={(time, final) => {
// A seek past what is buffered fetches from there: only once the drag settles.
playerRef.current?.seekTo?.(time, final);
setClipClock((c) => ({ ...c, now: time }));
}}
onPlayFrom={(time) => {
seekTo(time);
setClipClock((c) => ({ ...c, now: time }));
}}
onClose={closeClip}
/>
)}
</div>
{/* Wide: the side panel. Its content scrolls inside a box the video's height, so a long
description never makes the card taller than the video. */}
<div
@@ -1201,6 +1300,19 @@ export default function PlayerModal({
variant="outline"
size="md"
/>
{playerError == null && (
<IconButton
ref={clipButtonRef}
variant="outline"
size="md"
pressed={clipOpen}
onClick={() => (clip ? closeClip() : openClip())}
label={t("clip.open")}
title={`${t("clip.open")} · C`}
>
<Scissors />
</IconButton>
)}
{onRemoveFromList && (
<IconButton
variant="outline-danger"