fix(clip): clip mode keeps its range, its focus and its keys through the player's own moves

- A mark on the very end seeks a moment short of it; while a clip is marked the end of the item
  neither closes the player nor steps the queue, and Plex's auto-skip waits.
- Plex: Escape and Back peel what is over the picture (info, up-next, the countdown) before the
  clip; Back under a dialog is the dialog's; no range opens while the length is unknown.
- YouTube: the focus goes back to the scissors the user can see, else the player; the panel keeps
  its name, Loop and focus when the view is maximised or restored under it.
- A typed time stays in its field until taken, then shows where it landed; unreadable stays marked
  invalid; a decimal comma is read (ui/TimecodeField, the editor's fields too).
- RangeTrack: a drag is its pointer's, a cancelled drag reports final, an adornment sits beside the
  slider, a caller-chosen focus ring (amber on the clip strip). CropFrame: a drag is its pointer's.
- The editor's remove-cut is a real button at the cut's foot; a segment's keep toggle sits over the
  cut's hit area.
This commit is contained in:
2026-10-10 15:35:21 +02:00
parent b4bf71ea64
commit c489b60c8b
18 changed files with 607 additions and 121 deletions
+29 -27
View File
@@ -3,12 +3,13 @@ import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Crop, Crosshair, Maximize, Minus, Plus, Repeat, Scissors, X } from "lucide-react";
import { api, type ClipRequest } from "../lib/api";
import { DISPLAY_NAME_MAX } from "../lib/api/downloads";
import { invalidateDownloads } from "../lib/downloads";
import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors";
import { navigateTo } from "../lib/nav";
import { log } from "../lib/log";
import { formatTimecode, parseTimecode } from "../lib/timecode";
import { formatTimecode } from "../lib/timecode";
import { handleBounds, markIn, markOut, zoomWindow, type ClipRange } from "../lib/clipRange";
import { fitRatio, fracRatio, WHOLE, type Frac } from "../lib/cropGeometry";
import { RangeTrack, RulerTicks, type RangeWindow } from "./ui/RangeTrack";
@@ -17,6 +18,7 @@ import SegmentedToggle from "./ui/SegmentedToggle";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Input } from "./ui/Input";
import { TimecodeField } from "./ui/TimecodeField";
// The clip panel: marks a range of what is playing and cuts it into a download of its own. It lives
// IN the player (the YouTube card under the video, the Plex player's control band), so the video
@@ -81,9 +83,15 @@ interface Props {
}
const NUDGE_S = 0.1;
// How far short of the item's end a seek stops: a mark on the very end moves the player to its last
// moment, not past it — a player put at its end ENDS (a Plex film closes, a YouTube queue steps on)
// and takes the clip with it.
const LAST_MOMENT_S = 0.25;
// On the always-dark chrome the ends are white (a light theme's accent is a dark blue there, which
// all but vanished on the bar) and the playhead amber, so an end and the playhead are never confused.
// A focused end is ringed in amber too, as the crop frame is: the accent ring was under 3:1 there.
const PLAYHEAD = "bg-warn";
const END_RING = "focus-visible:outline-warn";
export default function ClipPanel({
src,
@@ -138,15 +146,19 @@ export default function ClipPanel({
if (looping && current >= clip.end) restart();
}, [looping, current, clip.end]);
const seek = (time: number, final: boolean) =>
onSeek(Math.max(0, Math.min(time, duration - LAST_MOMENT_S)), final);
const moveIn = (time: number, final: boolean) => {
setFrozen(final ? null : (frozen ?? view));
onChange(markIn(clip, time, duration));
onSeek(time, final);
const next = markIn(clip, time, duration);
onChange(next);
seek(next.start, final);
};
const moveOut = (time: number, final: boolean) => {
setFrozen(final ? null : (frozen ?? view));
onChange(markOut(clip, time, duration));
onSeek(time, final);
const next = markOut(clip, time, duration);
onChange(next);
seek(next.end, final);
};
const body: ClipRequest = {
@@ -210,7 +222,7 @@ export default function ClipPanel({
duration={duration}
playhead={current}
playheadClassName={PLAYHEAD}
onSeek={(time, final) => final && onSeek(time, true)}
onSeek={(time, final) => final && seek(time, true)}
className="h-5 flex-1 cursor-pointer rounded-sm bg-white/15"
regions={[
{
@@ -233,7 +245,8 @@ export default function ClipPanel({
view={view}
playhead={current}
playheadClassName={PLAYHEAD}
onSeek={onSeek}
onSeek={seek}
handleRingClassName={END_RING}
className="h-14 cursor-pointer rounded-md bg-white/10 text-white/45"
background={
<RulerTicks view={view} format={(s) => formatTimecode(s, hours).replace(/\.\d$/, "")} />
@@ -276,7 +289,7 @@ export default function ClipPanel({
<MarkField
label={t("clip.in")}
value={clip.start}
text={tc(clip.start)}
hours={hours}
onSet={(v) => moveIn(v, true)}
nudge={NUDGE_S}
hereLabel={t("clip.inHere")}
@@ -285,7 +298,7 @@ export default function ClipPanel({
<MarkField
label={t("clip.out")}
value={clip.end}
text={tc(clip.end)}
hours={hours}
onSet={(v) => moveOut(v, true)}
nudge={NUDGE_S}
hereLabel={t("clip.outHere")}
@@ -353,7 +366,7 @@ export default function ClipPanel({
size="sm"
className="min-w-48 flex-1"
value={name}
maxLength={255}
maxLength={DISPLAY_NAME_MAX}
onChange={(e) => setName(e.target.value)}
placeholder={t("clip.namePlaceholder")}
aria-label={t("clip.name")}
@@ -374,7 +387,7 @@ export default function ClipPanel({
function MarkField({
label,
value,
text,
hours,
onSet,
nudge,
hereLabel,
@@ -382,17 +395,13 @@ function MarkField({
}: {
label: string;
value: number;
text: string;
hours: boolean;
onSet: (v: number) => void;
nudge: number;
hereLabel: string;
onHere: () => void;
}) {
const { t } = useTranslation();
const commit = (raw: string) => {
const v = parseTimecode(raw);
if (v != null && Math.abs(v - value) > 1e-3) onSet(v);
};
return (
<div className="flex items-center gap-1" role="group" aria-label={label}>
<span className="mr-1 text-muted">{label}</span>
@@ -404,20 +413,13 @@ function MarkField({
>
<Minus />
</IconButton>
<Input
// Re-created when the mark moves elsewhere, so the field shows it; a typed draft lives in the
// field until Enter or blur.
key={text}
<TimecodeField
size="sm"
className="w-24 text-center tabular-nums"
defaultValue={text}
value={value}
hours={hours}
aria-label={label}
onBlur={(e) => commit(e.target.value)}
onKeyDown={(e) => {
if (e.key !== "Enter") return;
e.preventDefault();
commit(e.currentTarget.value);
}}
onCommit={onSet}
/>
<IconButton
variant="overlay"