feat(clip): crop a clip to a frame drawn on the picture, and give the clip panel the room it needs
An optional crop in clip mode: a Crop toggle puts a frame on the picture (free, 16:9, 9:16 or 1:1; the whole picture stays the default). The frame is sent in fractions of the box it was drawn in, with that box's shape; the worker fits the real picture into the box by its display aspect (sample aspect and rotation aware), drops the part over the bars, and crops first in the filter chain. A frame covering the whole box is no crop at all, and one off the picture is refused by name. The crop frame is a ui primitive (CropFrame) shared with the video editor, keyboard-operable. YouTube: the wide side panel shuts while clipping and comes back after; maximised, the clip panel docks in the stage. Plex: the picture sits above the clip panel so the frame lies on it.
This commit is contained in:
@@ -49,7 +49,7 @@ import {
|
||||
type PlexPlayerPrefs,
|
||||
} from "../lib/plexPlayerPrefs";
|
||||
import { Ctrl, PanelHead } from "./plexPlayerControls";
|
||||
import ClipPanel from "./ClipPanel";
|
||||
import ClipPanel, { ClipCropFrame, type ClipCrop } from "./ClipPanel";
|
||||
import { DEFAULT_CLIP_S, markIn, markOut, openAt, type ClipRange } from "../lib/clipRange";
|
||||
import { PlexPlayerSettings } from "./PlexPlayerSettings";
|
||||
import { PlexStatsOverlay } from "./PlexStatsOverlay";
|
||||
@@ -294,19 +294,29 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// Clip mode: the range being marked, or null. Its panel takes the control band's place, and a
|
||||
// next item starts without one (reset against the item it was opened on).
|
||||
const [clip, setClip] = useState<ClipRange | null>(null);
|
||||
// The clip's crop frame over the picture, or null for the whole picture.
|
||||
const [crop, setCrop] = useState<ClipCrop | null>(null);
|
||||
const [clipFor, setClipFor] = useState(id);
|
||||
if (clipFor !== id) {
|
||||
setClipFor(id);
|
||||
setClip(null);
|
||||
setCrop(null);
|
||||
}
|
||||
const clipRef = useLatest(clip);
|
||||
const cropRef = useLatest(crop);
|
||||
// The scissors that opened the panel is gone while it is open; closing it gives the focus back.
|
||||
const clipButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const refocusClip = useRef(false);
|
||||
const closeClip = useCallback(() => {
|
||||
refocusClip.current = true;
|
||||
setClip(null);
|
||||
setCrop(null);
|
||||
}, []);
|
||||
// Escape and Back inside clip mode: a crop turns off first, then the panel closes.
|
||||
const stepOutOfClip = useCallback(() => {
|
||||
if (cropRef.current) setCrop(null);
|
||||
else closeClip();
|
||||
}, [cropRef, closeClip]);
|
||||
useEffect(() => {
|
||||
if (clip != null || !refocusClip.current) return;
|
||||
refocusClip.current = false;
|
||||
@@ -626,6 +636,36 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// and play intent and read its own clock: the element's starts at the stream's start (a transcode
|
||||
// begins at the resume point), and a seek outside the stream restarts it.
|
||||
const [videoEl, setVideoEl] = useState<HTMLVideoElement | null>(null);
|
||||
// The picture's shape, for the clip's crop frame: the element's own once it knows it (a stream
|
||||
// switch can change it), 16:9 until then.
|
||||
const [picture, setPicture] = useState(16 / 9);
|
||||
useEffect(() => {
|
||||
if (!videoEl) return;
|
||||
const read = () => {
|
||||
if (videoEl.videoWidth > 0 && videoEl.videoHeight > 0)
|
||||
setPicture(videoEl.videoWidth / videoEl.videoHeight);
|
||||
};
|
||||
read();
|
||||
videoEl.addEventListener("loadedmetadata", read);
|
||||
videoEl.addEventListener("resize", read);
|
||||
return () => {
|
||||
videoEl.removeEventListener("loadedmetadata", read);
|
||||
videoEl.removeEventListener("resize", read);
|
||||
};
|
||||
}, [videoEl]);
|
||||
// In clip mode the picture stands above the clip panel rather than under it, so the crop frame
|
||||
// reaches every edge of it: the panel's height, measured (it wraps with the window and the text).
|
||||
const [clipBand, setClipBand] = useState(0);
|
||||
const clipOpen = clip != null;
|
||||
useLayoutEffect(() => {
|
||||
const bar = bottomBarRef.current;
|
||||
if (!clipOpen || !bar) return;
|
||||
const measure = () => setClipBand(bar.offsetHeight);
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(bar);
|
||||
return () => ro.disconnect();
|
||||
}, [clipOpen]);
|
||||
useMediaSession(
|
||||
videoEl,
|
||||
detail && {
|
||||
@@ -1064,7 +1104,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
return;
|
||||
ev.preventDefault();
|
||||
// The clip panel is the player's own too, and closes before the player does.
|
||||
if (clipRef.current) closeClip();
|
||||
if (clipRef.current) stepOutOfClip();
|
||||
else back("key");
|
||||
},
|
||||
escape: (ev) => {
|
||||
@@ -1075,7 +1115,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) closeClip();
|
||||
if (clipRef.current) stepOutOfClip();
|
||||
else back("escape");
|
||||
},
|
||||
});
|
||||
@@ -1109,6 +1149,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
statsOpenRef,
|
||||
clipRef,
|
||||
closeClip,
|
||||
stepOutOfClip,
|
||||
absRef,
|
||||
durationRef,
|
||||
]);
|
||||
@@ -1191,6 +1232,11 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// (a 720p stream → 1280×720) and never scales it UP, so in fullscreen it sat tiny and centred.
|
||||
// `touch-manipulation`: a double-tap is the skip, never the browser's zoom.
|
||||
className="w-full h-full object-contain touch-manipulation"
|
||||
// In clip mode the picture stands above the clip panel: shorter, and at the top of the player,
|
||||
// whose flex box would otherwise centre it and leave its bottom under the panel.
|
||||
style={
|
||||
clipOpen ? { height: `calc(100% - ${clipBand}px)`, alignSelf: "flex-start" } : undefined
|
||||
}
|
||||
onClick={onPictureClick}
|
||||
playsInline
|
||||
>
|
||||
@@ -1211,6 +1257,14 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
/>
|
||||
)}
|
||||
</video>
|
||||
{crop && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 top-0"
|
||||
style={{ height: `calc(100% - ${clipBand}px)` }}
|
||||
>
|
||||
<ClipCropFrame picture={picture} crop={crop} onCrop={setCrop} />
|
||||
</div>
|
||||
)}
|
||||
<PlexSubtitles
|
||||
video={videoEl}
|
||||
pos={prefs.subPos}
|
||||
@@ -1403,6 +1457,9 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
onChange={setClip}
|
||||
duration={duration}
|
||||
current={abs}
|
||||
picture={picture}
|
||||
crop={crop}
|
||||
onCrop={setCrop}
|
||||
onSeek={(time, final) => {
|
||||
if (final) seekTo(time);
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user