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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user