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
+28 -20
View File
@@ -50,7 +50,7 @@ import {
} from "../lib/plexPlayerPrefs";
import { Ctrl, PanelHead } from "./plexPlayerControls";
import ClipPanel, { ClipCropFrame, type ClipCrop } from "./ClipPanel";
import { DEFAULT_CLIP_S, markIn, markOut, openAt, type ClipRange } from "../lib/clipRange";
import { markInAt, markOutAt, openAt, type ClipRange } from "../lib/clipRange";
import { PlexPlayerSettings } from "./PlexPlayerSettings";
import { PlexStatsOverlay } from "./PlexStatsOverlay";
import { createFragMeter } from "../lib/plexStats";
@@ -833,6 +833,9 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
seekTo,
videoRef,
reportWatchedError,
// Marking a clip, the viewer is where they want to be: a skip would carry the player off the
// range (an intro clip skipped past its Out, the credits' leaving for the next item).
suspended: clip != null,
});
// Download the ORIGINAL physical file (no re-encode/repackage). One user gesture, direct link.
@@ -902,6 +905,8 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// Only a genuine play-to-the-end counts. A next episode that failed to load never played
// (absRef 0) — its stray 'ended' must not mark anything watched or cascade another advance.
if (absRef.current <= 0) return;
// Marking a clip, the end is a place, not a finish: closing or moving on would drop the clip.
if (clipRef.current) return;
const watchedWrite = api.plexSetState(id, "watched");
watchedWrite.catch(reportWatchedError);
// Whatever follows (the up-next card, the next item, closing) leaves a FINISHED item — unless
@@ -918,7 +923,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
};
video.addEventListener("ended", onEnded);
return () => video.removeEventListener("ended", onEnded);
}, [id, nextId, go, onClose, finishProgress, reportWatchedError, absRef, upNextPrefRef]);
}, [id, nextId, go, onClose, finishProgress, reportWatchedError, absRef, upNextPrefRef, clipRef]);
// Up-next card actions: advance = go to the next item (it was already marked watched on 'ended');
// cancel = dismiss and stay on the finished frame. Loading a new item closes any lingering card.
@@ -937,21 +942,25 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// The ONE Back cascade for the player's own ways of saying "back": the on-screen arrow, ⌫ and
// Escape. It peels off what is layered over the video — the auto-skip countdown, then the open
// panel, then the stats readout — and only then leaves the player. The mouse/browser Back peels
// the layers the viewer OPENED one per press, the one opened last first (each holds its own history
// entry, see BackClose below); the ones that open by themselves have none, so Back leaves past
// them. The shortcuts sheet is not in it: a Modal above us, with its own Escape layer and
// Back-to-close. Leaving closes whatever is still up first (`useHistorySubview`'s back).
// panel, then the stats readout, then the clip being marked (its crop first) — and only then leaves
// the player. The mouse/browser Back peels the layers the viewer OPENED one per press, the one
// opened last first (each holds its own history entry, see BackClose below); the ones that open by
// themselves have none, so Back leaves past them. The shortcuts sheet is not in it: a Modal above
// us, with its own Escape layer and Back-to-close. Leaving closes whatever is still up first
// (`useHistorySubview`'s back).
// - The on-screen arrow is a click on "leave", not a cancel, so it does not stop a countdown.
// - Escape in fullscreen leaves fullscreen (the browser's own Escape) and not the player.
// - The arrow leaves past a clip as it does past a panel; the keys step out of it.
const back = useCallback(
(from: "arrow" | "key" | "escape") => {
if (from !== "arrow" && skipProgressRef.current != null) cancelAutoSkipRef.current();
else if (panelRef.current !== "none") setPanel("none");
else if (statsOpenRef.current) setStatsOpen(false);
else if (from !== "escape" || !fullscreenElement()) onClose();
else if (from === "escape" && fullscreenElement()) return;
else if (from !== "arrow" && clipRef.current) stepOutOfClip();
else onClose();
},
[onClose, panelRef, statsOpenRef, skipProgressRef, cancelAutoSkipRef],
[onClose, panelRef, statsOpenRef, skipProgressRef, cancelAutoSkipRef, clipRef, stepOutOfClip],
);
// The mouse/browser Back closes the layer whose history entry it popped (see BackClose below).
const closeStats = useCallback(() => setStatsOpen(false), []);
@@ -1072,19 +1081,20 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
},
info: () => togglePanel("info"),
stats: () => setStatsOpen((v) => !v),
// A range needs the item's length (unknown while its detail loads, or never given).
clip: () => {
if (clipRef.current) closeClip();
else setClip(openAt(absRef.current, durationRef.current));
else if (durationRef.current > 0) setClip(openAt(absRef.current, durationRef.current));
},
markIn: () => {
const t = absRef.current;
const d = durationRef.current;
setClip((c) => markIn(c ?? openAt(t, d), t, d));
if (d > 0) setClip((c) => markInAt(c, t, d));
},
markOut: () => {
const t = absRef.current;
const d = durationRef.current;
setClip((c) => markOut(c ?? openAt(Math.max(0, t - DEFAULT_CLIP_S), d), t, d));
if (d > 0) setClip((c) => markOutAt(c, t, d));
},
back: (ev) => {
// HTPC-style "stop & go back" to wherever the player was opened from — mirrors the mouse
@@ -1097,15 +1107,14 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// ⌫ stopped closing them), then naming just the sheet and palette (the second fix, the third
// bug — the app-wide ErrorDialog is neither, so ⌫ tore the player down under it).
// The rule that holds: the player's own panels answer first, because `back`'s cascade is
// what closes them; anything else layered above us takes the key.
const ownPanelOpen =
panelRef.current !== "none" || statsOpenRef.current || clipRef.current != null;
// what closes them; anything else layered above us takes the key. The clip panel pushes no
// layer, so with it open a layer above is never the player's own (a refused Cut opens the
// ErrorDialog right over it): the clip is in the cascade, behind that layer.
const ownPanelOpen = panelRef.current !== "none" || statsOpenRef.current;
if (!ownPanelOpen && layerIdRef.current != null && anyLayerAbove(layerIdRef.current))
return;
ev.preventDefault();
// The clip panel is the player's own too, and closes before the player does.
if (clipRef.current) stepOutOfClip();
else back("key");
back("key");
},
escape: (ev) => {
// Defer to any layer opened above the player (Modal or anchored popover) — let it take the
@@ -1115,8 +1124,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// one and its close can pop it off the stack synchronously, so `anyLayerAbove` alone misses it.
if (escapeHandled(ev)) return;
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
if (clipRef.current) stepOutOfClip();
else back("escape");
back("escape");
},
});
};