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:
@@ -52,7 +52,7 @@ import {
|
||||
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
|
||||
import DownloadButton from "./DownloadButton";
|
||||
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 { api, type Video, type VideoStatus } from "../lib/api";
|
||||
import type { YTPlayer } from "../lib/youtube";
|
||||
import {
|
||||
@@ -371,6 +371,41 @@ export default function PlayerModal({
|
||||
const clipDockRef = useRef<HTMLDivElement>(null);
|
||||
const [clipDockH, setClipDockH] = useState(0);
|
||||
const clipDocked = clipOpen && (maximized || isFullscreen);
|
||||
// Its two places are two parents (the dock is IN the stage, the card's slot under it), and a panel
|
||||
// rendered in the other one is a new panel: the typed name, Loop and the focus were lost on every
|
||||
// F. So it renders once, into a host node of its own, and the host moves between the two.
|
||||
const [clipHost] = useState(() => document.createElement("div"));
|
||||
const clipSlotRef = useRef<HTMLDivElement>(null);
|
||||
// The control focused in the panel. A move is a remove and an insert, and the focus goes with
|
||||
// neither — leaving the big view, React has already removed the dock (and the host in it) before
|
||||
// the move below runs, so the focus is on <body> by then and only this still knows where it was.
|
||||
const clipFocusRef = useRef<HTMLElement | null>(null);
|
||||
useEffect(() => {
|
||||
const onIn = (e: FocusEvent) => {
|
||||
clipFocusRef.current = e.target instanceof HTMLElement ? e.target : null;
|
||||
};
|
||||
const onOut = (e: FocusEvent) => {
|
||||
// Left for somewhere else (not a removal, which fires nothing).
|
||||
if (e.relatedTarget instanceof Node && !clipHost.contains(e.relatedTarget))
|
||||
clipFocusRef.current = null;
|
||||
};
|
||||
clipHost.addEventListener("focusin", onIn);
|
||||
clipHost.addEventListener("focusout", onOut);
|
||||
return () => {
|
||||
clipHost.removeEventListener("focusin", onIn);
|
||||
clipHost.removeEventListener("focusout", onOut);
|
||||
};
|
||||
}, [clipHost]);
|
||||
useLayoutEffect(() => {
|
||||
const slot = clipDocked ? clipDockRef.current : clipSlotRef.current;
|
||||
if (!clipOpen || !slot || clipHost.parentElement === slot) return;
|
||||
const active = document.activeElement;
|
||||
const lost = active == null || active === document.body;
|
||||
const focused = clipHost.contains(active) ? active : lost ? clipFocusRef.current : null;
|
||||
slot.appendChild(clipHost);
|
||||
if (focused instanceof HTMLElement && clipHost.contains(focused))
|
||||
focused.focus({ preventScroll: true });
|
||||
}, [clipOpen, clipDocked, clipHost]);
|
||||
useLayoutEffect(() => {
|
||||
const el = clipDockRef.current;
|
||||
if (!clipDocked || !el) return;
|
||||
@@ -396,9 +431,14 @@ export default function PlayerModal({
|
||||
const iv = window.setInterval(read, 200);
|
||||
return () => window.clearInterval(iv);
|
||||
}, [clipOpen]);
|
||||
// The scissors that opened the panel gets the focus back when it closes.
|
||||
// The scissors gets the focus back when the panel closes: the one the user can see (the docked
|
||||
// bar's in the big views — the card's lies under the maximised stage, and a Space on it reopened the
|
||||
// panel), else the player itself (the card's is not there in a shut wide panel or for a video opened
|
||||
// from a link). Also when the panel went with its video (a queue step), not only when closed.
|
||||
const clipButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const clipDockButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const refocusClip = useRef(false);
|
||||
const clipOpenRef = useLatest(clipOpen);
|
||||
const closeClip = () => {
|
||||
refocusClip.current = true;
|
||||
setClip(null);
|
||||
@@ -408,10 +448,18 @@ export default function PlayerModal({
|
||||
setShutForClip(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (clipOpen || !refocusClip.current) return;
|
||||
// Read at the moment the panel closes: the view then decides, a later F is not a close.
|
||||
const refocusAfterClip = useEffectEvent(() => {
|
||||
const lost = document.activeElement == null || document.activeElement === document.body;
|
||||
if (!refocusClip.current && !lost) return;
|
||||
refocusClip.current = false;
|
||||
clipButtonRef.current?.focus();
|
||||
const big = maximized || isFullscreen;
|
||||
const target = big ? clipDockButtonRef.current : clipButtonRef.current;
|
||||
if (target) target.focus();
|
||||
else focusModal();
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!clipOpen) refocusAfterClip();
|
||||
}, [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 in the card, and in place of the docked bar when maximised; the
|
||||
@@ -588,6 +636,8 @@ export default function PlayerModal({
|
||||
// advance in the chosen direction, wrapping at the ends only when loop is "all" (a single-item
|
||||
// list repeats). Uses the live queue + settings via refs so it's correct from the bound handler.
|
||||
const advanceOnEnd = () => {
|
||||
// Marking a clip, the end is a place, not a finish: stepping on would drop the clip.
|
||||
if (clipOpenRef.current) return;
|
||||
const { autoMode: a, loopMode: l } = modeRef.current;
|
||||
if (l === "one") return replayCurrent();
|
||||
const { queue: q, index: i } = navRef.current;
|
||||
@@ -698,9 +748,8 @@ export default function PlayerModal({
|
||||
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)),
|
||||
markIn: () => markClip(markInAt),
|
||||
markOut: () => markClip(markOutAt),
|
||||
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
|
||||
@@ -955,9 +1004,7 @@ export default function PlayerModal({
|
||||
add-to-playlist's popover and download's Modal portal INTO the fullscreen element. */}
|
||||
{isFullscreen && <Toaster overlay order={overlayOrder} />}
|
||||
{clipDocked && (
|
||||
<div ref={clipDockRef} className="absolute inset-x-0 bottom-0 z-menu">
|
||||
{clipPanel}
|
||||
</div>
|
||||
<div ref={clipDockRef} className="absolute inset-x-0 bottom-0 z-menu" />
|
||||
)}
|
||||
{(maximized || isFullscreen) && !clipDocked && (
|
||||
<div
|
||||
@@ -989,7 +1036,12 @@ export default function PlayerModal({
|
||||
)}
|
||||
{/* Clip mode here: the clip panel takes this bar's place. */}
|
||||
{playerError == null && (
|
||||
<IconButton variant="overlay" onClick={openClip} label={t("clip.open")}>
|
||||
<IconButton
|
||||
ref={clipDockButtonRef}
|
||||
variant="overlay"
|
||||
onClick={openClip}
|
||||
label={t("clip.open")}
|
||||
>
|
||||
<Scissors />
|
||||
</IconButton>
|
||||
)}
|
||||
@@ -1086,7 +1138,8 @@ export default function PlayerModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!clipDocked && clipPanel}
|
||||
<div ref={clipSlotRef} />
|
||||
{clipPanel && createPortal(clipPanel, clipHost)}
|
||||
</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. */}
|
||||
|
||||
Reference in New Issue
Block a user