From 3438aeb695d5f5943c5d860b49b8b7e6fcbdfceb Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 10 Oct 2026 01:14:31 +0200 Subject: [PATCH 01/23] fix(player): the player's own focus grabs leave a tabbed-to control alone Turning ready, the pointer leaving the video, the window regaining focus, a wheel over it and leaving fullscreen each put the focus back on the card unconditionally, so a viewer who had tabbed to a control right after opening started over from the first button on the next Tab. They now take it back only when it sits in the iframe or off the card. --- .../src/components/PlayerModal.dom.test.tsx | 42 ++++++++++++++++++- frontend/src/components/PlayerModal.tsx | 27 +++++++++--- .../lib/playerModalHooks.apiload.dom.test.tsx | 2 +- .../playerModalHooks.progress.dom.test.tsx | 2 +- frontend/src/lib/playerModalHooks.ts | 30 ++++++------- 5 files changed, 80 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/PlayerModal.dom.test.tsx b/frontend/src/components/PlayerModal.dom.test.tsx index 66fc8986..8bc2ea07 100644 --- a/frontend/src/components/PlayerModal.dom.test.tsx +++ b/frontend/src/components/PlayerModal.dom.test.tsx @@ -50,6 +50,8 @@ beforeAll(() => { // --- the fake IFrame API ------------------------------------------------------------------------ const clock = { current: 0, duration: 3000 }; const started: { videoId: string; start: number | undefined }[] = []; +// The last player's event handlers, so a test can say "the player is ready" when it wants to. +let playerEvents: { onReady?: () => void } = {}; const base = { getCurrentTime: () => clock.current, getDuration: () => clock.duration, @@ -61,8 +63,12 @@ const fakePlayer = new Proxy(base, { get: (t, k) => (k in t ? t[k as keyof typeof t] : k === "then" ? undefined : () => undefined), }); window.YT = { - Player: function (_el: unknown, opts: { videoId: string; playerVars: { start?: number } }) { + Player: function ( + _el: unknown, + opts: { videoId: string; playerVars: { start?: number }; events?: { onReady?: () => void } }, + ) { started.push({ videoId: opts.videoId, start: opts.playerVars.start }); + playerEvents = opts.events ?? {}; return fakePlayer; } as never, }; @@ -331,6 +337,40 @@ describe("PlayerModal — the dialog contract", () => { } }); + // The card takes the focus back on its own when it may have slipped into the iframe: the player + // turning ready, the pointer leaving the video, the window regaining focus. None of those may take + // it off a control the viewer tabbed to, or their next Tab starts over from the first button. + it.each([ + ["the player turns ready", () => playerEvents.onReady?.()], + [ + "the pointer leaves the video", + () => { + fireEvent.mouseLeave(document.querySelector(".player-stage")!); + }, + ], + [ + "the window regains focus", + () => { + fireEvent.focus(window); + }, + ], + ])("leaves the focus on a control the viewer tabbed to when %s", async (_why, happen) => { + open(); + await waitFor(() => expect(playerEvents.onReady).toBeTypeOf("function")); + const control = within(screen.getByTestId("player-modal")).getByTestId("player-close"); + control.focus(); + act(happen); + expect(document.activeElement).toBe(control); + }); + + it("takes the focus back from the iframe when the player turns ready", async () => { + open(); + await waitFor(() => expect(playerEvents.onReady).toBeTypeOf("function")); + (document.activeElement as HTMLElement).blur(); + act(() => playerEvents.onReady?.()); + expect(screen.getByTestId("player-modal").contains(document.activeElement)).toBe(true); + }); + it("keeps Tab inside the player", () => { open(); const dialog = screen.getByTestId("player-modal"); diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 9d81e669..6001f090 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -242,6 +242,22 @@ export default function PlayerModal({ // useYtNativeControls, useYtBigView) where a fresh identity each render would spread into their // dep lists too. const focusModal = useCallback(() => cardRef.current?.focus(), []); + // What the player does on its own (turning ready, the pointer leaving the video, the window + // regaining focus, a wheel over it, leaving fullscreen) takes the focus back only when it may have + // slipped into the iframe or off the card. Taking it off a control the viewer tabbed to restarted + // their Tab from the first button. + const reclaimFocus = useCallback(() => { + const card = cardRef.current; + const active = document.activeElement; + if ( + card && + active instanceof HTMLElement && + active.tagName !== "IFRAME" && + card.contains(active) + ) + return; + card?.focus(); + }, []); // The card fits the window, video AND what sits under it (the queue strip, title, channel row): the // video is 16:9 at the card's width, so on a short window (133% zoom on a 1080p screen: 685px) a // full-width card ran past the bottom and cut its channel row. `below` is that chrome's @@ -300,12 +316,12 @@ export default function PlayerModal({ const { volumeUi, reapplyStored, nudgeVolume } = useYtVolume({ playerRef, dialogRef, - focusModal, + reclaimFocus, }); const { nativeControls, setNativeControls } = useNativeControlsYield({ playerRef, stageRef, - focusModal, + reclaimFocus, }); const { isFullscreen, @@ -315,7 +331,7 @@ export default function PlayerModal({ toggleTrueFullscreen, exitBigView, setMaximized, - } = useYtBigView({ stageRef, playerRef, focusModal }); + } = useYtBigView({ stageRef, playerRef, reclaimFocus }); // The player can navigate to other videos (YouTube links in a description). The // currently-playing id may differ from the active item. @@ -550,7 +566,8 @@ export default function PlayerModal({ // Keyboard shortcuts. The chords live in the shared keymap (`ytPlayer` // scope) so the help sheet lists them; only the behaviour behind each id is here. // These fire only while focus is on our page, not inside the cross-origin player iframe — so - // we focus the modal card on open and again on player-ready (autoplay can grab focus). + // we focus the modal card on open, and take it back on player-ready (autoplay can grab focus) + // unless the viewer has since tabbed to a control (`reclaimFocus`). // // The handler itself is a useEffectEvent (React 19.2+): it is bound ONCE per player, but it reads // whatever is current at the moment a key arrives, which is what the disabled dep list used to @@ -654,7 +671,7 @@ export default function PlayerModal({ setPlayerError, setLiveData, reapplyStored, - focusModal, + reclaimFocus, advanceOnEnd, sessionPosRef, qc, diff --git a/frontend/src/lib/playerModalHooks.apiload.dom.test.tsx b/frontend/src/lib/playerModalHooks.apiload.dom.test.tsx index d2a3d327..de4adb42 100644 --- a/frontend/src/lib/playerModalHooks.apiload.dom.test.tsx +++ b/frontend/src/lib/playerModalHooks.apiload.dom.test.tsx @@ -39,7 +39,7 @@ function mountPlayer(setPlayerError: (code: number | null) => void) { setPlayerError, setLiveData: () => {}, reapplyStored: () => {}, - focusModal: () => {}, + reclaimFocus: () => {}, advanceOnEnd: () => {}, sessionPosRef: { current: new Map() }, qc: new QueryClient(), diff --git a/frontend/src/lib/playerModalHooks.progress.dom.test.tsx b/frontend/src/lib/playerModalHooks.progress.dom.test.tsx index 6d45f7ec..dbc10ceb 100644 --- a/frontend/src/lib/playerModalHooks.progress.dom.test.tsx +++ b/frontend/src/lib/playerModalHooks.progress.dom.test.tsx @@ -81,7 +81,7 @@ function mountPlayer(qc: QueryClient, sessionPos: Map) { setPlayerError: () => {}, setLiveData: () => {}, reapplyStored: () => {}, - focusModal: () => {}, + reclaimFocus: () => {}, advanceOnEnd: () => {}, sessionPosRef: { current: sessionPos }, qc, diff --git a/frontend/src/lib/playerModalHooks.ts b/frontend/src/lib/playerModalHooks.ts index e99411a7..1320f597 100644 --- a/frontend/src/lib/playerModalHooks.ts +++ b/frontend/src/lib/playerModalHooks.ts @@ -81,11 +81,11 @@ function loadYouTubeApi(): Promise { export function useYtVolume({ playerRef, dialogRef, - focusModal, + reclaimFocus, }: { playerRef: RefObject; dialogRef: RefObject; - focusModal: () => void; + reclaimFocus: () => void; }): { /** Level (0–100) to flash in the on-player overlay, or null when hidden. */ volumeUi: number | null; @@ -168,7 +168,7 @@ export function useYtVolume({ ) return; e.preventDefault(); - focusModal(); // re-grab focus if it had slipped into the native controls + reclaimFocus(); // re-grab the focus if it had slipped into the native controls nudgeVolume(e.deltaY < 0 ? 5 : -5); }); useEffect(() => { @@ -204,11 +204,11 @@ export function useYtVolume({ export function useNativeControlsYield({ playerRef, stageRef, - focusModal, + reclaimFocus, }: { playerRef: RefObject; stageRef: RefObject; - focusModal: () => void; + reclaimFocus: () => void; }): { nativeControls: boolean; setNativeControls: (on: boolean) => void; @@ -221,7 +221,7 @@ export function useNativeControlsYield({ }); const rearmOurControls = useEffectEvent(() => { setNativeControls(false); - focusModal(); + reclaimFocus(); }); useEffect(() => { // activeElement settles on the tick after blur, so defer the check. @@ -249,11 +249,11 @@ export function useNativeControlsYield({ export function useYtBigView({ stageRef, playerRef, - focusModal, + reclaimFocus, }: { stageRef: RefObject; playerRef: RefObject; - focusModal: () => void; + reclaimFocus: () => void; }): { isFullscreen: boolean; maximized: boolean; @@ -299,7 +299,7 @@ export function useYtBigView({ const ours = !!fsEl && (fsEl === stageRef.current || fsEl === frame); isFullscreenRef.current = ours; // read by the once-bound keydown handler setIsFullscreen(ours); // drives the overlay's pointer-events - if (!fsEl) focusModal(); + if (!fsEl) reclaimFocus(); }); useEffect(() => { return onFullscreenChange(() => syncFullscreen()); // both spellings — see lib/fullscreen @@ -348,7 +348,7 @@ export function useYtProgress({ setPlayerError, setLiveData, reapplyStored, - focusModal, + reclaimFocus, advanceOnEnd, sessionPosRef, qc, @@ -364,7 +364,7 @@ export function useYtProgress({ setPlayerError: (code: number | null) => void; setLiveData: (d: { title?: string; author?: string } | null) => void; reapplyStored: () => void; - focusModal: () => void; + reclaimFocus: () => void; advanceOnEnd: () => void; // Positions this player session has actually STORED, per video id. Written from the server's // answer, read back by the modal's resume so stepping next→prev inside one session does not @@ -382,7 +382,7 @@ export function useYtProgress({ setPlayerError, setLiveData, reapplyStored, - focusModal, + reclaimFocus, advanceOnEnd, qc, })); @@ -396,7 +396,7 @@ export function useYtProgress({ setPlayerError, setLiveData, reapplyStored, - focusModal, + reclaimFocus, advanceOnEnd, qc, } = itemSnapshot(); @@ -458,7 +458,7 @@ export function useYtProgress({ }, events: { onReady: () => { - focusModal(); + reclaimFocus(); reapplyStored(); }, onStateChange: (e: YTPlayerEvent) => { @@ -554,7 +554,7 @@ export function useYtProgress({ // and refs (which never change). // // The render values the body uses — `resumeAt`, `setWatched`, `advanceOnEnd`, `reapplyStored`, - // `focusModal`, `qc`, … — are a PER-ITEM SNAPSHOT, taken once through `itemSnapshot` when this + // `reclaimFocus`, `qc`, … — are a PER-ITEM SNAPSHOT, taken once through `itemSnapshot` when this // player is built: they describe the video it was built for, and re-reading them mid-session // would be the bug, not the fix. `setWatched` is the clearest case: bound to the item, it lets // this cleanup's late auto-mark land on the item being LEFT, which the modal then ignores. What From b16cf7668c32839715a53041d52d558f0c9f9649 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 10 Oct 2026 02:08:43 +0200 Subject: [PATCH 02/23] fix(downloads): YouTube downloads reach their profile's height again The primary player client, android_vr, was still offered 1080p and 2160p, but every fetch of those formats died with HTTP 403: it now needs a stream PO token the bgutil sidecar cannot mint. The fallback set then served 360p only, so every download finished at 360p without failing. tv_simply is offered the full ladder and its fetch completes (measured: 1080p, and a localdev download through the pipeline reached the default profile's 720p). yt-dlp 2026.8.19 marks the clients that need such a token, so it stops fetching into the 403. --- backend/app/config.py | 15 +++++++++------ backend/app/downloads/adapters.py | 3 ++- backend/requirements.lock | 5 +++-- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index b7680444..8edf93aa 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -181,15 +181,18 @@ class Settings(BaseSettings): # for YouTube Proof-of-Origin tokens (bypasses bot-detection + unlocks high-quality # formats). Empty disables it (yt-dlp still works, but may hit "confirm you're not a bot"). download_pot_base_url: str = "http://bgutil-pot:4416" - # yt-dlp player clients (comma-separated). PRIMARY = android_vr: high-quality DASH, no PO - # token / JS runtime needed, reliable for normal use. On a bot/DRM/format failure (YouTube's - # per-client responses are flaky, and android_vr can get bot-flagged under heavy load) the - # worker retries with the FALLBACK set — the POT-capable web clients (web needs the token + - # Deno) plus android for audio. Both admin-tunable when YouTube shifts what works. + # yt-dlp player clients (comma-separated). PRIMARY = tv_simply: measured 2026-10-10 as the one + # client that is offered 1080p+ AND finishes the fetch, its stream token minted by the bgutil + # sidecar. android_vr (the primary until then) was still offered 2160p, but every fetch died + # with HTTP 403 — it now needs a stream PO token the sidecar cannot mint — and the fallback + # below served 360p only, so every download came out at 360p without failing. On a + # bot/DRM/format failure the worker retries with the FALLBACK set — the POT-capable web clients + # (web needs the token + Deno) plus android, whose 360p is the last resort. Both admin-tunable + # (env) when YouTube shifts what works. # A SIGNED-IN download (the user added YouTube cookies) keeps only the cookie-capable clients # of these two sets — yt-dlp drops the rest, so android_vr / android / ios never run there. # See adapters.YouTubeAdapter._COOKIE_CAPABLE. - download_player_clients: str = "android_vr" + download_player_clients: str = "tv_simply" download_player_clients_fallback: str = "web_safari,web,android" @staticmethod diff --git a/backend/app/downloads/adapters.py b/backend/app/downloads/adapters.py index 5c8e4150..7878bffe 100644 --- a/backend/app/downloads/adapters.py +++ b/backend/app/downloads/adapters.py @@ -106,7 +106,8 @@ class YouTubeAdapter(SourceAdapter): # `player_client=android_vr` yt-dlp logs `Skipping client "android_vr" since it does not support # cookies`, drops it, and the request that is left with no client fails with "Requested format is # not available" — which is already a retriable marker above, so the cost is a wasted attempt - # rather than a hard failure. Our primary set is android_vr ALONE, so without this filter every + # rather than a hard failure. Our primary set is tv_simply ALONE, which cannot carry cookies either + # (measured on yt-dlp 2026.08.19: `SUPPORTS_COOKIES` False), so without this filter every # signed-in download would burn its first attempt that way before reaching the POT-backed web # fallback the cookie exists for. The list is yt-dlp's own INNERTUBE_CLIENTS[...] # ["SUPPORTS_COOKIES"], pinned by test_adapters against the installed yt-dlp, so a version bump diff --git a/backend/requirements.lock b/backend/requirements.lock index de7bbd57..0d6f8dac 100644 --- a/backend/requirements.lock +++ b/backend/requirements.lock @@ -4,7 +4,8 @@ # The prod Dockerfile + Dockerfile.test install THIS file; the gate's pip-audit (scripts/check.sh) # scans it, blocking. A security fix may be ONE pin bumped in place instead of a full # regeneration, which would move every other package too — then `pip install -r requirements.lock && pip check` must -# pass. In place so far: urllib3 2.7.0 -> 2.8.0 (PYSEC-2026-4175/4176/4177). +# pass. In place so far: urllib3 2.7.0 -> 2.8.0 (PYSEC-2026-4175/4176/4177); yt-dlp 2026.7.4 -> 2026.8.19 +# (it marks the clients that need a stream PO token, so the downloader stops fetching into a 403). APScheduler==3.11.3 Authlib==1.7.2 Mako==1.4.1 @@ -62,4 +63,4 @@ uvloop==0.22.1 watchfiles==1.2.0 websockets==17.0.1 yt-dlp-ejs==0.8.0 -yt-dlp==2026.7.4 +yt-dlp==2026.8.19 From 14598ec320123f584e0be985b9ef57398d393428 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 10 Oct 2026 02:31:38 +0200 Subject: [PATCH 03/23] feat(downloads): ad-hoc clips cut straight from a YouTube video or a Plex item POST /api/downloads/clip takes a source (a YouTube id or a Plex rating key), a range of at most ten minutes and, for Plex, the audio track being heard. The clip becomes a per-user asset in the library like an editor clip, shareable as soon as it is ready. A YouTube clip fetches only its range (yt-dlp download_ranges: 3-7 s whatever the video's length); a Plex clip reads the library file in place. Both then go through one re-encode: frame-exact at the marks, H.264 at most 1080p with HDR tonemapped to SDR, stereo AAC, no chapter track, .mp4. Job kinds are a JobKind StrEnum now, and Re-download refuses every kind but a download. --- backend/app/downloads/clip.py | 169 +++++++ backend/app/downloads/pipeline.py | 170 ++++++- backend/app/downloads/quota.py | 4 +- backend/app/downloads/runner.py | 9 +- backend/app/downloads/service.py | 114 ++++- backend/app/downloads/status.py | 17 + backend/app/downloads/upload.py | 4 +- backend/app/routes/downloads.py | 40 +- backend/tests/test_clip.py | 423 ++++++++++++++++++ backend/tests/test_phone_safe_contract.py | 11 +- backend/tests/test_upload.py | 5 +- frontend/src/components/downloadRow.tsx | 2 +- .../src/components/useDownloadActions.tsx | 2 + frontend/src/lib/api/downloads.ts | 8 +- frontend/src/lib/downloadActions.test.ts | 1 + frontend/src/lib/downloadActions.ts | 2 +- frontend/src/lib/downloadFailure.test.ts | 10 + frontend/src/lib/downloadFailure.ts | 4 +- 18 files changed, 963 insertions(+), 32 deletions(-) create mode 100644 backend/app/downloads/clip.py create mode 100644 backend/tests/test_clip.py diff --git a/backend/app/downloads/clip.py b/backend/app/downloads/clip.py new file mode 100644 index 00000000..e0340059 --- /dev/null +++ b/backend/app/downloads/clip.py @@ -0,0 +1,169 @@ +"""Ad-hoc clips: a marked range of a YouTube video or a Plex item, cut into a new per-user asset. + +A clip never needs the whole source first. A YouTube clip fetches only its range (yt-dlp's +`download_ranges`; measured: 3-7 s whether the video runs four minutes or an hour, and the fetched +range starts on the marked frame), and a Plex clip reads the library file in place. Either way the +range then goes through ONE re-encode built here, so every clip has the same shape whatever its +source: frame-exact at both ends, H.264 at most 1080p (an HDR source tonemapped to SDR — kept as +10-bit PQ it played washed out), stereo AAC, in an .mp4 that plays on any phone. + +The spec is stored on the job (`DownloadJob.edit_spec`) and is the clip's whole identity: its +signature is the asset's cache key, so marking the same range again is an instant cache hit, and a +different range or audio track is a different clip. +""" + +import hashlib +import json +import math +import re +from pathlib import Path + +from app.downloads.edit import AAC_ARGS, EVEN_FRAME, INPUT_GUARD, X264_ARGS + +SOURCES = ("youtube", "plex") +# Owner's cap: a clip is a moment, not a way to export a whole film to a public link. +MAX_CLIP_S = 600 +MIN_CLIP_S = 1 +MAX_HEIGHT = 1080 +# What yt-dlp fetches the range in (formats.normalize fills the rest): the clip's own ceiling, so the +# re-encode never upscales and never throws away a better source than it keeps. +YOUTUBE_FETCH_SPEC = {"mode": "av", "max_height": MAX_HEIGHT, "container": "mp4"} +# Bump when the spec → output-bytes mapping changes, so a cached clip re-derives. +_SIG_VERSION = 1 + +_YOUTUBE_ID = re.compile(r"[A-Za-z0-9_-]{11}") +_RATING_KEY = re.compile(r"[0-9]{1,32}") +_MAX_AUDIO_TRACK = 63 +# The transfer characteristics of HDR video (PQ, HLG): an 8-bit SDR encode must tonemap them. +_HDR_TRANSFERS = frozenset({"smpte2084", "arib-std-b67"}) +# HDR → SDR as measured on the owner's 4K HDR files (2026-10-10): linearise, tonemap (hable), +# convert the primaries and transfer to BT.709 and back to 8-bit 4:2:0. +_TONEMAP = ( + "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=hable:desat=0," + "zscale=t=bt709:m=bt709:r=tv,format=yuv420p" +) +_SCALE = f"scale=-2:'min({MAX_HEIGHT},ih)'" + + +class ClipError(Exception): + """A clip request that cannot be cut; `reason` is an i18n-mappable key.""" + + def __init__(self, reason: str): + self.reason = reason + super().__init__(reason) + + +def normalize( + source: str, ref: str, start_s: float, end_s: float, audio_track: int | None = None +) -> dict: + """The validated, canonical clip spec. Times are rounded to the millisecond so the same marks + always give the same cache key. Raises ClipError.""" + if source not in SOURCES: + raise ClipError("bad_source") + pattern = _YOUTUBE_ID if source == "youtube" else _RATING_KEY + if not isinstance(ref, str) or not pattern.fullmatch(ref): + raise ClipError("bad_source") + if not (math.isfinite(start_s) and math.isfinite(end_s)) or start_s < 0: + raise ClipError("bad_range") + start, end = round(start_s, 3), round(end_s, 3) + if end - start < MIN_CLIP_S: + raise ClipError("too_short") + if end - start > MAX_CLIP_S: + raise ClipError("too_long") + spec: dict = {"source": source, "ref": ref, "start_s": start, "end_s": end} + if source == "plex" and audio_track is not None: + if not 0 <= audio_track <= _MAX_AUDIO_TRACK: + raise ClipError("bad_audio") + spec["audio_track"] = audio_track + return spec + + +def duration(spec: dict) -> float: + return spec["end_s"] - spec["start_s"] + + +def sig(spec: dict) -> str: + """The clip's cache signature: the spec itself, versioned.""" + body = json.dumps({**spec, "_v": _SIG_VERSION}, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(body.encode()).hexdigest()[:24] + + +def asset_ref(user_id: int, spec: dict) -> str: + """The asset's `source_ref`. The user id makes a clip private to whoever cut it, like an edit: + never dedup-shared, never cache-served to someone else.""" + return f"{user_id}:{spec['source']}:{spec['ref']}" + + +def reference_url(spec: dict) -> str | None: + """Where a YouTube clip came from: its video at the clip's start. A Plex clip has no public page.""" + if spec.get("source") != "youtube" or not spec.get("ref"): + return None + return f"https://www.youtube.com/watch?v={spec['ref']}&t={int(spec.get('start_s') or 0)}s" + + +def is_hdr(probe: dict) -> bool: + """Whether the first video stream of an ffprobe `-show_streams` JSON carries an HDR transfer.""" + for s in probe.get("streams") or []: + if s.get("codec_type") == "video": + return s.get("color_transfer") in _HDR_TRANSFERS + return False + + +def audio_track_count(probe: dict) -> int: + return sum(1 for s in probe.get("streams") or [] if s.get("codec_type") == "audio") + + +def build_cmd( + src: Path, + dest: Path, + start_s: float, + dur_s: float, + *, + audio_track: int | None, + hdr: bool, + threads: int, +) -> list[str]: + """ffmpeg for one clip: an input seek (decodes from the keyframe before `start_s` and drops the + frames up to it, so the clip starts on the marked frame), the first video stream, the chosen audio + track (the first when none was chosen; none at all is fine), no subtitles, data or chapters.""" + vf = ",".join([*([_TONEMAP] if hdr else []), _SCALE, EVEN_FRAME]) + audio = f"0:a:{audio_track}" if audio_track is not None else "0:a:0?" + return [ + "ffmpeg", + "-y", + "-hide_banner", + "-loglevel", + "error", + "-nostdin", + "-progress", + "pipe:1", + "-nostats", + "-ss", + f"{start_s:.3f}", + *INPUT_GUARD, + "-i", + str(src), + "-t", + f"{dur_s:.3f}", + "-map", + "0:v:0", + "-map", + audio, + "-sn", + "-dn", + # A source's chapters would ride into the .mp4 as a data track of their own (an mkv's + # chapter list came out as `bin_data`); a clip of a moment has no chapters to keep. + "-map_chapters", + "-1", + "-vf", + vf, + *X264_ARGS, + "-threads", + str(threads), + *AAC_ARGS, + "-ac", + "2", + "-movflags", + "+faststart", + str(dest), + ] diff --git a/backend/app/downloads/pipeline.py b/backend/app/downloads/pipeline.py index b8ac2324..f8c94d10 100644 --- a/backend/app/downloads/pipeline.py +++ b/backend/app/downloads/pipeline.py @@ -7,10 +7,13 @@ from datetime import date, datetime, timedelta, timezone from pathlib import Path import yt_dlp +from sqlalchemy import select +from yt_dlp.utils import download_range_func from app import logs, sysconfig from app.config import settings from app.db import SessionLocal +from app.downloads import clip as clipmod from app.downloads import edit as editmod from app.downloads import errors as dlerrors from app.downloads import adapters, credentials, formats, mediakind, playable, probe, quota @@ -26,7 +29,8 @@ from app.downloads.jobdb import ( ) from app.downloads.stated import StatedFailure from app.downloads.status import JobStatus -from app.models import DISPLAY_TEXT_MAX, DownloadJob, MediaAsset +from app.models import DISPLAY_TEXT_MAX, DownloadJob, MediaAsset, PlexItem +from app.plex import paths as plex_paths from app.notifications import create_notification from app.titles import normalize_title @@ -333,7 +337,7 @@ def _media_info(info: dict) -> dict: def _extract_with_fallback( - job_id, asset_id, url, spec, staging, adapter, cookiefile=None, max_bytes=None + job_id, asset_id, url, spec, staging, adapter, cookiefile=None, max_bytes=None, section=None ): """Download via yt-dlp using the source `adapter`'s client sets + options. For YouTube the adapter supplies the primary player-client set then a POT-backed fallback (retried on a bot/DRM/format @@ -349,7 +353,11 @@ def _extract_with_fallback( `cookiefile` is the acting user's per-host cookies.txt, already gated by `credentials.resolve_cookiefile`; passed to every attempt so the fallback stays authenticated. - `max_bytes` (SEC-13) is the owner's remaining storage budget, applied to every attempt.""" + `max_bytes` (SEC-13) is the owner's remaining storage budget, applied to every attempt. + + `section` = (start_s, end_s) fetches only that range (an ad-hoc clip, see downloads/clip): the + range is stream-copied, and its first frame is the marked one (measured), so the clip's own + re-encode starts at 0 instead of re-fetching keyframes at the cut.""" client_sets = adapter.client_sets(authed=cookiefile is not None) outtmpl = str(staging / "%(id)s.%(ext)s") # Resolving may read a page first — network time the stall watchdog must not see as a stall. @@ -375,6 +383,9 @@ def _extract_with_fallback( max_bytes=max_bytes, ) opts["postprocessor_hooks"] = [_make_pp_hook(job_id)] + if section is not None: + opts["download_ranges"] = download_range_func([], [section]) + opts["force_keyframes_at_cuts"] = False _touch_job(job_id) # stall-watchdog heartbeat: extraction writes no progress, so a slow / # multi-client-set fallback would otherwise cross the stall timeout with a stale updated_at. # (Per-ATTEMPT only; a single extract_info that itself runs past the stall timeout is still @@ -896,6 +907,159 @@ def _process_edit(job_id: int, asset_id: int) -> None: shutil.rmtree(staging, ignore_errors=True) +def _clip_source(job_id: int, asset_id: int, spec: dict, staging: Path) -> tuple[Path, float]: + """The file a clip is cut from and where in it the clip starts. A YouTube clip fetches ONLY its + range into `staging` (so it starts at 0 there); a Plex clip reads the library file in place.""" + if spec["source"] == "youtube": + with SessionLocal() as db: + job = db.get(DownloadJob, job_id) + remaining = quota.remaining_bytes(db, job.user_id) if job else None + if remaining == 0: # see `_download`: zero is a refusal, not "no limit" + raise _OwnerRefused(dlerrors.QUOTA_FULL_MESSAGE) + _set_job(job_id, phase="downloading", progress=0) + # Signed out, like a download without a cookie: the cookie gate is written for a download's + # salted asset identity (`credentials.resolve_cookiefile`), not for a clip's. + info = _extract_with_fallback( + job_id, + asset_id, + service.source_url("youtube", spec["ref"]), + formats.normalize(clipmod.YOUTUBE_FETCH_SPEC), + staging, + adapters.resolve_adapter("youtube", spec["ref"]), + max_bytes=remaining, + section=(spec["start_s"], spec["end_s"]), + ) + return _produced_file(_media_info(dict(info)), staging), 0.0 + with SessionLocal() as db: + item = db.execute( + select(PlexItem).where(PlexItem.rating_key == spec["ref"]) + ).scalar_one_or_none() + src = plex_paths.local_media_path(db, item.file_path) if item else None + if src is None: + raise StatedFailure("The library file is not reachable from here.") + return src, spec["start_s"] + + +def _process_clip(job_id: int, asset_id: int) -> None: + """Cut an ad-hoc clip (downloads/clip): fetch or open its source, ONE re-encode to the clip + shape, then into the user's library beside their edits.""" + with SessionLocal() as db: + job = db.get(DownloadJob, job_id) + if job is None: + return + spec = dict(job.edit_spec or {}) + user_id = job.user_id + staging = _edit_staging_dir(asset_id) + try: + if staging.exists(): + shutil.rmtree(staging, ignore_errors=True) + staging.mkdir(parents=True, exist_ok=True) + src, start = _clip_source(job_id, asset_id, spec, staging) + data = probe.probe_json(src) or {} + audio_track = spec.get("audio_track") + if audio_track is not None and audio_track >= clipmod.audio_track_count(data): + raise StatedFailure("The chosen audio track is not in the file.") + total_s = clipmod.duration(spec) + out = staging / "clip.mp4" + _set_job(job_id, phase="editing", progress=0, speed_bps=None, eta_s=None) + editmod.run_ffmpeg( + clipmod.build_cmd( + src, + out, + start, + total_s, + audio_track=audio_track, + hdr=clipmod.is_hdr(data), + threads=editmod.encode_thread_cap(), + ), + total_s, + on_progress=_ffmpeg_progress_writer(job_id, "editing"), + should_cancel=_cancel_poll(job_id), + ) + if not out.exists(): + raise StatedFailure("ffmpeg produced no output file") + # The phone-safe shape by construction; still held to the rule every ready file meets. + out = _ensure_phone_safe(job_id, out, staging, "editing", round(total_s)) + ext = out.suffix.lstrip(".").lower() or "mp4" + rel = storage.edit_rel_path(user_id, asset_id, ext) + storage.place_file(out, settings.download_root, rel) + final = storage.abs_path(settings.download_root, rel) + vcodec, acodec, width, height, _ = probe.probe_media(final) + _, retention_days = _download_settings() + + with SessionLocal() as db: + asset = db.get(MediaAsset, asset_id) + if asset: + asset.status = "ready" + asset.rel_path = rel + asset.size_bytes = final.stat().st_size if final.exists() else None + asset.container = ext + asset.vcodec, asset.acodec, asset.width, asset.height = ( + vcodec, + acodec, + width, + height, + ) + asset.duration_s = round(total_s) + asset.media_kind = mediakind.derive(vcodec, acodec) + # The clip's own frame as its poster: the link preview shows the moment, and a Plex + # item has no public thumbnail at all. + asset.poster_path = editmod.ensure_poster(asset, settings.download_root) + asset.error = None + asset.last_access_at = datetime.now(timezone.utc) + asset.expires_at = datetime.now(timezone.utc) + timedelta(days=retention_days) + title = asset.title if asset else None + finalized = service.finalize_if_running( + db, job_id, status=JobStatus.DONE, progress=100, phase=None + ) + db.commit() + if finalized: + _notify_download_done(db, user_id, title, job_id, kind="edit") + log.info( + "clip job %s done: asset %s", + job_id, + asset_id, + extra=logs.event( + "clip.job.done", job_id=job_id, asset_id=asset_id, source=spec["source"] + ), + ) + except (_Aborted, editmod.EditAborted): + # Paused/canceled mid-fetch or mid-encode: free the asset for a later retry. + _reset_asset_pending(asset_id) + log.info( + "clip job %s aborted (pause/cancel)", + job_id, + extra=logs.event("clip.job.aborted", job_id=job_id), + ) + except _OwnerRefused as exc: + # Quota full: the job fails, the asset goes back to pending for a Retry once there is room + # (the download's handling; see `_download`). + msg = str(exc)[:500] + with SessionLocal() as db: + service.finalize_if_running( + db, + job_id, + status=JobStatus.ERROR, + error=msg, + error_code=dlerrors.classify(msg), + error_detail=None, + phase=None, + ) + db.commit() + _reset_asset_pending(asset_id) + log.info( + "clip job %s refused for its owner: %s", + job_id, + formats.log_safe(msg), + extra=logs.event("clip.job.refused", job_id=job_id), + ) + except Exception as exc: # noqa: BLE001 — surface the failure, keep the worker alive + # silent-ok: not silent — `_fail_edit` is the report, and it takes `exc` for its stack. + _fail_edit(job_id, asset_id, str(exc)[:500], exc, what="clip") + finally: + shutil.rmtree(staging, ignore_errors=True) + + def _check_converted_fits(owner_id: int | None, old_size: int, new_size: int) -> None: """Refuse a converted upload that would push its owner over the storage cap: the upload was charged at its original size, and a re-encode can come out larger. The original stays, counted, diff --git a/backend/app/downloads/quota.py b/backend/app/downloads/quota.py index 66c92ef8..afde02ae 100644 --- a/backend/app/downloads/quota.py +++ b/backend/app/downloads/quota.py @@ -24,7 +24,7 @@ from sqlalchemy import and_, func, or_, select from sqlalchemy.orm import Session from app import sysconfig -from app.downloads.status import JobStatus +from app.downloads.status import JobKind, JobStatus from app.models import DownloadJob, DownloadQuota, MediaAsset # Jobs a user is considered to be "holding" (occupy a job slot / footprint). Terminal failures @@ -86,7 +86,7 @@ def footprint(db: Session, user_id: int) -> int: DownloadJob.user_id == user_id, or_( DownloadJob.status.in_(_HOLDING), - and_(DownloadJob.job_kind == "upload", DownloadJob.status == JobStatus.ERROR), + and_(DownloadJob.job_kind == JobKind.UPLOAD, DownloadJob.status == JobStatus.ERROR), ), DownloadJob.asset_id.is_not(None), ) diff --git a/backend/app/downloads/runner.py b/backend/app/downloads/runner.py index 654de3e8..9af835ef 100644 --- a/backend/app/downloads/runner.py +++ b/backend/app/downloads/runner.py @@ -22,6 +22,7 @@ from app.config import settings from app.db import SessionLocal from app.downloads import edit as editmod from app.downloads import formats +from app.downloads.status import JobKind from app.models import DownloadJob log = logs.get_logger(__name__) @@ -95,14 +96,16 @@ def _run(job_id, asset_id, job_kind, source_kind, source_ref, spec, user_id) -> (`cause=child_exit`).""" # Imported here (not at module top) so the child pays the yt-dlp import cost only when it has # real work, and to keep the import graph obvious: runner is a thin entry over the pipeline. - from app.downloads.pipeline import _convert_upload, _download, _process_edit + from app.downloads.pipeline import _convert_upload, _download, _process_clip, _process_edit with formats.hiding(source_ref): try: - if job_kind == "edit": + if job_kind == JobKind.EDIT: _process_edit(job_id, asset_id) - elif job_kind == "upload": + elif job_kind == JobKind.UPLOAD: _convert_upload(job_id, asset_id) + elif job_kind == JobKind.CLIP: + _process_clip(job_id, asset_id) else: _download(job_id, asset_id, source_kind, source_ref, spec, user_id) except Exception as exc: # noqa: BLE001 — whatever it is, it is reported here or raw diff --git a/backend/app/downloads/service.py b/backend/app/downloads/service.py index 22c2a019..f2c4ef63 100644 --- a/backend/app/downloads/service.py +++ b/backend/app/downloads/service.py @@ -16,11 +16,21 @@ from sqlalchemy.orm import Session from app.config import settings from app.downloads import adapters +from app.downloads import clip as clipmod from app.downloads import credentials from app.downloads import edit as editmod from app.downloads import formats, mediakind, quota, storage -from app.downloads.status import HOLDING, JobStatus -from app.models import DISPLAY_TEXT_MAX, Channel, DownloadJob, MediaAsset, Video +from app.downloads.status import HOLDING, JobKind, JobStatus +from app.models import ( + DISPLAY_TEXT_MAX, + Channel, + DownloadJob, + MediaAsset, + PlexItem, + PlexShow, + Video, +) +from app.plex import paths as plex_paths class EditError(Exception): @@ -40,10 +50,13 @@ def source_url(source_kind: str, source_ref: str) -> str: def reference_url(job, asset) -> str | None: """The clean "downloaded from" URL for a job — shared by the private Downloads page and the public watch page so both surfaces always agree. None for edit derivatives (a clip's source is the user's - own earlier download, not a web page); else the canonical page URL yt-dlp recorded; else one derived - from source_kind+source_ref (covers queued/older rows before the worker fills it).""" - if job is None or job.job_kind == "edit": + own earlier download, not a web page) and for a Plex clip (the library is not a public page); a + YouTube clip links its video at the clip's start; else the canonical page URL yt-dlp recorded; else + one derived from source_kind+source_ref (covers queued/older rows before the worker fills it).""" + if job is None or job.job_kind == JobKind.EDIT: return None + if job.job_kind == JobKind.CLIP: + return clipmod.reference_url(job.edit_spec or {}) if asset is not None and asset.source_webpage_url: return asset.source_webpage_url if job.source_kind in ("youtube", "url"): @@ -251,7 +264,7 @@ def enqueue_edit( job = DownloadJob( user_id=user_id, asset_id=asset.id, - job_kind="edit", + job_kind=JobKind.EDIT, source_kind="edit", source_ref=ref, source_job_id=source_job.id, @@ -275,6 +288,95 @@ def enqueue_edit( return job +def _clip_source_facts(db: Session, spec: dict) -> dict: + """Title, uploader, thumbnail and length of what a clip is cut from, read from our own catalog. + Raises ClipError when a Plex item is unknown or its file cannot be read here — said at enqueue, + rather than as a job that fails in the queue a minute later.""" + if spec["source"] == "youtube": + video = db.get(Video, spec["ref"]) + if video is None: # not in the catalog: the worker fills the title from yt-dlp's answer + return {"title": None, "uploader": None, "thumbnail_url": None, "length_s": None} + channel = db.get(Channel, video.channel_id) + return { + "title": video.title, + "uploader": channel.title if channel else None, + "thumbnail_url": video.thumbnail_url, + "length_s": video.duration_seconds, + } + item = db.execute( + select(PlexItem).where(PlexItem.rating_key == spec["ref"]) + ).scalar_one_or_none() + if item is None: + raise clipmod.ClipError("not_found") + if plex_paths.local_media_path(db, item.file_path) is None: + raise clipmod.ClipError("media_missing") + title = item.title + show = db.get(PlexShow, item.show_id) if item.show_id else None + if show is not None: + episode = ( + f"S{item.season_number}·E{item.episode_number}" + if item.season_number is not None and item.episode_number is not None + else None + ) + title = " — ".join(p for p in (show.title, episode, item.title) if p) + return { + "title": title, + "uploader": None, + # Plex art is served through our own authenticated routes, never a public URL; the worker + # cuts the clip's poster from the clip itself. + "thumbnail_url": None, + "length_s": item.duration_s, + } + + +def enqueue_clip( + db: Session, user_id: int, spec: dict, display_name: str | None = None +) -> DownloadJob: + """Queue an ad-hoc clip (`clipmod.normalize`d spec) for `user_id`: a NEW per-user asset cut + straight from the source — a YouTube range or a Plex library file (see downloads/clip). + + Raises ClipError (unknown or unreachable source, a range past its end) or quota.QuotaExceeded.""" + facts = _clip_source_facts(db, spec) + # One second of grace: a mark set on the last frame may round past the catalog's whole seconds. + if facts["length_s"] and spec["end_s"] > facts["length_s"] + 1: + raise clipmod.ClipError("past_end") + + quota.check_enqueue(db, user_id) + + ref = clipmod.asset_ref(user_id, spec)[:512] + asset = get_or_create_asset(db, "clip", ref, clipmod.sig(spec)) + if not asset.title: + asset.title = _clip_title(facts["title"], display_name) + asset.uploader = facts["uploader"] + asset.thumbnail_url = facts["thumbnail_url"] + asset.duration_s = round(clipmod.duration(spec)) + asset.media_kind = mediakind.VIDEO + + job = DownloadJob( + user_id=user_id, + asset_id=asset.id, + job_kind=JobKind.CLIP, + source_kind="clip", + source_ref=ref, + edit_spec=spec, + profile_snapshot={}, + display_name=display_name or asset.title[:DISPLAY_TEXT_MAX], + status="queued", + queue_pos=next_queue_pos(db), + ) + db.add(job) + incr_ref_count(db, asset.id) + + if asset.status == "ready": # the same range was already cut for this user → instant + job.status = "done" + job.progress = 100 + asset.last_access_at = datetime.now(timezone.utc) + + db.commit() + db.refresh(job) + return job + + def finalize_if_running(db: Session, job_id: int, **fields) -> bool: """Write terminal `fields` to a job in ONE atomic ``UPDATE ... WHERE status='running'`` and report whether it fired. This is the single owner of the invariant "a finishing worker child must never diff --git a/backend/app/downloads/status.py b/backend/app/downloads/status.py index 7ee2a05f..7849930e 100644 --- a/backend/app/downloads/status.py +++ b/backend/app/downloads/status.py @@ -33,6 +33,23 @@ HOLDING = frozenset( {JobStatus.QUEUED, JobStatus.RUNNING, JobStatus.PAUSED, JobStatus.DONE, JobStatus.ERROR} ) + +class JobKind(StrEnum): + """What a job produces, which decides the worker path that runs it and what the library may do + with the result (only a DOWNLOAD has a web source to fetch again).""" + + DOWNLOAD = "download" # yt-dlp fetches a source into a shared, deduplicated asset + EDIT = "edit" # a per-user trim/crop of a file already in the library + UPLOAD = "upload" # a file the user sent; the worker only converts it + CLIP = ( + "clip" # a per-user range of a YouTube video or a Plex item, cut straight from the source + ) + + +# A job kind with no web source to fetch again: Re-download refuses these. +NOT_REFETCHABLE = frozenset({JobKind.EDIT, JobKind.UPLOAD, JobKind.CLIP}) + + # States a route sets to STOP a running job. The worker's supervisor must step in (kill the child, free # the asset, leave the route's status intact) when it observes one of these on a job it is running. STOPPED_BY_ROUTE = frozenset({JobStatus.PAUSED, JobStatus.CANCELED}) diff --git a/backend/app/downloads/upload.py b/backend/app/downloads/upload.py index 1629a05e..7612c4d3 100644 --- a/backend/app/downloads/upload.py +++ b/backend/app/downloads/upload.py @@ -34,7 +34,7 @@ from sqlalchemy.orm import Session from app.config import settings from app.downloads import edit as editmod from app.downloads import mediakind, playable, probe, quota, service, storage -from app.downloads.status import JobStatus +from app.downloads.status import JobKind, JobStatus from app.models import DownloadJob, MediaAsset, display_text @@ -113,7 +113,7 @@ def ingest_upload( job = DownloadJob( user_id=user_id, asset_id=asset.id, - job_kind="upload", + job_kind=JobKind.UPLOAD, source_kind="upload", source_ref=ref, profile_snapshot={}, diff --git a/backend/app/routes/downloads.py b/backend/app/routes/downloads.py index 6dcc0784..2f513ef8 100644 --- a/backend/app/routes/downloads.py +++ b/backend/app/routes/downloads.py @@ -8,6 +8,7 @@ state, serves finished files (range-aware, with the user's custom display name), import os import re import tempfile +from typing import Literal from collections.abc import AsyncIterator, Sequence from dataclasses import dataclass from datetime import datetime, timedelta, timezone @@ -16,7 +17,7 @@ from urllib.parse import parse_qs, unquote, urlparse from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import FileResponse -from pydantic import BaseModel +from pydantic import BaseModel, Field from sqlalchemy import delete, func, select from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.orm import Session @@ -27,13 +28,14 @@ from app import audit, sysconfig from app.auth import admin_user, current_user from app.config import settings from app.db import get_db, release_read_connection +from app.downloads import clip as clipmod from app.downloads import credentials as dlcreds from app.downloads import edit as editmod from app.downloads import links as linksmod from app.downloads import quota, service, storage from app.downloads import upload as uploadmod from app.downloads import urlguard -from app.downloads.status import JobStatus +from app.downloads.status import NOT_REFETCHABLE, JobStatus from app.models import ( DownloadJob, DownloadLink, @@ -576,6 +578,34 @@ def enqueue_edit( return _serialize_job(db, job) +class EnqueueClipIn(BaseModel): + source: Literal["youtube", "plex"] + # The YouTube video id or the Plex rating key. + ref: str = Field(max_length=32) + start_s: float + end_s: float + # Plex only: which of the file's audio tracks (0-based, in file order) — the one being heard. + audio_track: int | None = None + display_name: str | None = None + + +@router.post("/clip") +def enqueue_clip( + body: EnqueueClipIn, user: User = Depends(current_user), db: Session = Depends(get_db) +) -> dict: + """Queue an ad-hoc clip: the marked range of a YouTube video or a Plex item, cut straight from + the source into a new asset in this user's library (downloads/clip).""" + try: + spec = clipmod.normalize(body.source, body.ref, body.start_s, body.end_s, body.audio_track) + job = service.enqueue_clip(db, user.id, spec, display_text(body.display_name)) + except clipmod.ClipError as e: + status = 404 if e.reason == "not_found" else 422 + raise HTTPException(status_code=status, detail={"code": "clip", "reason": e.reason}) from e + except quota.QuotaExceeded as e: + raise HTTPException(status_code=422, detail=_quota_detail(e)) from e + return _serialize_job(db, job) + + def _quota_detail(e: quota.QuotaExceeded) -> dict: """Structured error the trilingual client localizes (reason key + numbers), instead of a pre-baked English string with a dot-decimal GB value. See lib/api.ts localizeDetail().""" @@ -768,9 +798,9 @@ def redownload_download( never deletes a file another job/user still shares: the old asset is only removed if THIS job was its last holder.""" job = _own_job_for_update(db, user, job_id) - if job.job_kind in ("edit", "upload"): - # Neither has a re-fetchable source: an editor clip is derived locally, an upload IS the - # source. Re-running yt-dlp against source_kind='upload'/'edit' would just fail. + if job.job_kind in NOT_REFETCHABLE: + # None has a re-fetchable source: an editor clip is derived locally, an upload IS the + # source, an ad-hoc clip is a range of a source the clip itself does not re-fetch. raise refused(409, "not_redownloadable") if job.status in (JobStatus.QUEUED, JobStatus.RUNNING, JobStatus.PAUSED): raise refused(409, "still_downloading") diff --git a/backend/tests/test_clip.py b/backend/tests/test_clip.py new file mode 100644 index 00000000..1913bb89 --- /dev/null +++ b/backend/tests/test_clip.py @@ -0,0 +1,423 @@ +"""Ad-hoc clips (downloads/clip): the spec, the one ffmpeg command every clip goes through, the route, +and the worker path for both sources. + +The ffmpeg command is judged at argv level: where the seek sits, which streams are mapped, what the +video filter does to an HDR source. The worker runs with ffmpeg / yt-dlp replaced by stand-ins that +write a file where they were told to, so what is checked is what the pipeline asks of them and what +it stores afterwards. +""" + +from pathlib import Path + +import pytest + +from app.config import settings +from app.downloads import clip as clipmod +from app.downloads import pipeline, quota, runner, service +from app.downloads import probe as probemod +from app.downloads.edit import INPUT_GUARD +from app.models import Channel, DownloadJob, MediaAsset, PlexItem, PlexLibrary, PlexShow, Video +from app.plex import paths as plex_paths + +# --- the spec ----------------------------------------------------------------------------------- + + +def test_a_spec_is_canonical_so_the_same_marks_hit_the_same_clip(): + a = clipmod.normalize("youtube", "abcdefghijk", 10.00049, 40.0) + b = clipmod.normalize("youtube", "abcdefghijk", 10.0, 40.0004) + assert a == b == {"source": "youtube", "ref": "abcdefghijk", "start_s": 10.0, "end_s": 40.0} + assert clipmod.sig(a) == clipmod.sig(b) + assert clipmod.sig(a) != clipmod.sig({**a, "end_s": 41.0}) + + +@pytest.mark.parametrize( + ("args", "reason"), + [ + (("vimeo", "abcdefghijk", 0, 10), "bad_source"), + (("youtube", "short", 0, 10), "bad_source"), + (("youtube", "abcdefghij/", 0, 10), "bad_source"), + (("plex", "12a", 0, 10), "bad_source"), + (("youtube", "abcdefghijk", -1, 10), "bad_range"), + (("youtube", "abcdefghijk", 0, float("nan")), "bad_range"), + (("youtube", "abcdefghijk", 5, 5.5), "too_short"), + (("youtube", "abcdefghijk", 30, 10), "too_short"), + (("youtube", "abcdefghijk", 0, 600.001), "too_long"), + ], +) +def test_a_range_it_cannot_cut_is_refused_with_its_reason(args, reason): + with pytest.raises(clipmod.ClipError) as e: + clipmod.normalize(*args) + assert e.value.reason == reason + + +def test_the_ten_minute_cap_itself_is_allowed(): + assert clipmod.duration(clipmod.normalize("plex", "42", 0, 600)) == 600 + + +def test_an_audio_track_is_kept_for_plex_only_and_must_be_sane(): + assert clipmod.normalize("plex", "42", 0, 10, 2)["audio_track"] == 2 + assert "audio_track" not in clipmod.normalize("youtube", "abcdefghijk", 0, 10, 2) + with pytest.raises(clipmod.ClipError, match="bad_audio"): + clipmod.normalize("plex", "42", 0, 10, -1) + + +def test_a_youtube_clip_links_its_video_at_the_clip_start_and_a_plex_clip_links_nothing(): + yt = clipmod.normalize("youtube", "abcdefghijk", 75.9, 90) + assert clipmod.reference_url(yt) == "https://www.youtube.com/watch?v=abcdefghijk&t=75s" + assert clipmod.reference_url(clipmod.normalize("plex", "42", 0, 10)) is None + + +# --- the ffmpeg command ---------------------------------------------------------------------------- + + +def _cmd(**kw): + args = {"audio_track": None, "hdr": False, "threads": 3, **kw} + return clipmod.build_cmd(Path("/m/in.mkv"), Path("/s/out.mp4"), 61.5, 30.0, **args) + + +def test_the_seek_is_on_the_input_so_the_clip_starts_on_the_marked_frame(): + cmd = _cmd() + i = cmd.index("-i") + assert cmd[cmd.index("-ss") + 1] == "61.500" and cmd.index("-ss") < i + assert cmd[i + 1] == "/m/in.mkv" + assert cmd[cmd.index("-t") + 1] == "30.000" and cmd.index("-t") > i + # The single-file demuxer allowlist goes with every library read. + g = cmd.index(INPUT_GUARD[0]) + assert cmd[g : g + len(INPUT_GUARD)] == INPUT_GUARD and g < i + assert cmd[-1] == "/s/out.mp4" + + +def test_it_maps_the_first_video_and_the_heard_audio_track_and_nothing_else(): + cmd = _cmd(audio_track=2) + maps = [cmd[i + 1] for i, a in enumerate(cmd) if a == "-map"] + assert maps == ["0:v:0", "0:a:2"] + assert "-sn" in cmd and "-dn" in cmd + # An mkv's chapters would ride into the .mp4 as a data track of their own (measured on a Blu-ray + # rip: `bin_data`), which -dn does not drop. + assert cmd[cmd.index("-map_chapters") + 1] == "-1" + # No track chosen: the first one, and a source with no audio at all still cuts. + maps = [c for i, c in enumerate(_cmd()) if i and _cmd()[i - 1] == "-map"] + assert maps == ["0:v:0", "0:a:0?"] + + +def test_the_output_is_phone_safe_stereo_h264_at_most_1080p(): + cmd = _cmd() + assert cmd[cmd.index("-c:v") + 1] == "libx264" + assert cmd[cmd.index("-pix_fmt") + 1] == "yuv420p" + assert cmd[cmd.index("-c:a") + 1] == "aac" and cmd[cmd.index("-ac") + 1] == "2" + assert cmd[cmd.index("-threads") + 1] == "3" + vf = cmd[cmd.index("-vf") + 1] + assert "min(1080,ih)" in vf and "tonemap" not in vf + + +def test_an_hdr_source_is_tonemapped_to_sdr_before_it_is_scaled(): + vf = _cmd(hdr=True)[_cmd(hdr=True).index("-vf") + 1] + assert vf.index("tonemap") < vf.index("zscale=t=bt709") < vf.index("min(1080,ih)") + + +@pytest.mark.parametrize( + ("transfer", "hdr"), + [("smpte2084", True), ("arib-std-b67", True), ("bt709", False), (None, False)], +) +def test_hdr_is_read_from_the_first_video_stream(transfer, hdr): + data = { + "streams": [{"codec_type": "audio"}, {"codec_type": "video", "color_transfer": transfer}] + } + assert clipmod.is_hdr(data) is hdr + + +# --- the route --------------------------------------------------------------------------------------- + + +@pytest.fixture +def yt_video(db): + db.add(Channel(id="UC_clip", title="The Channel")) + db.flush() + db.add( + Video( + id="abcdefghijk", + channel_id="UC_clip", + title="A Funny Video", + duration_seconds=300, + thumbnail_url="https://i.ytimg.com/vi/abcdefghijk/hq.jpg", + ) + ) + db.commit() + return "abcdefghijk" + + +@pytest.fixture +def plex_episode(db, tmp_path, monkeypatch): + lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) + db.add(lib) + db.flush() + show = PlexShow(rating_key="s9", library_id=lib.id, title="The Show") + db.add(show) + db.flush() + db.add( + PlexItem( + rating_key="777", + library_id=lib.id, + kind="episode", + title="Pilot", + show_id=show.id, + season_number=1, + episode_number=2, + duration_s=1800, + file_path="/data/shows/pilot.mkv", + ) + ) + db.commit() + media = tmp_path / "pilot.mkv" + media.write_bytes(b"m" * 8) + monkeypatch.setattr( + plex_paths, + "local_media_path", + lambda _db, p: media if p == "/data/shows/pilot.mkv" else None, + ) + return media + + +def _clip(client, **body): + return client.post("/api/downloads/clip", json={"start_s": 10, "end_s": 40, **body}) + + +def test_a_youtube_clip_is_queued_as_its_own_job_titled_after_its_video( + db, make_user, as_user, yt_video +): + r = _clip(as_user(make_user()), source="youtube", ref=yt_video) + assert r.status_code == 200, r.text + job = r.json() + assert (job["job_kind"], job["status"], job["display_name"]) == ( + "clip", + "queued", + "A Funny Video (clip)", + ) + assert job["edit_spec"] == {"source": "youtube", "ref": yt_video, "start_s": 10, "end_s": 40} + assert job["source_url"] == f"https://www.youtube.com/watch?v={yt_video}&t=10s" + asset = db.get(MediaAsset, job["asset"]["id"]) + assert (asset.duration_s, asset.uploader, asset.media_kind) == (30, "The Channel", "video") + + +def test_the_same_range_again_is_the_same_clip_but_never_another_users( + db, make_user, as_user, yt_video +): + a, b = make_user(), make_user() + first = _clip(as_user(a), source="youtube", ref=yt_video).json()["asset"]["id"] + again = _clip(as_user(a), source="youtube", ref=yt_video).json()["asset"]["id"] + other = _clip(as_user(b), source="youtube", ref=yt_video).json()["asset"]["id"] + longer = _clip(as_user(a), source="youtube", ref=yt_video, end_s=41).json()["asset"]["id"] + assert first == again + assert len({first, other, longer}) == 3 + + +def test_a_ready_clip_asked_for_again_is_done_at_once(db, make_user, as_user, yt_video): + u = make_user() + first = _clip(as_user(u), source="youtube", ref=yt_video).json() + asset = db.get(MediaAsset, first["asset"]["id"]) + asset.status, asset.rel_path = "ready", ".edits/1/clip_1.mp4" + db.commit() + again = _clip(as_user(u), source="youtube", ref=yt_video).json() + assert (again["status"], again["progress"]) == ("done", 100) + + +@pytest.mark.parametrize( + ("body", "status", "reason"), + [ + ({"end_s": 700}, 422, "too_long"), + ({"start_s": 299.5, "end_s": 330}, 422, "past_end"), + ({"ref": "nope"}, 422, "bad_source"), + ], +) +def test_a_youtube_clip_it_cannot_cut_says_why( + db, make_user, as_user, yt_video, body, status, reason +): + r = _clip(as_user(make_user()), **{"source": "youtube", "ref": yt_video, **body}) + assert r.status_code == status + assert r.json()["detail"] == {"code": "clip", "reason": reason} + assert db.query(DownloadJob).count() == 0 + + +def test_a_plex_clip_is_titled_show_episode_title_and_keeps_its_audio_track( + db, make_user, as_user, plex_episode +): + r = _clip(as_user(make_user()), source="plex", ref="777", audio_track=1) + assert r.status_code == 200, r.text + job = r.json() + assert job["display_name"] == "The Show — S1·E2 — Pilot (clip)" + assert job["edit_spec"]["audio_track"] == 1 + assert job["source_url"] is None + + +def test_a_plex_clip_of_an_unknown_item_is_a_404_and_of_an_unreachable_file_a_422( + db, make_user, as_user, plex_episode +): + c = as_user(make_user()) + assert _clip(c, source="plex", ref="999").json()["detail"]["reason"] == "not_found" + assert _clip(c, source="plex", ref="999").status_code == 404 + item = db.query(PlexItem).one() + item.file_path = "/data/elsewhere.mkv" + db.commit() + r = _clip(c, source="plex", ref="777") + assert (r.status_code, r.json()["detail"]["reason"]) == (422, "media_missing") + + +def test_a_clip_over_quota_is_refused_like_a_download( + db, make_user, as_user, yt_video, monkeypatch +): + def full(_db, _uid): + raise quota.QuotaExceeded("max_jobs", limit=5, current=5) + + monkeypatch.setattr(quota, "check_enqueue", full) + r = _clip(as_user(make_user()), source="youtube", ref=yt_video) + assert r.status_code == 422 + assert db.query(DownloadJob).count() == 0 + + +def test_a_clip_is_not_redownloadable(db, make_user, as_user, yt_video): + c = as_user(make_user()) + job = _clip(c, source="youtube", ref=yt_video).json() + row = db.get(DownloadJob, job["id"]) + row.status = "done" + db.commit() + r = c.post(f"/api/downloads/{job['id']}/redownload") + assert (r.status_code, r.json()["detail"]["reason"]) == (409, "not_redownloadable") + + +# --- the worker ---------------------------------------------------------------------------------------- + + +def test_a_clip_job_reaches_the_clip_entry(monkeypatch): + called = [] + for name in ("_convert_upload", "_process_edit", "_download", "_process_clip"): + monkeypatch.setattr(pipeline, name, lambda *a, _n=name: called.append(_n)) + runner._run(5, 7, "clip", "clip", "1:youtube:abcdefghijk", {}, 1) + assert called == ["_process_clip"] + + +@pytest.fixture +def worker(db, tmp_path, monkeypatch): + """The worker path with its external programs replaced: ffmpeg writes the file it was given, the + probe answers what the test says, the phone-safe step and the poster pass through.""" + monkeypatch.setattr(settings, "download_root", str(tmp_path / "root")) + seen: dict = {} + + def ffmpeg(cmd, *_a, **_k): + seen["cmd"] = cmd + Path(cmd[-1]).write_bytes(b"clip") + + monkeypatch.setattr(pipeline.editmod, "run_ffmpeg", ffmpeg) + monkeypatch.setattr(pipeline, "_ensure_phone_safe", lambda _j, p, *_a, **_k: p) + monkeypatch.setattr(pipeline.editmod, "ensure_poster", lambda *_a, **_k: None) + monkeypatch.setattr(probemod, "probe_media", lambda _p: ("h264", "aac", 1920, 1080, 30)) + seen["probe"] = {"streams": [{"codec_type": "video"}, {"codec_type": "audio"}]} + monkeypatch.setattr(probemod, "probe_json", lambda _p: seen["probe"]) + + def run(job_json): + job = db.get(DownloadJob, job_json["id"]) + asset = db.get(MediaAsset, job.asset_id) + job.status, asset.status = "running", "downloading" + db.commit() + pipeline._process_clip(job.id, asset.id) + db.expire_all() + return db.get(DownloadJob, job.id), db.get(MediaAsset, asset.id) + + seen["run"] = run + return seen + + +def test_a_plex_clip_is_cut_from_the_library_file_with_its_audio_track( + db, make_user, as_user, plex_episode, worker +): + worker["probe"] = { + "streams": [ + {"codec_type": "video", "color_transfer": "smpte2084"}, + {"codec_type": "audio"}, + {"codec_type": "audio"}, + ] + } + job = _clip(as_user(make_user()), source="plex", ref="777", audio_track=1).json() + job, asset = worker["run"](job) + assert (job.status, asset.status, asset.container, asset.height) == ( + "done", + "ready", + "mp4", + 1080, + ) + cmd = worker["cmd"] + assert cmd[cmd.index("-i") + 1] == str(plex_episode) + assert cmd[cmd.index("-ss") + 1] == "10.000" + assert "0:a:1" in cmd and "tonemap" in cmd[cmd.index("-vf") + 1] + assert asset.rel_path.startswith(".edits/") and asset.rel_path.endswith(".mp4") + + +def test_a_plex_clip_of_a_track_the_file_does_not_have_fails_and_says_so( + db, make_user, as_user, plex_episode, worker +): + job = _clip(as_user(make_user()), source="plex", ref="777", audio_track=3).json() + job, asset = worker["run"](job) + assert (job.status, asset.status) == ("error", "error") + assert "audio track" in job.error + assert "cmd" not in worker + + +def test_a_youtube_clip_fetches_only_its_range_and_cuts_it_from_zero( + db, make_user, as_user, yt_video, worker, monkeypatch +): + asked: dict = {} + + def extract(job_id, asset_id, url, spec, staging, adapter, **kw): + asked.update(url=url, spec=spec, **kw) + f = Path(staging) / "abcdefghijk.mp4" + f.write_bytes(b"range") + return {"requested_downloads": [{"filepath": str(f)}]} + + monkeypatch.setattr(pipeline, "_extract_with_fallback", extract) + job = _clip(as_user(make_user()), source="youtube", ref=yt_video).json() + job, asset = worker["run"](job) + assert (job.status, asset.status) == ("done", "ready") + assert asked["section"] == (10, 40) + assert asked["spec"]["max_height"] == 1080 + assert asked["url"].endswith(yt_video) + cmd = worker["cmd"] + assert cmd[cmd.index("-ss") + 1] == "0.000" and cmd[cmd.index("-t") + 1] == "30.000" + assert cmd[cmd.index("-i") + 1].endswith("abcdefghijk.mp4") + + +def test_a_youtube_clip_hands_the_range_to_yt_dlp(monkeypatch, tmp_path): + seen = {} + + class FakeYDL: + def __init__(self, opts): + seen.update(opts) + + def __enter__(self): + return self + + def __exit__(self, *_a): + return False + + def extract_info(self, *_a, **_k): + return {} + + monkeypatch.setattr(pipeline.yt_dlp, "YoutubeDL", FakeYDL) + monkeypatch.setattr(pipeline, "_touch_job", lambda *_a: None) + adapter = pipeline.adapters.resolve_adapter("youtube", "abcdefghijk") + pipeline._extract_with_fallback( + 1, + 2, + "https://www.youtube.com/watch?v=abcdefghijk", + pipeline.formats.normalize({}), + tmp_path, + adapter, + section=(10.0, 40.0), + ) + ranges = list(seen["download_ranges"]({}, None)) + assert [(r["start_time"], r["end_time"]) for r in ranges] == [(10.0, 40.0)] + assert seen["force_keyframes_at_cuts"] is False + + +def test_service_reference_url_follows_the_clip_spec(db, make_user, as_user, yt_video): + job = _clip(as_user(make_user()), source="youtube", ref=yt_video, start_s=65, end_s=90).json() + row = db.get(DownloadJob, job["id"]) + assert service.reference_url(row, None) == f"https://www.youtube.com/watch?v={yt_video}&t=65s" diff --git a/backend/tests/test_phone_safe_contract.py b/backend/tests/test_phone_safe_contract.py index 50505f73..a15e1476 100644 --- a/backend/tests/test_phone_safe_contract.py +++ b/backend/tests/test_phone_safe_contract.py @@ -73,10 +73,17 @@ def test_every_path_to_ready_runs_the_rule(): found.append(where) if not runs_rule: skipped.append(where) - # The paths known today: a download, an editor clip, an upload's conversion, an upload kept as is. + # The paths known today: a download, an editor clip, an ad-hoc clip, an upload's conversion, an + # upload kept as is. # Fewer means the scan went blind (a rename, a new status spelling) — not that a path is gone. names = {w.rsplit(" ", 1)[1] for w in found} - assert {"_download", "_process_edit", "_convert_upload", "ingest_upload"} <= names, found + assert { + "_download", + "_process_edit", + "_process_clip", + "_convert_upload", + "ingest_upload", + } <= names, found assert skipped == [], f"marks an asset ready without the phone-safe rule: {skipped}" diff --git a/backend/tests/test_upload.py b/backend/tests/test_upload.py index f7360911..088e5f96 100644 --- a/backend/tests/test_upload.py +++ b/backend/tests/test_upload.py @@ -558,8 +558,8 @@ def _display_write_sites() -> dict[tuple[str, str], set[str]]: # Bounded by `display_text` unless noted: # service.redownload CLEARS the field so the fresh metadata wins — `None` is the # one value that needs no bound, and a clear is still a write. -# service.enqueue / enqueue_edit `display_name` is already bounded by its caller; the fallback -# slices the YouTube title to the same width. +# service.enqueue / enqueue_edit / `display_name` is already bounded by its caller; the fallback +# enqueue_clip slices the source's title to the same width. # downloads.upload_download passes through `spool_upload`'s value, bounded there. # # Held as EXACT EQUALITY rather than as a list of offenders. The offender form could only fail @@ -573,6 +573,7 @@ DISPLAY_WRITE_SITES = { "if asset.status == 'ready' and asset.title else None)" }, ("downloads/service.py", "enqueue_edit"): {"display_name or asset.title[:DISPLAY_TEXT_MAX]"}, + ("downloads/service.py", "enqueue_clip"): {"display_name or asset.title[:DISPLAY_TEXT_MAX]"}, ("downloads/upload.py", "ingest_upload"): {"display_text(display_name)"}, ("routes/auth.py", "callback"): {"display_text(userinfo.get('name'))"}, ("routes/auth.py", "_complete_link"): {"display_text(userinfo.get('name'))"}, diff --git a/frontend/src/components/downloadRow.tsx b/frontend/src/components/downloadRow.tsx index 7cc4689b..a9d4f3c0 100644 --- a/frontend/src/components/downloadRow.tsx +++ b/frontend/src/components/downloadRow.tsx @@ -268,7 +268,7 @@ export function DownloadRow({
- {job.job_kind === "edit" && ( + {(job.job_kind === "edit" || job.job_kind === "clip") && ( {t("downloads.clipBadge")} diff --git a/frontend/src/components/useDownloadActions.tsx b/frontend/src/components/useDownloadActions.tsx index d0696acf..d035d12c 100644 --- a/frontend/src/components/useDownloadActions.tsx +++ b/frontend/src/components/useDownloadActions.tsx @@ -59,6 +59,7 @@ const CANCEL_COPY: Record = { download: CANCEL_DOWNLOAD, upload: CANCEL_UPLOAD, edit: CANCEL_EDIT, + clip: CANCEL_EDIT, // both are a clip being cut: the same words }; // Deleting a download may leave its shared cache file for other holders. An upload and a clip are // their owner's alone (a clip's asset is keyed by its user), so deleting one removes the file for good. @@ -69,6 +70,7 @@ const DELETE_COPY: Record = { body: "downloads.confirm.deleteUploadBody", }, edit: { title: "downloads.confirm.deleteEditTitle", body: "downloads.confirm.deleteEditBody" }, + clip: { title: "downloads.confirm.deleteEditTitle", body: "downloads.confirm.deleteEditBody" }, }; export function useDownloadActions({ diff --git a/frontend/src/lib/api/downloads.ts b/frontend/src/lib/api/downloads.ts index aad414c6..01dcd8e4 100644 --- a/frontend/src/lib/api/downloads.ts +++ b/frontend/src/lib/api/downloads.ts @@ -44,10 +44,10 @@ interface DownloadAsset { } export type DownloadStatus = "queued" | "running" | "paused" | "done" | "error" | "canceled"; -// "download" (default), "edit" (a per-user trim/crop derivative cut from another job), or "upload" -// (a locally-uploaded file — no re-fetchable source, so re-download is rejected backend-side). -// Local (not exported): consumers compare `job.job_kind` against literals, never name the type. -export type DownloadJobKind = "download" | "edit" | "upload"; +// "download" (default), "edit" (a per-user trim/crop derivative cut from another job), "upload" +// (a locally-uploaded file) or "clip" (a marked range of a YouTube video or a Plex item, cut straight +// from the source). Only a download has a re-fetchable source; re-download refuses the rest. +export type DownloadJobKind = "download" | "edit" | "upload" | "clip"; // Phase-2 editor recipe. A single `trim` (one output file) or a `segments` cut-list joined into // one file. `accurate` re-encodes for a frame-accurate cut (a crop always re-encodes). diff --git a/frontend/src/lib/downloadActions.test.ts b/frontend/src/lib/downloadActions.test.ts index f638f195..2abe0a60 100644 --- a/frontend/src/lib/downloadActions.test.ts +++ b/frontend/src/lib/downloadActions.test.ts @@ -52,6 +52,7 @@ describe("libraryRowActions", () => { it("an edit clip cannot be re-downloaded", () => { expect(libraryRowActions(job({ job_kind: "edit" }, {})).redownload).toBe(false); + expect(libraryRowActions(job({ job_kind: "clip" }, {})).redownload).toBe(false); }); it("edit is hidden when the media has no duration", () => { diff --git a/frontend/src/lib/downloadActions.ts b/frontend/src/lib/downloadActions.ts index 2c97b761..022b37eb 100644 --- a/frontend/src/lib/downloadActions.ts +++ b/frontend/src/lib/downloadActions.ts @@ -22,7 +22,7 @@ export function libraryRowActions(job: DownloadJob): LibraryRowActions { play: media, edit: media && job.can_download && hasDuration(job), editDetails: media, - redownload: job.job_kind !== "edit" && job.job_kind !== "upload", + redownload: !job.job_kind || job.job_kind === "download", }; } diff --git a/frontend/src/lib/downloadFailure.test.ts b/frontend/src/lib/downloadFailure.test.ts index 8eba0bc3..0adcff5f 100644 --- a/frontend/src/lib/downloadFailure.test.ts +++ b/frontend/src/lib/downloadFailure.test.ts @@ -53,6 +53,7 @@ describe("failTitleKey", () => { await i18n.changeLanguage("en"); expect(failTitleKey({ job_kind: "upload" })).toBe("downloads.errors.fail.titleUpload"); expect(failTitleKey({ job_kind: "edit" })).toBe("downloads.errors.fail.titleEdit"); + expect(failTitleKey({ job_kind: "clip" })).toBe("downloads.errors.fail.titleEdit"); expect(failTitleKey({ job_kind: "download" })).toBe("downloads.errors.fail.title"); expect(failTitleKey({})).toBe("downloads.errors.fail.title"); for (const lng of ["en", "hu"]) { @@ -106,6 +107,15 @@ describe("failTitleKey", () => { ]); }); + it("a clip from YouTube or Plex is worded as the editor's clip", () => { + for (const part of ["short", "body"] as const) + expect( + failCopyKeys({ error_code: "unknown", error_detail: null, job_kind: "clip" }, part), + ).toEqual( + failCopyKeys({ error_code: "unknown", error_detail: null, job_kind: "edit" }, part), + ); + }); + it("an upload's own failures read as an upload's", async () => { await i18n.changeLanguage("en"); for (const code of ["convert_failed", "upload_missing"]) { diff --git a/frontend/src/lib/downloadFailure.ts b/frontend/src/lib/downloadFailure.ts index 72f3a4d5..5ab6c1cc 100644 --- a/frontend/src/lib/downloadFailure.ts +++ b/frontend/src/lib/downloadFailure.ts @@ -13,7 +13,8 @@ export function failCopyKeys( job: Pick, part: "short" | "body", ): string[] { - const kind = job.job_kind ?? "download"; + // A clip from YouTube or Plex is worded as the editor's clip: both are "making the clip". + const kind = job.job_kind === "clip" ? "edit" : (job.job_kind ?? "download"); const category = (code: string) => [ ...(kind === "download" ? [] : [`downloads.errors.fail.${kind}.${code}.${part}`]), `downloads.errors.fail.cat.${code}.${part}`, @@ -30,6 +31,7 @@ const TITLE_KEYS: Record = { download: "downloads.errors.fail.title", upload: "downloads.errors.fail.titleUpload", // its failure is a conversion, not a download edit: "downloads.errors.fail.titleEdit", // a clip being cut + clip: "downloads.errors.fail.titleEdit", // a clip being cut, from YouTube or Plex }; /** The title over a failed job's explanation, worded for what the job was making. */ From d466ae915bfc636b699cf93f457685aa4d353711 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 10 Oct 2026 02:43:13 +0200 Subject: [PATCH 04/23] fix(downloads): a YouTube clip outside the catalog takes its title from the fetch It was queued as "Clip (clip)" and stayed so: nothing renamed it once yt-dlp had answered. --- backend/app/downloads/pipeline.py | 30 +++++++++++++++++++++--------- backend/app/downloads/service.py | 8 ++++---- backend/tests/test_clip.py | 19 +++++++++++++++++++ 3 files changed, 44 insertions(+), 13 deletions(-) diff --git a/backend/app/downloads/pipeline.py b/backend/app/downloads/pipeline.py index f8c94d10..478cb428 100644 --- a/backend/app/downloads/pipeline.py +++ b/backend/app/downloads/pipeline.py @@ -907,9 +907,12 @@ def _process_edit(job_id: int, asset_id: int) -> None: shutil.rmtree(staging, ignore_errors=True) -def _clip_source(job_id: int, asset_id: int, spec: dict, staging: Path) -> tuple[Path, float]: - """The file a clip is cut from and where in it the clip starts. A YouTube clip fetches ONLY its - range into `staging` (so it starts at 0 there); a Plex clip reads the library file in place.""" +def _clip_source( + job_id: int, asset_id: int, spec: dict, staging: Path +) -> tuple[Path, float, str | None]: + """The file a clip is cut from, where in it the clip starts, and the source's title when only + the fetch could tell it. A YouTube clip fetches ONLY its range into `staging` (so it starts at + 0 there); a Plex clip reads the library file in place.""" if spec["source"] == "youtube": with SessionLocal() as db: job = db.get(DownloadJob, job_id) @@ -929,7 +932,9 @@ def _clip_source(job_id: int, asset_id: int, spec: dict, staging: Path) -> tuple max_bytes=remaining, section=(spec["start_s"], spec["end_s"]), ) - return _produced_file(_media_info(dict(info)), staging), 0.0 + info = _media_info(dict(info)) + title = normalize_title(adapters.resolve_adapter("youtube", spec["ref"]).title(info)) + return _produced_file(info, staging), 0.0, title with SessionLocal() as db: item = db.execute( select(PlexItem).where(PlexItem.rating_key == spec["ref"]) @@ -937,7 +942,7 @@ def _clip_source(job_id: int, asset_id: int, spec: dict, staging: Path) -> tuple src = plex_paths.local_media_path(db, item.file_path) if item else None if src is None: raise StatedFailure("The library file is not reachable from here.") - return src, spec["start_s"] + return src, spec["start_s"], None def _process_clip(job_id: int, asset_id: int) -> None: @@ -954,7 +959,7 @@ def _process_clip(job_id: int, asset_id: int) -> None: if staging.exists(): shutil.rmtree(staging, ignore_errors=True) staging.mkdir(parents=True, exist_ok=True) - src, start = _clip_source(job_id, asset_id, spec, staging) + src, start, fetched_title = _clip_source(job_id, asset_id, spec, staging) data = probe.probe_json(src) or {} audio_track = spec.get("audio_track") if audio_track is not None and audio_track >= clipmod.audio_track_count(data): @@ -1008,10 +1013,17 @@ def _process_clip(job_id: int, asset_id: int) -> None: asset.error = None asset.last_access_at = datetime.now(timezone.utc) asset.expires_at = datetime.now(timezone.utc) + timedelta(days=retention_days) + fields: dict = {"status": JobStatus.DONE, "progress": 100, "phase": None} + # A video outside the catalog was queued as "Clip (clip)": the fetch has its title now. + placeholder = service.clip_title(None, None) + if asset and fetched_title and asset.title == placeholder: + named = service.clip_title(fetched_title, None) + asset.title = named + if job_row := db.get(DownloadJob, job_id): + if job_row.display_name == placeholder: + fields["display_name"] = named[:DISPLAY_TEXT_MAX] title = asset.title if asset else None - finalized = service.finalize_if_running( - db, job_id, status=JobStatus.DONE, progress=100, phase=None - ) + finalized = service.finalize_if_running(db, job_id, **fields) db.commit() if finalized: _notify_download_done(db, user_id, title, job_id, kind="edit") diff --git a/backend/app/downloads/service.py b/backend/app/downloads/service.py index f2c4ef63..98e4f4ba 100644 --- a/backend/app/downloads/service.py +++ b/backend/app/downloads/service.py @@ -210,7 +210,7 @@ def enqueue( return job -def _clip_title(source_title: str | None, display_name: str | None) -> str: +def clip_title(source_title: str | None, display_name: str | None) -> str: # Clipped to DISPLAY_TEXT_MAX, not to a literal: this feeds `DownloadJob.display_name` through # `enqueue_edit`, the same column `display_text` guards. A machine-made title, so it is clipped # rather than run through `display_text` — blank here means "no title", not None. @@ -252,7 +252,7 @@ def enqueue_edit( ref = f"{user_id}:{src_asset.id}"[:512] asset = get_or_create_asset(db, "edit", ref, sig) if not asset.title: - asset.title = _clip_title(src_asset.title, display_name) + asset.title = clip_title(src_asset.title, display_name) asset.uploader = src_asset.uploader asset.thumbnail_url = src_asset.thumbnail_url asset.upload_date = src_asset.upload_date @@ -294,7 +294,7 @@ def _clip_source_facts(db: Session, spec: dict) -> dict: rather than as a job that fails in the queue a minute later.""" if spec["source"] == "youtube": video = db.get(Video, spec["ref"]) - if video is None: # not in the catalog: the worker fills the title from yt-dlp's answer + if video is None: # not in the catalog: titled "Clip" until yt-dlp names it (worker) return {"title": None, "uploader": None, "thumbnail_url": None, "length_s": None} channel = db.get(Channel, video.channel_id) return { @@ -346,7 +346,7 @@ def enqueue_clip( ref = clipmod.asset_ref(user_id, spec)[:512] asset = get_or_create_asset(db, "clip", ref, clipmod.sig(spec)) if not asset.title: - asset.title = _clip_title(facts["title"], display_name) + asset.title = clip_title(facts["title"], display_name) asset.uploader = facts["uploader"] asset.thumbnail_url = facts["thumbnail_url"] asset.duration_s = round(clipmod.duration(spec)) diff --git a/backend/tests/test_clip.py b/backend/tests/test_clip.py index 1913bb89..8c7451be 100644 --- a/backend/tests/test_clip.py +++ b/backend/tests/test_clip.py @@ -421,3 +421,22 @@ def test_service_reference_url_follows_the_clip_spec(db, make_user, as_user, yt_ job = _clip(as_user(make_user()), source="youtube", ref=yt_video, start_s=65, end_s=90).json() row = db.get(DownloadJob, job["id"]) assert service.reference_url(row, None) == f"https://www.youtube.com/watch?v={yt_video}&t=65s" + + +def test_a_youtube_clip_outside_the_catalog_takes_its_title_from_the_fetch( + db, make_user, as_user, worker, monkeypatch +): + def extract(job_id, asset_id, url, spec, staging, adapter, **kw): + f = Path(staging) / "zyxwvutsrqp.mp4" + f.write_bytes(b"range") + return {"requested_downloads": [{"filepath": str(f)}], "title": "Found On YouTube"} + + monkeypatch.setattr(pipeline, "_extract_with_fallback", extract) + job = _clip(as_user(make_user()), source="youtube", ref="zyxwvutsrqp").json() + assert job["display_name"] == "Clip (clip)" + job, asset = worker["run"](job) + assert (job.status, asset.title, job.display_name) == ( + "done", + "Found On YouTube (clip)", + "Found On YouTube (clip)", + ) From f221c1962ab0ffbbdb9fc3676ebfe5e04c94e6ca Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 10 Oct 2026 03:58:27 +0200 Subject: [PATCH 05/23] fix(downloads): a YouTube clip goes with the user's own cookie and is diagnosed like a download A clip fetched its range signed out, so a video behind YouTube's per-video "not a bot" wall could never be clipped while its download went through with the user's cookie - and the failure copy told the user to add a cookie the clip would not use. The clip asset is private by construction (`clip.asset_ref` names its user), so `usable_clip_cookie` attaches the user's YouTube cookie on that identity, and a failed range fetch now carries the download catalog's diagnosis (`youtube.bot_wall` / `youtube.cookie_rejected`) instead of a bare category. --- backend/app/downloads/credentials.py | 31 +++++++++++ backend/app/downloads/pipeline.py | 79 +++++++++++++++++++++------- backend/tests/test_clip.py | 78 +++++++++++++++++++++++++++ 3 files changed, 170 insertions(+), 18 deletions(-) diff --git a/backend/app/downloads/credentials.py b/backend/app/downloads/credentials.py index f7cef6cd..648c441d 100644 --- a/backend/app/downloads/credentials.py +++ b/backend/app/downloads/credentials.py @@ -30,6 +30,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session from app import logs +from app.downloads import clip as clipmod from app.downloads import formats from app.downloads.adapters import _host_is from app.models import DownloadCredential, MediaAsset @@ -259,6 +260,28 @@ def usable_cookie( return None if not is_authed_asset(asset, spec, user_id, url): return None # shared asset — never attach a cookie to a non-private identity + return _readable(cred, user_id, host_key) + + +def usable_clip_cookie( + db: Session, user_id: int, url: str | None, asset: MediaAsset, clip_spec: dict +) -> str | None: + """`usable_cookie` for an ad-hoc clip's range fetch. The second gate is the clip's own identity: + a clip asset is private by construction (`clip.asset_ref` names its user, see downloads/clip), + so a cookie goes with it exactly when that asset is THIS user's clip of `clip_spec`. Without it a + video YouTube gates signed out could never be clipped, while its download goes through.""" + host_key = host_key_for_url(url) + if host_key is None: + return None + cred = get_credential(db, user_id, host_key) + if cred is None: + return None + if asset.source_kind != "clip" or asset.source_ref != clipmod.asset_ref(user_id, clip_spec): + return None + return _readable(cred, user_id, host_key) + + +def _readable(cred: DownloadCredential, user_id: int, host_key: str) -> str | None: raw = decrypt_optional(cred.cookies_enc) if not raw: log.warning( @@ -284,6 +307,14 @@ def resolve_cookiefile( return _write_cookiefile(raw, staging) if raw else None +def resolve_clip_cookiefile( + db: Session, user_id: int, url: str | None, asset: MediaAsset, clip_spec: dict, staging: Path +) -> Path | None: + """`resolve_cookiefile` for an ad-hoc clip (`usable_clip_cookie`).""" + raw = usable_clip_cookie(db, user_id, url, asset, clip_spec) + return _write_cookiefile(raw, staging) if raw else None + + def _write_cookiefile(raw: str, staging: Path) -> Path: """Write the cookie blob to a private 0600 file under `staging/.auth/` and return its path. diff --git a/backend/app/downloads/pipeline.py b/backend/app/downloads/pipeline.py index 478cb428..591ab6e1 100644 --- a/backend/app/downloads/pipeline.py +++ b/backend/app/downloads/pipeline.py @@ -41,6 +41,15 @@ class _Aborted(Exception): """Raised inside the progress hook when the job was paused/canceled mid-download.""" +class _ClipFetchFailed(Exception): + """A clip's YouTube range fetch failed: its message and the download catalog's diagnosis, so a + clip on YouTube's wall says what a download of the same video would.""" + + def __init__(self, msg: str, diagnosis: dlerrors.Diagnosis): + super().__init__(msg) + self.diagnosis = diagnosis + + class _OwnerRefused(Exception): """A refusal that belongs to THIS job's owner, not to the source. @@ -351,7 +360,8 @@ def _extract_with_fallback( download as itself. `cookiefile` is the acting user's per-host cookies.txt, already gated by - `credentials.resolve_cookiefile`; passed to every attempt so the fallback stays authenticated. + `credentials.resolve_cookiefile` (a clip: `resolve_clip_cookiefile`); passed to every attempt so + the fallback stays authenticated. `max_bytes` (SEC-13) is the owner's remaining storage budget, applied to every attempt. @@ -747,11 +757,18 @@ def _log_download_failed(job_id: int, msg: str, code: str | None, exc: BaseExcep def _fail_edit( - job_id: int, asset_id: int, msg: str, exc: BaseException | None = None, *, what: str = "edit" + job_id: int, + asset_id: int, + msg: str, + exc: BaseException | None = None, + *, + what: str = "edit", + diagnosis: dlerrors.Diagnosis | None = None, ) -> None: - """Fail a local ffmpeg job — an edit, or an upload's conversion (`what="upload"`) — and say so - ONCE: every way one fails comes through here, so this is where its line and its event code - (`.job.failed`) live. `exc` is the exception when there was one. + """Fail a local ffmpeg job — an edit, a clip, or an upload's conversion (`what="upload"`) — and + say so ONCE: every way one fails comes through here, so this is where its line and its event code + (`.job.failed`) live. `exc` is the exception when there was one; `diagnosis` replaces the + plain category when the failure was a fetch the download catalog can name (a clip's). Its traceback goes with the line unless the message already tells it all (`_failure_stack`). Logged after the write, as on the download error path.""" @@ -766,9 +783,12 @@ def _fail_edit( job_id, status=JobStatus.ERROR, error=msg, - error_code=dlerrors.classify_conversion(msg) + error_code=diagnosis.code + if diagnosis + else dlerrors.classify_conversion(msg) if what == "upload" else dlerrors.classify(msg), + error_detail=diagnosis.detail if diagnosis else None, phase=None, ) db.commit() @@ -914,24 +934,44 @@ def _clip_source( the fetch could tell it. A YouTube clip fetches ONLY its range into `staging` (so it starts at 0 there); a Plex clip reads the library file in place.""" if spec["source"] == "youtube": + url = service.source_url("youtube", spec["ref"]) + cookiefile: Path | None = None with SessionLocal() as db: job = db.get(DownloadJob, job_id) + asset = db.get(MediaAsset, asset_id) remaining = quota.remaining_bytes(db, job.user_id) if job else None + # Signed in when the user has a YouTube cookie, as their downloads are: YouTube gates + # single videos signed out ("not a bot"), and the clip is theirs alone. + if job and asset: + cookiefile = credentials.resolve_clip_cookiefile( + db, job.user_id, url, asset, spec, staging + ) if remaining == 0: # see `_download`: zero is a refusal, not "no limit" raise _OwnerRefused(dlerrors.QUOTA_FULL_MESSAGE) _set_job(job_id, phase="downloading", progress=0) - # Signed out, like a download without a cookie: the cookie gate is written for a download's - # salted asset identity (`credentials.resolve_cookiefile`), not for a clip's. - info = _extract_with_fallback( - job_id, - asset_id, - service.source_url("youtube", spec["ref"]), - formats.normalize(clipmod.YOUTUBE_FETCH_SPEC), - staging, - adapters.resolve_adapter("youtube", spec["ref"]), - max_bytes=remaining, - section=(spec["start_s"], spec["end_s"]), - ) + try: + info = _extract_with_fallback( + job_id, + asset_id, + url, + formats.normalize(clipmod.YOUTUBE_FETCH_SPEC), + staging, + adapters.resolve_adapter("youtube", spec["ref"]), + cookiefile=str(cookiefile) if cookiefile else None, + max_bytes=remaining, + section=(spec["start_s"], spec["end_s"]), + ) + except (_Aborted, _OwnerRefused): + raise + except Exception as exc: # re-raised below with the download catalog's diagnosis + msg = str(exc)[:500] + raise _ClipFetchFailed( + msg, dlerrors.diagnose(msg, url=url, cookie_attached=cookiefile is not None) + ) from exc + finally: + # Never leave a plaintext session cookie on disk past the fetch (see `_download`). + if cookiefile is not None: + cookiefile.unlink(missing_ok=True) info = _media_info(dict(info)) title = normalize_title(adapters.resolve_adapter("youtube", spec["ref"]).title(info)) return _produced_file(info, staging), 0.0, title @@ -1065,6 +1105,9 @@ def _process_clip(job_id: int, asset_id: int) -> None: formats.log_safe(msg), extra=logs.event("clip.job.refused", job_id=job_id), ) + except _ClipFetchFailed as exc: + # silent-ok: not silent — `_fail_edit` is the report, with the fetch's diagnosis. + _fail_edit(job_id, asset_id, str(exc), exc, what="clip", diagnosis=exc.diagnosis) except Exception as exc: # noqa: BLE001 — surface the failure, keep the worker alive # silent-ok: not silent — `_fail_edit` is the report, and it takes `exc` for its stack. _fail_edit(job_id, asset_id, str(exc)[:500], exc, what="clip") diff --git a/backend/tests/test_clip.py b/backend/tests/test_clip.py index 8c7451be..8371a11f 100644 --- a/backend/tests/test_clip.py +++ b/backend/tests/test_clip.py @@ -440,3 +440,81 @@ def test_a_youtube_clip_outside_the_catalog_takes_its_title_from_the_fetch( "Found On YouTube (clip)", "Found On YouTube (clip)", ) + + +# --- a YouTube clip goes with the user's own cookie, and its failure is diagnosed ----------------- + +_YT_COOKIES = "# Netscape HTTP Cookie File\n.youtube.com\tTRUE\t/\tTRUE\t0\tSID\tabc\n" + + +@pytest.fixture +def fernet(monkeypatch): + from cryptography.fernet import Fernet + + from app import security + + monkeypatch.setattr(security, "_fernet", Fernet(Fernet.generate_key())) + + +def _capture_extract(monkeypatch, asked: dict): + def extract(job_id, asset_id, url, spec, staging, adapter, **kw): + cookiefile = kw.get("cookiefile") + asked["cookiefile"] = cookiefile + asked["cookie_text"] = Path(cookiefile).read_text() if cookiefile else None + f = Path(staging) / "abcdefghijk.mp4" + f.write_bytes(b"range") + return {"requested_downloads": [{"filepath": str(f)}]} + + monkeypatch.setattr(pipeline, "_extract_with_fallback", extract) + + +def test_a_youtube_clip_goes_signed_in_with_the_users_own_cookie_and_leaves_none_behind( + db, make_user, as_user, yt_video, worker, monkeypatch, fernet +): + from app.downloads import credentials + + user = make_user() + credentials.upsert_credential(db, user.id, "youtube", _YT_COOKIES) + db.commit() + asked: dict = {} + _capture_extract(monkeypatch, asked) + job, _asset = worker["run"](_clip(as_user(user), source="youtube", ref=yt_video).json()) + assert job.status == "done" + assert asked["cookie_text"] == _YT_COOKIES + assert not Path(asked["cookiefile"]).exists() + + +def test_a_youtube_clip_never_goes_with_another_users_cookie( + db, make_user, as_user, yt_video, worker, monkeypatch, fernet +): + from app.downloads import credentials + + other = make_user() + credentials.upsert_credential(db, other.id, "youtube", _YT_COOKIES) + db.commit() + asked: dict = {} + _capture_extract(monkeypatch, asked) + job, _asset = worker["run"](_clip(as_user(make_user()), source="youtube", ref=yt_video).json()) + assert job.status == "done" and asked["cookiefile"] is None + + +@pytest.mark.parametrize( + ("with_cookie", "detail"), [(False, "youtube.bot_wall"), (True, "youtube.cookie_rejected")] +) +def test_a_youtube_clip_on_the_bot_wall_is_diagnosed_like_a_download( + db, make_user, as_user, yt_video, worker, monkeypatch, fernet, with_cookie, detail +): + from app.downloads import credentials + + user = make_user() + if with_cookie: + credentials.upsert_credential(db, user.id, "youtube", _YT_COOKIES) + db.commit() + + def walled(*_a, **_k): + raise RuntimeError(f"ERROR: [youtube] {yt_video}: Sign in to confirm you’re not a bot.") + + monkeypatch.setattr(pipeline, "_extract_with_fallback", walled) + job, asset = worker["run"](_clip(as_user(user), source="youtube", ref=yt_video).json()) + assert (job.status, asset.status) == ("error", "error") + assert (job.error_code, job.error_detail) == ("login_required", detail) From 50157cc2fd389c5d023adc685209945ca7e0c4f9 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 10 Oct 2026 03:58:27 +0200 Subject: [PATCH 06/23] feat(player): clip mode - mark a range of what is playing and cut it into a download Both players get a scissors in their control row (Plex: the control row; YouTube: the actions and the docked bar), `c`, and `[` / `]` to mark at the playhead. The clip panel lives in the player, so the watched video is the preview: an overview of the whole item and a zoomed strip around the selection whose ends are sliders (arrows step a tenth, Shift a second), Start / End fields with nudges and "to the playhead", the length, a looping Play selection, a name, Cut. Escape closes the panel before the player and the focus goes back to the scissors. A cut is a toast with a Downloads link plus the usual Ready notification. - ui/RangeTrack + RulerTicks: the shared, domain-free time track (regions, keyboard-steppable handles with a 1.5rem hit area, a playhead, a background slot, over a window of the item). The Downloads editor's cuts moved onto it, so they are keyboard-steppable now. - lib/clipRange holds the marking rules (1 s to 10 min, the server's), lib/timecode the editing timecode the editor's private tc/parseTc became. - keymap: a symbol typed through AltGr answers its bare-symbol row - on the HU layout `[` is AltGr+F, which arrives as Ctrl+Alt; letter and Mod rows still never match Ctrl+Alt. - The YouTube stage sits in a wrapper with the panel, so the maximised stage's blur escape matches at any depth. - media-scope: fields on the always-dark player chrome read white in a light theme too. --- docs/DESIGN.md | 15 + .../src/components/ClipPanel.dom.test.tsx | 159 +++++ frontend/src/components/ClipPanel.tsx | 336 +++++++++ .../src/components/PlayerModal.dom.test.tsx | 72 +- frontend/src/components/PlayerModal.tsx | 406 +++++++---- .../PlexPlayer.controls.dom.test.tsx | 57 ++ frontend/src/components/PlexPlayer.tsx | 650 ++++++++++-------- .../VideoEditor.partial.dom.test.tsx | 23 + frontend/src/components/VideoEditor.tsx | 242 +++---- .../src/components/plexPlayerControls.tsx | 5 +- frontend/src/components/ui/README.md | 14 +- .../src/components/ui/RangeTrack.dom.test.tsx | 157 +++++ frontend/src/components/ui/RangeTrack.tsx | 270 ++++++++ frontend/src/i18n/locales/en/clip.json | 21 + frontend/src/i18n/locales/en/editor.json | 4 +- frontend/src/i18n/locales/en/keys.json | 4 +- frontend/src/i18n/locales/hu/clip.json | 21 + frontend/src/i18n/locales/hu/editor.json | 4 +- frontend/src/i18n/locales/hu/keys.json | 4 +- frontend/src/index.css | 18 +- frontend/src/lib/api/downloads.ts | 13 + frontend/src/lib/clipRange.test.ts | 65 ++ frontend/src/lib/clipRange.ts | 55 ++ frontend/src/lib/keymap.test.ts | 26 + frontend/src/lib/keymap.ts | 17 + frontend/src/lib/playerStage.contract.test.ts | 2 +- frontend/src/lib/timecode.test.ts | 36 + frontend/src/lib/timecode.ts | 27 + 28 files changed, 2121 insertions(+), 602 deletions(-) create mode 100644 frontend/src/components/ClipPanel.dom.test.tsx create mode 100644 frontend/src/components/ClipPanel.tsx create mode 100644 frontend/src/components/ui/RangeTrack.dom.test.tsx create mode 100644 frontend/src/components/ui/RangeTrack.tsx create mode 100644 frontend/src/i18n/locales/en/clip.json create mode 100644 frontend/src/i18n/locales/hu/clip.json create mode 100644 frontend/src/lib/clipRange.test.ts create mode 100644 frontend/src/lib/clipRange.ts create mode 100644 frontend/src/lib/timecode.test.ts create mode 100644 frontend/src/lib/timecode.ts diff --git a/docs/DESIGN.md b/docs/DESIGN.md index b012c01c..8dbca4d4 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -545,6 +545,20 @@ of a full-width panel and nothing on the right. open. Stacked, the description opens from an ⓘ, because a hover popover never opens on a tap (measured live). The wheel: over the video it sets the volume; over a panel with something to scroll it scrolls only, never the volume, even at its end; over one with nothing to scroll it sets the volume. [WCAG 1.4.13] +- **Clip mode (both players, owner's picks A + P1 + a):** a scissors in the control row (Plex: the C5 row, + before the ⋯; YouTube: the actions beside Download, and the docked bar) or `C` opens a clip panel IN the + player; `[` / `]` mark the start / end at the playhead and open it when shut. The video being watched is the + preview — no second stream. Plex: the panel takes the seek bar's and the row's place. YouTube: it sits under + the video (the card's fit counts it, so the video shrinks to make room), and a big view steps back to the + card. The panel: an overview of the whole item (the selection and the zoomed window on it), a zoomed strip + around the selection with the two ends (`ui/RangeTrack`: each end a slider — arrows step a tenth, + Shift a second; drawn 4px, hit 1.5rem), Start / End fields with a tenth back / forward and "to the + playhead", the length, Play selection (loops), a name, Cancel / Cut clip. On the always-dark chrome the + ends are white and the playhead amber (a light theme's accent all but vanished there). The strip's window + follows the selection when it settles, never under a dragging pointer. Plex seeks once a drag is + released (a seek may restart the transcode); YouTube follows the drag. Escape closes the panel before the + player; the scissors gets the focus back. A cut is a toast with a Downloads link and the usual + "Ready to watch" notification. Limits are the server's: 1 s to 10 min (`lib/clipRange`). ## Dialogs — _Decided_ (C4, built in S3f) @@ -730,3 +744,4 @@ of a full-width panel and nothing on the right. | 2026-10-08 | YouTube modal chrome | No prev/next strips beside the card; Close an icon (the panel's top right; stacked, the title row's end); the description clamped to three lines + More; the panel shuts to a 2.75rem strip of close, expand, prev/next (option D1) | Keep the strips; a "Close" text button; the full description in a scroller; a panel that hides outright | The strips cost the video 7rem of width for a second copy of the panel's buttons; a long description pushed the channel and the actions out of sight; a hidden panel left no way to step the queue without the keyboard | | 2026-10-08 | The wheel over the YouTube modal | Over the video: volume. Over a panel with something to scroll: scroll only, never volume, not even at its end. Over a panel with nothing to scroll: volume (owner) | Volume everywhere; a scroll that hands over to the volume at its end | Reading a long description turned the volume down once it hit the end | | 2026-10-09 | A Plex show card plays | Its picture plays the show's next episode everywhere (an episode with a resume point → Continue + Restart, else the first unwatched, else S1·E1), resolved on the click; the preview names it once up; title and ⓘ open the show (option A + hover ask) | The picture opens the show page (as before); every card carrying `next` | A show card on Discover had no Play while Home's had one; carrying `next` costs a grid page up to 180 queries for the few cards anyone plays | +| 2026-10-10 | Clip mode in the players (E38) | Scissors in the control row + `C`, `[` `]`; the panel in the player with the watched video as preview; overview + zoomed strip on `ui/RangeTrack` (ends are sliders); a toast + the Ready notification after Cut | A ⋯ menu entry; permanent In / Out buttons; a big dialog with its own preview (a second Plex transcode / YouTube iframe) | Owner's picks A, P1, a; a film is hours and a clip seconds, so only a zoomed strip gives a hand the precision. The Downloads editor's cuts moved onto the same track (keyboard-steppable now) | diff --git a/frontend/src/components/ClipPanel.dom.test.tsx b/frontend/src/components/ClipPanel.dom.test.tsx new file mode 100644 index 00000000..bb6f3ea2 --- /dev/null +++ b/frontend/src/components/ClipPanel.dom.test.tsx @@ -0,0 +1,159 @@ +// @vitest-environment jsdom +// +// The clip panel: it opens on its In handle, every way of marking keeps a range the server takes, +// Cut sends the range (and a Plex clip's audio track) and closes, a refused Cut stays open and says +// so, and "Play selection" loops back to In. The range is the caller's state, so the test holds it. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState } from "react"; +import type { ClipRequest } from "../lib/api"; +import type { ClipRange } from "../lib/clipRange"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useTranslation: () => ({ t: (k: string) => k }) }; +}); + +const enqueueClip = vi.fn<(body: ClipRequest) => Promise>(); +vi.mock("../lib/api", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, api: { ...actual.api, enqueueClip: (b: ClipRequest) => enqueueClip(b) } }; +}); +const notify = vi.fn(); +vi.mock("../lib/notifications", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, notify: (n: unknown) => notify(n) }; +}); +const notifyActionError = vi.fn(); +vi.mock("../lib/actionErrors", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, notifyActionError: (e: unknown, t: string) => notifyActionError(e, t) }; +}); + +import ClipPanel, { type ClipSource } from "./ClipPanel"; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); +beforeEach(() => { + // A block, not an expression: a function returned from beforeEach is run as its teardown. + enqueueClip.mockResolvedValue({ id: 1 }); +}); + +function Harness({ + src = { source: "plex", ref: "777", audioTrack: 1 }, + start = { start: 100, end: 130 }, + current = 100, + onClose = () => {}, + onPlayFrom = () => {}, + onSeek = () => {}, +}: { + src?: ClipSource; + start?: ClipRange; + current?: number; + onClose?: () => void; + onPlayFrom?: (t: number) => void; + onSeek?: (t: number, final: boolean) => void; +}) { + const [clip, setClip] = useState(start); + return ( + + {`${clip.start}-${clip.end}`} + + + ); +} + +const range = () => screen.getByTestId("range").textContent; + +describe("ClipPanel", () => { + it("opens with the focus on its In handle, so the arrows move the mark at once", () => { + render(); + const handle = screen.getByRole("slider", { name: "clip.in" }); + expect(document.activeElement).toBe(handle); + fireEvent.keyDown(handle, { key: "ArrowLeft", shiftKey: true }); + expect(range()).toBe("99-130"); + }); + + it("takes a typed time on Enter and carries the other end along past it", () => { + render(); + const [inField] = screen.getAllByRole("textbox", { name: "clip.in" }); + fireEvent.change(inField!, { target: { value: "2:20" } }); + fireEvent.keyDown(inField!, { key: "Enter" }); + expect(range()).toBe("140-170"); + }); + + it("marks an end at the playhead", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "clip.outHere" })); + expect(range()).toBe("100-115.5"); + }); + + it("cuts the range with the Plex audio track and closes", async () => { + const onClose = vi.fn(); + render(); + fireEvent.change(screen.getByRole("textbox", { name: "clip.name" }), { + target: { value: " The bit " }, + }); + fireEvent.click(screen.getByRole("button", { name: "clip.cut" })); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + expect(enqueueClip).toHaveBeenCalledWith({ + source: "plex", + ref: "777", + start_s: 100.123, + end_s: 130, + audio_track: 1, + display_name: "The bit", + }); + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ level: "success", message: "clip.queued" }), + ); + }); + + it("sends no audio track for a YouTube clip", async () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "clip.cut" })); + await waitFor(() => expect(enqueueClip).toHaveBeenCalled()); + const body = enqueueClip.mock.calls[0]![0]; + expect(body.source).toBe("youtube"); + expect(Object.keys(body)).not.toContain("audio_track"); + }); + + it("stays open and says so when the cut is refused", async () => { + const onClose = vi.fn(); + const err = new Error("boom"); + enqueueClip.mockRejectedValue(err); + render(); + fireEvent.click(screen.getByRole("button", { name: "clip.cut" })); + await waitFor(() => expect(notifyActionError).toHaveBeenCalledTimes(1)); + expect(notifyActionError.mock.calls[0]![0]).toBe(err); + expect(notifyActionError.mock.calls[0]![1]).toBe("clip.failed"); + expect(onClose).not.toHaveBeenCalled(); + expect((screen.getByRole("button", { name: "clip.cut" }) as HTMLButtonElement).disabled).toBe( + false, + ); + }); + + it("loops the selection: playing past End goes back to Start", () => { + const onPlayFrom = vi.fn(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "clip.loop" })); + expect(onPlayFrom).toHaveBeenLastCalledWith(100); + onPlayFrom.mockClear(); + act(() => rerender()); + expect(onPlayFrom).not.toHaveBeenCalled(); + act(() => rerender()); + expect(onPlayFrom).toHaveBeenLastCalledWith(100); + }); +}); diff --git a/frontend/src/components/ClipPanel.tsx b/frontend/src/components/ClipPanel.tsx new file mode 100644 index 00000000..0c5f35c2 --- /dev/null +++ b/frontend/src/components/ClipPanel.tsx @@ -0,0 +1,336 @@ +import { useEffect, useEffectEvent, useRef, useState, type SubmitEvent } from "react"; +import { useTranslation } from "react-i18next"; +import { useQueryClient } from "@tanstack/react-query"; +import { Crosshair, Minus, Plus, Repeat, Scissors, X } from "lucide-react"; +import { api, type ClipRequest } from "../lib/api"; +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 { handleBounds, markIn, markOut, zoomWindow, type ClipRange } from "../lib/clipRange"; +import { RangeTrack, RulerTicks, type RangeWindow } from "./ui/RangeTrack"; +import { Button } from "./ui/Button"; +import { IconButton } from "./ui/IconButton"; +import { Input } from "./ui/Input"; + +// 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 +// being watched is the preview: dragging a handle or pressing a timeline moves the player there. +// +// Two tracks: the whole item, with the selection and the zoomed window on it, and a zoomed strip +// around the selection where the handles are. The strip's window follows the selection when it +// settles, not while a handle is being dragged — a window that moved under the pointer would run +// away from the drag. A film is hours long and a clip seconds: only the strip gives a hand the +// precision, and the fields and their tenth-of-a-second steps give the rest. +// +// The range itself is the caller's state (`clip`), so the player's `[` / `]` keys and this panel +// mark the same range by the same rules (lib/clipRange). + +export interface ClipSource { + source: ClipRequest["source"]; + ref: string; + /** Plex: the audio track that is playing, which the clip carries. */ + audioTrack?: number | null; +} + +interface Props { + src: ClipSource; + clip: ClipRange; + onChange: (r: ClipRange) => void; + duration: number; + /** The player's position, live. */ + current: number; + /** Move the player; `final` false while a pointer still drags (a player whose seek is costly waits). */ + onSeek: (time: number, final: boolean) => void; + /** Play from `time` (the loop's restart, and "Play selection"). */ + onPlayFrom: (time: number) => void; + onClose: () => void; +} + +const NUDGE_S = 0.1; +// 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. +const PLAYHEAD = "bg-warn"; + +export default function ClipPanel({ + src, + clip, + onChange, + duration, + current, + onSeek, + onPlayFrom, + onClose, +}: Props) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const rootRef = useRef(null); + const [name, setName] = useState(""); + const [busy, setBusy] = useState(false); + const [looping, setLooping] = useState(false); + // The strip's window while a handle drags (null at rest: it follows the selection). + const [frozen, setFrozen] = useState(null); + const hours = duration >= 3600; + const tc = (s: number) => formatTimecode(s, hours); + const view = frozen ?? zoomWindow(clip, duration); + const bounds = handleBounds(clip, duration); + const length = clip.end - clip.start; + + // Opened from a button or a key, the panel takes the focus to its In handle: the button that + // opened it is gone (its row is this panel now), and the arrows then move the mark at once. + useEffect(() => { + rootRef.current?.querySelector('[data-handle="in"]')?.focus(); + }, []); + + // "Play selection" loops: past Out, back to In. + const restart = useEffectEvent(() => onPlayFrom(clip.start)); + useEffect(() => { + if (looping && current >= clip.end) restart(); + }, [looping, current, clip.end]); + + const moveIn = (time: number, final: boolean) => { + setFrozen(final ? null : (frozen ?? view)); + onChange(markIn(clip, time, duration)); + onSeek(time, final); + }; + const moveOut = (time: number, final: boolean) => { + setFrozen(final ? null : (frozen ?? view)); + onChange(markOut(clip, time, duration)); + onSeek(time, final); + }; + + const body: ClipRequest = { + source: src.source, + ref: src.ref, + start_s: Math.round(clip.start * 1000) / 1000, + end_s: Math.round(clip.end * 1000) / 1000, + ...(src.audioTrack != null ? { audio_track: src.audioTrack } : {}), + ...(name.trim() ? { display_name: name.trim() } : {}), + }; + const submit = async (e: SubmitEvent) => { + e.preventDefault(); + if (busy) return; + setBusy(true); + try { + await api.enqueueClip(body); + invalidateDownloads(qc); + log.info("clip.queued", "clip queued", { source: src.source, seconds: Math.round(length) }); + notify({ + level: "success", + message: t("clip.queued"), + action: { label: t("clip.view"), onClick: () => navigateTo("downloads") }, + }); + onClose(); + } catch (err) { + // A refusal the server explains (too long, quota) is already in the error dialog; a lost + // connection or a 404 is not, and must not leave the panel looking as if nothing happened. + notifyActionError(err, t("clip.failed")); + setBusy(false); + } + }; + + return ( +
e.stopPropagation()} + > +
+ + + final && onSeek(time, true)} + className="h-5 flex-1 cursor-pointer rounded-sm bg-white/15" + regions={[ + { + key: "window", + start: view.from, + end: view.to, + className: "border-x border-white/50", + }, + { key: "sel", start: clip.start, end: clip.end, className: "bg-accent/70" }, + ]} + /> + + + +
+ + formatTimecode(s, hours).replace(/\.\d$/, "")} /> + } + regions={[ + { + key: "sel", + start: clip.start, + end: clip.end, + className: "bg-accent/25 border-y-2 border-accent", + }, + ]} + handles={[ + { + id: "in", + label: t("clip.in"), + time: clip.start, + ...bounds.in, + valueText: tc(clip.start), + step: NUDGE_S, + bigStep: 1, + onChange: moveIn, + barClassName: "bg-white", + }, + { + id: "out", + label: t("clip.out"), + time: clip.end, + ...bounds.out, + valueText: tc(clip.end), + step: NUDGE_S, + bigStep: 1, + onChange: moveOut, + barClassName: "bg-white", + }, + ]} + /> + +
+ moveIn(v, true)} + nudge={NUDGE_S} + hereLabel={t("clip.inHere")} + onHere={() => moveIn(current, true)} + /> + moveOut(v, true)} + nudge={NUDGE_S} + hereLabel={t("clip.outHere")} + onHere={() => moveOut(current, true)} + /> + + {t("clip.length", { length: formatTimecode(length) })} + + +
+ +
+ setName(e.target.value)} + placeholder={t("clip.namePlaceholder")} + aria-label={t("clip.name")} + /> + + +
+ + ); +} + +/** One end's field: the time typed (Enter or leaving the field sets it), a tenth back and forward, + * and "to the playhead". */ +function MarkField({ + label, + value, + text, + onSet, + nudge, + hereLabel, + onHere, +}: { + label: string; + value: number; + text: string; + 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 ( +
+ {label} + onSet(value - nudge)} + label={t("clip.earlier", { label })} + > + + + commit(e.target.value)} + onKeyDown={(e) => { + if (e.key !== "Enter") return; + e.preventDefault(); + commit(e.currentTarget.value); + }} + /> + onSet(value + nudge)} + label={t("clip.later", { label })} + > + + + + + +
+ ); +} diff --git a/frontend/src/components/PlayerModal.dom.test.tsx b/frontend/src/components/PlayerModal.dom.test.tsx index 8bc2ea07..0224f1d8 100644 --- a/frontend/src/components/PlayerModal.dom.test.tsx +++ b/frontend/src/components/PlayerModal.dom.test.tsx @@ -476,7 +476,10 @@ describe("PlayerModal — no side strips", () => { ); expect(screen.getAllByRole("button", { name: "player.next" })).toHaveLength(1); expect(screen.getAllByRole("button", { name: "player.previous" })).toHaveLength(1); - const row = document.querySelector(".player-stage")!.parentElement!.parentElement!; + // stage → its wrapper (with the clip panel) → the card → the row + const row = + document.querySelector(".player-stage")!.parentElement!.parentElement! + .parentElement!; expect(row.className).not.toMatch(/px-14/); }); @@ -574,14 +577,17 @@ describe("PlayerModal — fits the window's height", () => { vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation(function ( this: HTMLElement, ) { - return this.querySelector(":scope > .player-stage") ? 630 : 0; + return this.querySelector(":scope > * > .player-stage") ? 630 : 0; }); const A = vid("A", 0); const B = vid("B", 0); renderModal( {}} onState={() => {}} />, ); - const row = document.querySelector(".player-stage")!.parentElement!.parentElement!; + // stage → its wrapper (with the clip panel) → the card → the row + const row = + document.querySelector(".player-stage")!.parentElement!.parentElement! + .parentElement!; expect(row.style.maxWidth).toBe("min(56rem, max(36rem, (100dvh - 3rem - 160px) * 16 / 9))"); }); }); @@ -626,7 +632,7 @@ describe("PlayerModal — the side panel on a wide window", () => { {}} onState={() => {}} />, ); const stage = document.querySelector(".player-stage")!; - const card = stage.parentElement!; + const card = stage.parentElement!.parentElement!; // the stage sits in a wrapper (+ clip panel) const info = screen.getByTestId("player-info"); // The card is a row: the video, then the panel holding the title and the channel. expect(card.className).toMatch(/(^|\s)flex(\s|$)/); @@ -656,7 +662,8 @@ describe("PlayerModal — the side panel on a wide window", () => { {}} onState={() => {}} />, ); const row = () => - document.querySelector(".player-stage")!.parentElement!.parentElement!; + document.querySelector(".player-stage")!.parentElement!.parentElement! + .parentElement!; fireEvent.click(screen.getByRole("button", { name: "player.collapsePanel" })); expect(screen.queryByTestId("player-info")).toBeNull(); const strip = screen.getByTestId("player-strip"); @@ -722,7 +729,7 @@ describe("PlayerModal — the side panel on a wide window", () => { {}} onState={() => {}} />, ); const stage = document.querySelector(".player-stage")!; - const card = stage.parentElement!; + const card = stage.parentElement!.parentElement!; // the stage sits in a wrapper (+ clip panel) Object.defineProperty(card, "scrollHeight", { value: 900, configurable: true }); Object.defineProperty(card, "clientHeight", { value: 500, configurable: true }); const spin = (el: Element) => { @@ -818,3 +825,56 @@ describe("PlayerModal — the docked bar's fullscreen", () => { expect(maximised(false)).toBe(1); }); }); + +// Clip mode: the panel sits under the video (the video is its preview), Escape closes the panel and +// not the player, the scissors gets the focus back, and a mark made in the big view steps back to +// the card, where the panel lives. +describe("PlayerModal — clip mode", () => { + const key = (k: string) => + act( + () => + void window.dispatchEvent( + new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true }), + ), + ); + const open = (onClose = () => {}) => { + vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); + const A = vid("A", 0); + renderModal( + {}} />, + ); + }; + + it("opens under the video on C, and Escape closes the panel, not the player", async () => { + const onClose = vi.fn(); + clock.current = 42; + open(onClose); + await waitFor(() => expect(lastStart()?.videoId).toBe("A")); + key("c"); + const panel = screen.getByTestId("clip-panel"); + const stage = document.querySelector(".player-stage")!; + expect(stage.compareDocumentPosition(panel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(screen.getByRole("slider", { name: "clip.in" }).getAttribute("aria-valuenow")).toBe( + "42", + ); + key("Escape"); + expect(screen.queryByTestId("clip-panel")).toBeNull(); + expect(onClose).not.toHaveBeenCalled(); + await waitFor(() => + expect(document.activeElement).toBe(screen.getByRole("button", { name: "clip.open" })), + ); + }); + + it("marks in the big view by stepping back to the card", async () => { + clock.current = 10; + open(); + await waitFor(() => expect(lastStart()?.videoId).toBe("A")); + key("f"); + expect(document.querySelector(".player-stage--max")).toBeTruthy(); + key("]"); + expect(document.querySelector(".player-stage--max")).toBeNull(); + expect(screen.getByRole("slider", { name: "clip.out" }).getAttribute("aria-valuenow")).toBe( + "10", + ); + }); +}); diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 6001f090..8ac0246c 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -28,6 +28,7 @@ import { Minimize2, PanelRightClose, PanelRightOpen, + Scissors, Settings, SkipBack, SkipForward, @@ -50,6 +51,8 @@ import { } from "../lib/layerStack"; import { trapTab, useDialogChrome } from "./ui/dialogFocus"; import DownloadButton from "./DownloadButton"; +import ClipPanel from "./ClipPanel"; +import { DEFAULT_CLIP_S, markIn, markOut, openAt, type ClipRange } from "../lib/clipRange"; import { api, type Video, type VideoStatus } from "../lib/api"; import type { YTPlayer } from "../lib/youtube"; import { @@ -338,6 +341,56 @@ export default function PlayerModal({ const [currentVideoId, setCurrentVideoId] = useState(active.id); const currentIdRef = useRef(active.id); const navigated = currentVideoId !== active.id; + // Clip mode: the range being marked of the video that is playing, or null. Its panel sits under + // the video, which is its preview; another video starts without one. + const [clip, setClip] = useState(null); + const [clipFor, setClipFor] = useState(currentVideoId); + if (clipFor !== currentVideoId) { + setClipFor(currentVideoId); + setClip(null); + } + const clipOpen = clip != null; + // The player's clock, read while the panel is open: the iframe reports no time of its own. + const [clipClock, setClipClock] = useState({ now: 0, duration: 0 }); + useEffect(() => { + if (!clipOpen) return; + const read = () => { + const p = playerRef.current; + if (!p || typeof p.getCurrentTime !== "function") return; + const now = p.getCurrentTime() || 0; + const duration = p.getDuration() || 0; + // Paused, the clock stands still: no new state, no re-render of the whole modal. + setClipClock((c) => (c.now === now && c.duration === duration ? c : { now, duration })); + }; + read(); + const iv = window.setInterval(read, 200); + return () => window.clearInterval(iv); + }, [clipOpen]); + // The scissors that opened the panel gets the focus back when it closes. + const clipButtonRef = useRef(null); + const refocusClip = useRef(false); + const closeClip = () => { + refocusClip.current = true; + setClip(null); + }; + useEffect(() => { + if (clipOpen || !refocusClip.current) return; + refocusClip.current = false; + clipButtonRef.current?.focus(); + }, [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, so a big view steps back to the card first. */ + const markClip = (mark: (r: ClipRange | null, t: number, d: number) => ClipRange) => { + const p = playerRef.current; + if (!p || typeof p.getCurrentTime !== "function") return; + const t = p.getCurrentTime() || 0; + const d = p.getDuration() || 0; + if (d <= 0) return; + if (maximized || isFullscreen) exitBigView(); + setClipClock({ now: t, duration: d }); + setClip((r) => mark(r, t, d)); + }; + const openClip = () => markClip((_r, t, d) => openAt(t, d)); // Title/author of a navigated-to video, read from the player (free, no API call). const [liveData, setLiveData] = useState<{ title?: string; author?: string } | null>(null); // What is playing, by name: the panel's title, and the dialog's name (`titleId`) in either layout. @@ -601,6 +654,13 @@ export default function PlayerModal({ return; } dispatch(e, "ytPlayer", { + clip: () => { + 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)), 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 @@ -609,6 +669,12 @@ export default function PlayerModal({ // stack synchronously, so `anyLayerAbove` alone would miss it and close the player too. if (escapeHandled(ev)) return; if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return; + // The clip panel is the player's own layer: it closes before the player does. + if (clip) { + ev.preventDefault(); + closeClip(); + return; + } // In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal. if (fullscreenElement()) return; // Same for our own maximise: Esc steps back out of it before it closes the player. @@ -746,32 +812,35 @@ export default function PlayerModal({ }`} onClick={(e) => e.stopPropagation()} > -
- {/* Hide the iframe entirely on error so YouTube's own error screen can't bleed + {/* The video and, in clip mode, the clip panel under it. Always this wrapper, so opening the + panel never moves the stage to another parent (that would re-create the YouTube iframe). */} +
+
+ {/* Hide the iframe entirely on error so YouTube's own error screen can't bleed through our overlay. */} - {/* In the big views the video area is inset by the control bar's height (`bottom-14`) so + {/* In the big views the video area is inset by the control bar's height (`bottom-14`) so the bar below never overlaps it. The inset lives on THIS wrapper, not on `mountRef`: YT.Player REPLACES the mount element with its iframe (width/height 100% of the parent), which would drop any class on mountRef itself — the iframe then sizes to this wrapper. */} -
- {/* Interaction layer over the CENTRE of the video: catches click (play/pause) + wheel + className={ + maximized || isFullscreen + ? "absolute inset-x-0 top-0 bottom-14" + : "relative w-full h-full" + } + > +
+ {/* Interaction layer over the CENTRE of the video: catches click (play/pause) + wheel volume (the wheel event bubbles from here to the modal's listener — over the bare cross-origin iframe it would be swallowed) and stops the iframe stealing keyboard focus. It leaves the top AND bottom edges uncovered so YouTube's edge controls stay reachable, @@ -782,25 +851,25 @@ export default function PlayerModal({ gap) — it only yields to `nativeControls`, i.e. once the user opens YouTube's own settings menu (focus moves into the iframe), so that menu is navigable end to end. Hidden on error so the "Open on YouTube" CTA is clickable. */} - {playerError == null && - YT_CLICK_LAYER.map((box, i) => ( -
{ - focusModal(); - togglePlay(); - }} - title={t("player.shortcutsHint")} - aria-hidden="true" - style={box} - className={`absolute z-base cursor-pointer ${ - nativeControls ? "pointer-events-none" : "" - }`} - /> - ))} -
- {/* Our own control bar for the big views (maximise / our fullscreen), where the card's + {playerError == null && + YT_CLICK_LAYER.map((box, i) => ( +
{ + focusModal(); + togglePlay(); + }} + title={t("player.shortcutsHint")} + aria-hidden="true" + style={box} + className={`absolute z-base cursor-pointer ${ + nativeControls ? "pointer-events-none" : "" + }`} + /> + ))} +
+ {/* Our own control bar for the big views (maximise / our fullscreen), where the card's controls are off-screen. It's IN the stage (so it survives true fullscreen, where a portal would not) and DOCKED in its own strip: the video area above is inset by this bar's height (see the iframe wrapper's `bottom-14`), so the bar NEVER overlaps YouTube's seek @@ -809,124 +878,154 @@ export default function PlayerModal({ seek bar over the same pixels. stopPropagation so a click here doesn't toggle playback; the exit button also re-arms our shortcuts. Both of its dialogs work in true fullscreen: add-to-playlist's popover and download's Modal portal INTO the fullscreen element. */} - {isFullscreen && } - {(maximized || isFullscreen) && ( -
e.stopPropagation()} - className="absolute inset-x-0 bottom-0 z-menu flex h-14 items-center justify-start gap-x-3 overflow-x-auto border-t border-white/10 bg-media-chrome/95 px-3 text-sm text-white" - > - {hasQueue && ( - <> - - - - )} - {/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both + {isFullscreen && } + {(maximized || isFullscreen) && ( +
e.stopPropagation()} + className="absolute inset-x-0 bottom-0 z-menu flex h-14 items-center justify-start gap-x-3 overflow-x-auto border-t border-white/10 bg-media-chrome/95 px-3 text-sm text-white" + > + {hasQueue && ( + <> + + + + )} + {/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both portal into the fullscreen element rather than (a body-portaled node is painted underneath the element holding the lock, i.e. invisible). */} - {!navigated && } - {!navigated && ( - - )} - {!navigated && ( + {!navigated && } + {!navigated && ( + + )} + {/* The clip panel lives under the video in the card: this steps out of the big view. */} + {playerError == null && ( + + + + )} + {!navigated && ( + + )} + + {/* Absent where the browser cannot fullscreen an element (Brave on an iPad): there + the window-filling view is the biggest, and YouTube's own button opens the + system player. */} + {canFullscreenElements() && ( + + {isFullscreen ? : } + + )} - )} - - {/* Absent where the browser cannot fullscreen an element (Brave on an iPad): there - the window-filling view is the biggest, and YouTube's own button opens the - system player. */} - {canFullscreenElements() && ( - - {isFullscreen ? : } - - )} - -
- )} - {/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */} - {playerError == null && nativeControls && ( -
- - {t("player.nativeControls")} -
- )} - {/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */} - {volumeUi != null && ( -
- {volumeUi === 0 ? : } -
-
- {volumeUi} -
- )} - {playerError != null && ( -
-
- -

{t("player.unavailableTitle")}

-

- {playerError === YT_API_LOAD_FAILED - ? t("player.apiLoadFailedBody") - : playerError === 101 || playerError === 150 - ? t("player.embedDisabledBody") - : t("player.unavailableBody")} -

- } - > - {t("player.openOnYoutube")} - + )} + {/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */} + {playerError == null && nativeControls && ( +
+ + {t("player.nativeControls")}
-
+ )} + {/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */} + {volumeUi != null && ( +
+ {volumeUi === 0 ? ( + + ) : ( + + )} +
+
+
+ {volumeUi} +
+ )} + {playerError != null && ( +
+
+ +

{t("player.unavailableTitle")}

+

+ {playerError === YT_API_LOAD_FAILED + ? t("player.apiLoadFailedBody") + : playerError === 101 || playerError === 150 + ? t("player.embedDisabledBody") + : t("player.unavailableBody")} +

+ } + > + {t("player.openOnYoutube")} + +
+
+ )} +
+ + {clip && !maximized && !isFullscreen && ( + { + // A seek past what is buffered fetches from there: only once the drag settles. + playerRef.current?.seekTo?.(time, final); + setClipClock((c) => ({ ...c, now: time })); + }} + onPlayFrom={(time) => { + seekTo(time); + setClipClock((c) => ({ ...c, now: time })); + }} + onClose={closeClip} + /> )}
- {/* 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. */}
+ {playerError == null && ( + (clip ? closeClip() : openClip())} + label={t("clip.open")} + title={`${t("clip.open")} · C`} + > + + + )} {onRemoveFromList && ( { delete (HTMLElement.prototype as { requestFullscreen?: unknown }).requestFullscreen; }); }); + +// Clip mode: `[` / `]` mark at the playhead (opening the panel), the panel takes the control row's +// place, Escape closes the panel before it would close the player, and the scissors gets the focus +// back. Cut sends this item and the audio track that is playing. +describe("PlexPlayer — clip mode", () => { + const key = (k: string, init: KeyboardEventInit = {}) => + act( + () => + void window.dispatchEvent( + new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true, ...init }), + ), + ); + + it("opens on [ with Start at the playhead, and Escape closes only the panel", async () => { + mount({ details: { a: item("a") } }); + await playingAt("/media/a", 120); + expect(screen.queryByTestId("clip-panel")).toBeNull(); + key("["); + expect(screen.getByTestId("clip-panel")).toBeTruthy(); + expect(screen.getByRole("slider", { name: "clip.in" }).getAttribute("aria-valuenow")).toBe( + "120", + ); + // The seek bar and the control row make room for it. + expect(screen.queryByLabelText("plex.player.playPause")).toBeNull(); + key("Escape"); + expect(screen.queryByTestId("clip-panel")).toBeNull(); + expect(screen.getByLabelText("plex.player.playPause")).toBeTruthy(); + await waitFor(() => + expect(document.activeElement).toBe(screen.getByRole("button", { name: "clip.open" })), + ); + }); + + it("marks End on ] typed through AltGr (the HU layout's ])", async () => { + mount({ details: { a: item("a") } }); + await playingAt("/media/a", 200); + key("]", { ctrlKey: true, altKey: true, modifierAltGraph: true } as KeyboardEventInit); + expect(screen.getByRole("slider", { name: "clip.out" }).getAttribute("aria-valuenow")).toBe( + "200", + ); + }); + + it("cuts this item with the audio track that is playing", async () => { + const enqueue = vi.spyOn(api, "enqueueClip").mockResolvedValue({ id: 9 } as never); + mount({ details: { a: item("a") } }); + await playingAt("/media/a", 60); + fireEvent.click(screen.getByRole("button", { name: "clip.open" })); + fireEvent.click(screen.getByRole("button", { name: "clip.cut" })); + await waitFor(() => expect(enqueue).toHaveBeenCalled()); + expect(enqueue.mock.calls[0]![0]).toMatchObject({ + source: "plex", + ref: "a", + start_s: 60, + end_s: 90, + audio_track: 0, + }); + }); +}); diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 3ec37ba0..ce402233 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -29,6 +29,7 @@ import { MoreHorizontal, Pause, Play, + Scissors, Settings, SkipBack, SkipForward, @@ -48,6 +49,8 @@ import { type PlexPlayerPrefs, } from "../lib/plexPlayerPrefs"; import { Ctrl, PanelHead } from "./plexPlayerControls"; +import ClipPanel from "./ClipPanel"; +import { DEFAULT_CLIP_S, markIn, markOut, openAt, type ClipRange } from "../lib/clipRange"; import { PlexPlayerSettings } from "./PlexPlayerSettings"; import { PlexStatsOverlay } from "./PlexStatsOverlay"; import { createFragMeter } from "../lib/plexStats"; @@ -288,8 +291,31 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } const [settingsTab, setSettingsTab] = useState<"sync" | "playback" | "subtitle" | "clock">( "sync", ); - // keep the control bar up while a menu is open - const menuOpenRef = useLatest(panel === "more" || panel === "tracks" || panel === "settings"); + // 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(null); + const [clipFor, setClipFor] = useState(id); + if (clipFor !== id) { + setClipFor(id); + setClip(null); + } + const clipRef = useLatest(clip); + // The scissors that opened the panel is gone while it is open; closing it gives the focus back. + const clipButtonRef = useRef(null); + const refocusClip = useRef(false); + const closeClip = useCallback(() => { + refocusClip.current = true; + setClip(null); + }, []); + useEffect(() => { + if (clip != null || !refocusClip.current) return; + refocusClip.current = false; + clipButtonRef.current?.focus(); + }, [clip]); + // keep the control bar up while a menu — or the clip panel — is open + const menuOpenRef = useLatest( + panel === "more" || panel === "tracks" || panel === "settings" || clip != null, + ); const menuRef = useRef(null); const menuTriggerRef = useRef(null); // Auto-hiding control bar (reveal on mouse move / any key; hide after 3s while playing). @@ -1006,6 +1032,20 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } }, info: () => togglePanel("info"), stats: () => setStatsOpen((v) => !v), + clip: () => { + if (clipRef.current) closeClip(); + else setClip(openAt(absRef.current, durationRef.current)); + }, + markIn: () => { + const t = absRef.current; + const d = durationRef.current; + setClip((c) => markIn(c ?? openAt(t, d), 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)); + }, back: (ev) => { // HTPC-style "stop & go back" to wherever the player was opened from — mirrors the mouse // Back button (shared cascade). @@ -1018,11 +1058,14 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } // 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; + const ownPanelOpen = + panelRef.current !== "none" || statsOpenRef.current || clipRef.current != null; if (!ownPanelOpen && layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return; ev.preventDefault(); - back("key"); + // The clip panel is the player's own too, and closes before the player does. + if (clipRef.current) closeClip(); + else back("key"); }, escape: (ev) => { // Defer to any layer opened above the player (Modal or anchored popover) — let it take the @@ -1032,7 +1075,8 @@ 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; - back("escape"); + if (clipRef.current) closeClip(); + else back("escape"); }, }); }; @@ -1063,6 +1107,10 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } fineSeekStepRef, panelRef, statsOpenRef, + clipRef, + closeClip, + absRef, + durationRef, ]); // Seekbar hover: a timestamp tooltip that follows the cursor. Store the position as a FRACTION @@ -1345,309 +1393,343 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } : "opacity-0 pointer-events-none" }`} > - {/* Seek bar — click to jump, or drag the head (pointer capture) to scrub; the seek commits on + {/* Clip mode: the clip panel takes the seek bar's and the control row's place (it has a + timeline of its own). Its seeks wait for the release: a seek may restart the transcode. */} + {clip ? ( +
+ { + if (final) seekTo(time); + }} + onPlayFrom={(time) => seekTo(time, true)} + onClose={closeClip} + /> +
+ ) : ( + <> + {/* Seek bar — click to jump, or drag the head (pointer capture) to scrub; the seek commits on release so a long drag doesn't restart the stream on every step. The track stays thin inside a 2.75rem hit area (C3): a finger could not find the 6px bar, and `touch-none` makes a drag scrub instead of panning the page. */} -
{ - const el = e.currentTarget as HTMLElement; - try { - el.setPointerCapture(e.pointerId); - } catch { - /* capture unavailable — dragging still works via buttons state */ - } - const r = el.getBoundingClientRect(); - const x = Math.max(0, Math.min(e.clientX - r.left, r.width)); - setScrub((x / r.width) * duration); - }} - onPointerMove={(e) => { - const r = (e.currentTarget as HTMLElement).getBoundingClientRect(); - const frac = Math.max(0, Math.min((e.clientX - r.left) / r.width, 1)); - const tt = frac * duration; - setHover({ frac, t: tt }); - if (e.buttons & 1) setScrub(tt); // dragging with the primary button held - }} - onPointerUp={(e) => { - if (scrub != null) { - seekTo(scrub); - setScrub(null); - } - // A finger never "leaves": its time tip goes with it. - if (e.pointerType === "touch") setHover(null); - }} - onPointerCancel={() => { - setScrub(null); - setHover(null); - }} - onMouseLeave={() => setHover(null)} - > - {/* Hover timestamp tooltip — follows the cursor along the bar. */} - {hover && duration > 0 && (
{ + const el = e.currentTarget as HTMLElement; + try { + el.setPointerCapture(e.pointerId); + } catch { + /* capture unavailable — dragging still works via buttons state */ + } + const r = el.getBoundingClientRect(); + const x = Math.max(0, Math.min(e.clientX - r.left, r.width)); + setScrub((x / r.width) * duration); + }} + onPointerMove={(e) => { + const r = (e.currentTarget as HTMLElement).getBoundingClientRect(); + const frac = Math.max(0, Math.min((e.clientX - r.left) / r.width, 1)); + const tt = frac * duration; + setHover({ frac, t: tt }); + if (e.buttons & 1) setScrub(tt); // dragging with the primary button held + }} + onPointerUp={(e) => { + if (scrub != null) { + seekTo(scrub); + setScrub(null); + } + // A finger never "leaves": its time tip goes with it. + if (e.pointerType === "touch") setHover(null); + }} + onPointerCancel={() => { + setScrub(null); + setHover(null); + }} + onMouseLeave={() => setHover(null)} > - {fmt(hover.t)} + {/* Hover timestamp tooltip — follows the cursor along the bar. */} + {hover && duration > 0 && ( +
+ {fmt(hover.t)} +
+ )} +
+
+ {/* intro/credit marker regions */} + {detail?.markers.map((m, i) => ( +
+ ))} +
+ {/* The head: on hover for a mouse, always on touch (C3), where nothing hovers. */} +
+
- )} -
-
- {/* intro/credit marker regions */} - {detail?.markers.map((m, i) => ( -
- ))} -
- {/* The head: on hover for a mouse, always on touch (C3), where nothing hovers. */} -
-
-
- {/* The C5 row, one line on a portrait tablet: play/pause, Back, Next (only with a next + {/* The C5 row, one line on a portrait tablet: play/pause, Back, Next (only with a next item), mute, the time, then audio & subtitles, the fixed ⋯ and fullscreen. No Stop (closing is stopping), no Restart (Back is, and the card's), no ±10 s (← / → and the touch double-tap are). */} -
- - {playing ? : } - - - - - {showNext && ( - nextId && go(nextId)} - disabled={!nextId} - > - - - )} - -
- - {muted || volume === 0 ? ( - - ) : ( - +
+ + {playing ? : } + + + + + {showNext && ( + nextId && go(nextId)} + disabled={!nextId} + > + + )} - - {/* The browser's own range, like every other slider (DESIGN.md A6): its hover, focus, + +
+ + {muted || volume === 0 ? ( + + ) : ( + + )} + + {/* The browser's own range, like every other slider (DESIGN.md A6): its hover, focus, keyboard and screen-reader support come with it — the hand-drawn bar it replaces had none of them. The level shows above it while hovered. A pointer drag hands the focus back so Space and the arrows keep driving the player, not the slider. */} - {/* Not on touch: a tablet's volume is its buttons (iPadOS ignores a page's), and the + {/* Not on touch: a tablet's volume is its buttons (iPadOS ignores a page's), and the row has to fit a portrait screen. */} -
setVolHover(true)} - onPointerLeave={() => setVolHover(false)} - > - setVol(v, v === 0)} - onPointerUp={(e) => e.currentTarget.blur()} - /> - {volHover && ( -
- {Math.round((muted ? 0 : volume) * 100)} +
setVolHover(true)} + onPointerLeave={() => setVolHover(false)} + > + setVol(v, v === 0)} + onPointerUp={(e) => e.currentTarget.blur()} + /> + {volHover && ( +
+ {Math.round((muted ? 0 : volume) * 100)} +
+ )}
- )} -
-
+
-
- {fmt(abs)} / {fmt(duration)} - {/* Projected finish time (wall clock now + time remaining), in the clock's own format — +
+ {fmt(abs)} / {fmt(duration)} + {/* Projected finish time (wall clock now + time remaining), in the clock's own format — pushes later while paused, since the remaining time then rides the advancing clock. */} - {duration > 0 && ( - - ( - { - clockParts(new Date(now.getTime() + Math.max(0, duration - abs) * 1000), prefs) - .time - } - ) - - )} -
+ {duration > 0 && ( + + ( + { + clockParts( + new Date(now.getTime() + Math.max(0, duration - abs) * 1000), + prefs, + ).time + } + ) + + )} +
-
- {/* Quick TRACKS menu (audio / subtitle) — the frequent action, on its own button. Picking +
+ {/* Quick TRACKS menu (audio / subtitle) — the frequent action, on its own button. Picking a track keeps it open: the change is seen where it was made, and the other group is one arrow away. */} - {detail && (detail.audio_streams.length > 1 || textSubs.length > 0) && ( - <> - - - - - {/* Portaled, but a React child of the player: a click or a wheel here must not - reach the stage's play-toggle and volume handlers. */} -
e.stopPropagation()} - onWheel={(e) => e.stopPropagation()} - style={{ ...tracksFadeStyle, maxHeight: "min(60dvh, var(--fit-max-h))" }} - className="overflow-auto no-scrollbar p-2" - > - {detail.audio_streams.length > 1 && ( -
- {t("plex.player.audio")} - {detail.audio_streams.map((a) => ( - changeAudio(a.ord)} - className={`${TRACK_ROW} ${ - (audioOrd ?? 0) === a.ord ? "text-accent" : "text-white" - }`} - > - - {audioName(a)} - - ))} -
- )} - {textSubs.length > 0 && ( -
- {t("plex.player.subtitles")} - changeSubtitle(null)} - className={`${TRACK_ROW} ${subOrd == null ? "text-accent" : "text-white"}`} - > - - {t("plex.player.subOff")} - - {textSubs.map((sub) => ( - changeSubtitle(sub.ord)} - className={`${TRACK_ROW} ${ - subOrd === sub.ord ? "text-accent" : "text-white" - }`} - > - - {subtitleName(sub)} - - ))} -
- )} -
-
- - )} - - {/* The fixed ⋯ (C5): the rare tools. Settings opens the tuning panel from the same spot, - so the ⋯ also closes it. */} -
- - - - -
e.stopPropagation()} className="flex flex-col"> - {moreItems.map((it) => ( - closeThenAct(() => closePanel("more"), it.run)} - className={`${PLAYER_ROW} flex min-h-9 items-center gap-2.5 text-white`} + {detail && (detail.audio_streams.length > 1 || textSubs.length > 0) && ( + <> + - {it.icon} - {it.label} - - ))} + + + + {/* Portaled, but a React child of the player: a click or a wheel here must not + reach the stage's play-toggle and volume handlers. */} +
e.stopPropagation()} + onWheel={(e) => e.stopPropagation()} + style={{ ...tracksFadeStyle, maxHeight: "min(60dvh, var(--fit-max-h))" }} + className="overflow-auto no-scrollbar p-2" + > + {detail.audio_streams.length > 1 && ( +
+ {t("plex.player.audio")} + {detail.audio_streams.map((a) => ( + changeAudio(a.ord)} + className={`${TRACK_ROW} ${ + (audioOrd ?? 0) === a.ord ? "text-accent" : "text-white" + }`} + > + + {audioName(a)} + + ))} +
+ )} + {textSubs.length > 0 && ( +
+ {t("plex.player.subtitles")} + changeSubtitle(null)} + className={`${TRACK_ROW} ${subOrd == null ? "text-accent" : "text-white"}`} + > + + {t("plex.player.subOff")} + + {textSubs.map((sub) => ( + changeSubtitle(sub.ord)} + className={`${TRACK_ROW} ${ + subOrd === sub.ord ? "text-accent" : "text-white" + }`} + > + + {subtitleName(sub)} + + ))} +
+ )} +
+
+ + )} + + {/* Clip mode (`c`): mark a range of what is playing and cut it into a download. */} + {duration > 0 && ( + setClip(openAt(abs, duration))} + buttonRef={clipButtonRef} + > + + + )} + + {/* The fixed ⋯ (C5): the rare tools. Settings opens the tuning panel from the same spot, + so the ⋯ also closes it. */} +
+ + + + +
e.stopPropagation()} className="flex flex-col"> + {moreItems.map((it) => ( + closeThenAct(() => closePanel("more"), it.run)} + className={`${PLAYER_ROW} flex min-h-9 items-center gap-2.5 text-white`} + > + {it.icon} + {it.label} + + ))} +
+
+ {panel === "settings" && ( + + )}
- - {panel === "settings" && ( - - )} -
- {/* Absent where the browser cannot fullscreen an element (Brave on an iPad): the button + {/* Absent where the browser cannot fullscreen an element (Brave on an iPad): the button did nothing there, and the windowed player already fills the window. */} - {canFullscreenElements() && ( - - {fs ? : } - - )} -
-
+ {canFullscreenElements() && ( + + {fs ? : } + + )} +
+
+ + )}
{/* Stats-for-nerds debug overlay (toggle with "d" or the gauge button). Non-modal corner panel — diff --git a/frontend/src/components/VideoEditor.partial.dom.test.tsx b/frontend/src/components/VideoEditor.partial.dom.test.tsx index ca7d44a5..16eab8cb 100644 --- a/frontend/src/components/VideoEditor.partial.dom.test.tsx +++ b/frontend/src/components/VideoEditor.partial.dom.test.tsx @@ -101,3 +101,26 @@ describe("VideoEditor — a split export that fails part-way", () => { expect(enqueue.mock.calls[2]![0].display_name).toBe("Talk (2/2)"); }); }); + +// The cuts sit on the shared time track: each is a slider the keyboard steps, and a step stops a +// tenth short of the next cut, so no segment ever closes up. +describe("VideoEditor — a cut on the keyboard", () => { + it("steps a focused cut with the arrows and stops it before its neighbours", () => { + vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never); + render( + + {}} /> + , + ); + const video = document.querySelector("video")!; + Object.defineProperty(video, "currentTime", { value: 50, configurable: true, writable: true }); + fireEvent.timeUpdate(video); + fireEvent.click(screen.getByText("editor.splitHere")); + const cut = screen.getByRole("slider", { name: "editor.cutN" }); + expect(cut.getAttribute("aria-valuenow")).toBe("50"); + fireEvent.keyDown(cut, { key: "ArrowRight", shiftKey: true }); + expect(cut.getAttribute("aria-valuenow")).toBe("51"); + fireEvent.keyDown(cut, { key: "End" }); + expect(cut.getAttribute("aria-valuenow")).toBe("99.9"); + }); +}); diff --git a/frontend/src/components/VideoEditor.tsx b/frontend/src/components/VideoEditor.tsx index 66178a43..b38f6f34 100644 --- a/frontend/src/components/VideoEditor.tsx +++ b/frontend/src/components/VideoEditor.tsx @@ -15,29 +15,10 @@ import { IconButton } from "./ui/IconButton"; import { Pressable } from "./ui/Pressable"; import { Input } from "./ui/Input"; import { SURFACE } from "./ui/surfaces"; - -// mm:ss.d timecode <-> seconds. -function tc(s: number): string { - if (!isFinite(s) || s < 0) s = 0; - const m = Math.floor(s / 60); - return `${m}:${(s - m * 60).toFixed(1).padStart(4, "0")}`; -} -function parseTc(v: string): number | null { - v = v.trim(); - if (!v) return null; - if (v.includes(":")) { - const [m, s] = v.split(":"); - const mm = Number(m), - ss = Number(s); - if (!isFinite(mm) || !isFinite(ss)) return null; - return mm * 60 + ss; - } - const n = Number(v); - return isFinite(n) ? n : null; -} +import { RangeTrack } from "./ui/RangeTrack"; +import { formatTimecode, parseTimecode } from "../lib/timecode"; type Seg = { id: number; start: number; end: number; keep: boolean }; -type Drag = { type: "seek" } | { type: "boundary"; bi: number }; const MIN_SEG = 0.1; @@ -90,6 +71,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos const [name, setName] = useState(""); const fileUrl = useMemo(() => api.downloadFileUrl(job.id), [job.id]); + const tc = (sec: number) => formatTimecode(sec, duration >= 3600); const aspect = (job.asset?.width && job.asset?.height ? job.asset.width / job.asset.height : 0) || 16 / 9; const sb = useQuery({ @@ -200,37 +182,10 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos creating.current ? segs : segs.map((s) => (s.id === id ? { ...s, keep: !s.keep } : s)), ); - // --- timeline drag (seek / boundary) + hover preview --- - const [drag, setDrag] = useState(null); - // The hover point, measured in the pointer handler (track-relative x and its time), so render never - // reads the track's geometry (a DOM read in render made the React Compiler skip the editor). + // --- timeline hover preview --- + // The hover point, measured in the track's pointer handler (track-relative x and its time), so + // render never reads the track's geometry (a DOM read in render made the React Compiler skip it). const [hover, setHover] = useState<{ x: number; time: number } | null>(null); - const timeAt = useCallback( - (clientX: number) => { - const el = trackRef.current; - if (!el) return 0; - const r = el.getBoundingClientRect(); - return Math.max(0, Math.min(1, (clientX - r.left) / r.width)) * duration; - }, - [duration], - ); - useEffect(() => { - if (!drag) return; - const move = (e: PointerEvent) => { - const tt = timeAt(e.clientX); - if (drag.type === "seek") { - setCurrent(tt); - seek(tt); - } else moveBoundary(drag.bi, tt); - }; - const up = () => setDrag(null); - window.addEventListener("pointermove", move); - window.addEventListener("pointerup", up); - return () => { - window.removeEventListener("pointermove", move); - window.removeEventListener("pointerup", up); - }; - }, [drag, timeAt, seek, moveBoundary]); // --- crop drag --- const overlayRef = useRef(null); @@ -274,7 +229,6 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos }, []); // --- filmstrip tile helpers (percentage sprite positioning; aspect-correct cells) --- - const pct = (v: number) => `${(duration ? (v / duration) * 100 : 0).toFixed(3)}%`; const tileStyle = (idx: number): React.CSSProperties => { const cols = strip?.cols ?? 12; const rows = strip?.rows ?? 1; @@ -468,104 +422,92 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos {/* timeline: filmstrip + segments (keep/drop tint) + markers + playhead + hover preview */}
-
{ - setDrag({ type: "seek" }); - seek(timeAt(e.clientX)); - }} - onPointerMove={(e) => - setHover({ - x: e.clientX - e.currentTarget.getBoundingClientRect().left, - time: timeAt(e.clientX), - }) - } - onPointerLeave={() => setHover(null)} - > - {/* aspect-correct filmstrip */} - {stripCells > 0 && ( -
- {Array.from({ length: stripCells }).map((_, i) => ( -
- ))} -
- )} - {/* segment tint: dropped = dimmed + hatched */} - {segments.map((s) => ( -
+ { + setCurrent(time); + seek(time); + }} + onHover={setHover} + className="h-14 rounded-lg bg-surface border border-border cursor-pointer" + background={ + stripCells > 0 && ( +
+ {Array.from({ length: stripCells }).map((_, i) => ( +
+ ))} +
+ ) + } + regions={segments.map((s) => ({ + key: s.id, + start: s.start, + end: s.end, + // segment tint: dropped = dimmed + hatched + className: clsx( + s.end < duration && "border-r border-black/40", s.keep ? "bg-accent/10" : "bg-black/60", s.id === selId && "ring-2 ring-inset ring-white/70", - )} - style={{ - left: pct(s.start), - width: pct(s.end - s.start), - ...(s.keep - ? {} - : { - backgroundImage: - "repeating-linear-gradient(45deg, rgba(0,0,0,0.55) 0 6px, rgba(0,0,0,0.25) 6px 12px)", - }), - }} - onPointerDown={() => setSelId(s.id)} - title={s.keep ? t("editor.keptSeg") : t("editor.droppedSeg")} - > - {/* keep/drop toggle */} - { - e.stopPropagation(); - toggleKeep(s.id); - }} - className={clsx( - "absolute top-1 left-1 p-0.5 rounded-sm bg-black/50 backdrop-blur-xs", - s.keep ? "text-accent" : "text-muted", - )} - title={s.keep ? t("editor.drop") : t("editor.keep")} - > - {s.keep ? : } - -
- ))} - {/* draggable boundary markers */} - {boundaries.map((b, bi) => ( -
-
{ - e.stopPropagation(); - setDrag({ type: "boundary", bi }); - }} - /> - { - e.stopPropagation(); - deleteBoundary(bi); - }} - className="absolute top-0 -right-2 p-0.5 rounded-full bg-black/70 text-muted hover:text-danger" - title={t("editor.removeCut")} - > - - -
- ))} - {/* playhead */} -
setSelId(s.id), + children: ( + // keep/drop toggle + { + e.stopPropagation(); + toggleKeep(s.id); + }} + className={clsx( + "absolute top-1 left-1 p-0.5 rounded-sm bg-black/50 backdrop-blur-xs", + s.keep ? "text-accent" : "text-muted", + )} + title={s.keep ? t("editor.drop") : t("editor.keep")} + > + {s.keep ? : } + + ), + }))} + // The cuts between segments: dragged, or stepped with the arrows once focused. + handles={boundaries.map((b, bi) => ({ + id: `cut-${bi}`, + label: t("editor.cutN", { n: bi + 1 }), + time: b, + min: segments[bi]!.start + MIN_SEG, + max: segments[bi + 1]!.end - MIN_SEG, + valueText: tc(b), + step: 0.1, + bigStep: 1, + onChange: (time: number) => moveBoundary(bi, time), + adornment: ( + { + e.stopPropagation(); + deleteBoundary(bi); + }} + className="absolute top-0 -right-0.5 p-0.5 rounded-full bg-black/70 text-muted hover:text-danger" + title={t("editor.removeCut")} + > + + + ), + }))} />
@@ -612,7 +554,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos key={`s-${sel.id}-${sel.start}`} disabled={selIdx === 0} onBlur={(e) => { - const v = parseTc(e.target.value); + const v = parseTimecode(e.target.value); if (v != null && selIdx > 0) moveBoundary(selIdx - 1, v); }} /> @@ -626,7 +568,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos key={`e-${sel.id}-${sel.end}`} disabled={selIdx === segments.length - 1} onBlur={(e) => { - const v = parseTc(e.target.value); + const v = parseTimecode(e.target.value); if (v != null && selIdx < segments.length - 1) moveBoundary(selIdx, v); }} /> diff --git a/frontend/src/components/plexPlayerControls.tsx b/frontend/src/components/plexPlayerControls.tsx index 95e6406f..8b9a755f 100644 --- a/frontend/src/components/plexPlayerControls.tsx +++ b/frontend/src/components/plexPlayerControls.tsx @@ -1,4 +1,4 @@ -import { type ReactNode } from "react"; +import { type ReactNode, type Ref } from "react"; import { useTranslation } from "react-i18next"; import { RotateCcw } from "lucide-react"; import { IconButton } from "./ui/IconButton"; @@ -96,11 +96,13 @@ export function Ctrl({ onClick, disabled, align = "center", + buttonRef, children, }: { label: string; onClick: () => void; disabled?: boolean; + buttonRef?: Ref; // Tooltip horizontal anchor: "start"/"end" for edge buttons so it doesn't clip off the viewport. align?: "start" | "center" | "end"; children: ReactNode; @@ -114,6 +116,7 @@ export function Ctrl({ return (
` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` | -| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars) | -| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` | -| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` | +| area | components | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` | +| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `
+
+ + {crop && ( + <> + { + const next = { ...crop, shape }; + const lock = lockOf(next, picture); + onCrop(lock == null ? next : { ...next, rect: fitRatio(crop.rect, lock) }); + }} + options={[ + { value: "free", label: t("clip.shapeFree") }, + { value: "16:9", label: "16:9" }, + { value: "9:16", label: "9:16" }, + { value: "1:1", label: "1:1" }, + ]} + /> + + + )} +
+
); } + +/** The clip's crop frame, over the player's picture box (the caller positions this layer over it). */ +export function ClipCropFrame({ + picture, + crop, + onCrop, +}: { + picture: number; + crop: ClipCrop; + onCrop: (c: ClipCrop) => void; +}) { + return ( + onCrop({ ...crop, rect })} + className="z-base" + /> + ); +} diff --git a/frontend/src/components/PlayerModal.dom.test.tsx b/frontend/src/components/PlayerModal.dom.test.tsx index 0224f1d8..4ea6909e 100644 --- a/frontend/src/components/PlayerModal.dom.test.tsx +++ b/frontend/src/components/PlayerModal.dom.test.tsx @@ -691,6 +691,39 @@ describe("PlayerModal — the side panel on a wide window", () => { expect(row().style.maxWidth).toBe("calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem)"); }); + it("shuts while a clip is marked and opens again after it, unless the user opened it meanwhile", async () => { + wideWindow(true); + vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); + const A = vid("A", 0); + renderModal( + {}} onState={() => {}} />, + ); + await waitFor(() => expect(lastStart()?.videoId).toBe("A")); + fireEvent.click(screen.getByRole("button", { name: "clip.open" })); + expect(screen.getByTestId("player-strip")).toBeTruthy(); + // The clip panel sits under the video here too: the stacked card's floor, and its scroll past it + // (without them the fit ran the card down to a sliver at 133% with the largest text). + const stage = document.querySelector(".player-stage")!; + const cardEl = stage.parentElement!.parentElement!; + expect(cardEl.parentElement!.style.maxWidth).toBe( + "calc(max(36rem, (100dvh - 3rem - 0px) * 16 / 9) + 2.75rem)", + ); + expect(cardEl.className).toContain("overflow-y-auto"); + fireEvent.click(screen.getByRole("button", { name: "clip.close" })); + expect(screen.getByTestId("player-info")).toBeTruthy(); + expect(cardEl.className).toContain("overflow-hidden"); + // Opened again by hand while clipping: it stays open, and closing the clip leaves it so. + fireEvent.click(screen.getByRole("button", { name: "clip.open" })); + fireEvent.click( + within(screen.getByTestId("player-strip")).getByRole("button", { + name: "player.expandPanel", + }), + ); + fireEvent.click(screen.getByRole("button", { name: "player.collapsePanel" })); + fireEvent.click(screen.getByRole("button", { name: "clip.close" })); + expect(screen.getByTestId("player-strip")).toBeTruthy(); + }); + it("gives a panel with something to scroll the wheel for that alone, the volume elsewhere", async () => { wideWindow(true); vi.spyOn(api, "videoDetail").mockResolvedValue({ @@ -827,8 +860,8 @@ describe("PlayerModal — the docked bar's fullscreen", () => { }); // Clip mode: the panel sits under the video (the video is its preview), Escape closes the panel and -// not the player, the scissors gets the focus back, and a mark made in the big view steps back to -// the card, where the panel lives. +// not the player, the scissors gets the focus back; in the maximised view the panel takes the docked +// bar's place; a crop frame lies over the video and Escape turns it off before the panel closes. describe("PlayerModal — clip mode", () => { const key = (k: string) => act( @@ -865,16 +898,59 @@ describe("PlayerModal — clip mode", () => { ); }); - it("marks in the big view by stepping back to the card", async () => { + it("marks in the maximised view without leaving it: the panel takes the docked bar's place", async () => { clock.current = 10; open(); await waitFor(() => expect(lastStart()?.videoId).toBe("A")); key("f"); - expect(document.querySelector(".player-stage--max")).toBeTruthy(); + const stage = document.querySelector(".player-stage--max")!; + expect(stage).toBeTruthy(); key("]"); - expect(document.querySelector(".player-stage--max")).toBeNull(); + expect(document.querySelector(".player-stage--max")).toBe(stage); + expect(stage.contains(screen.getByTestId("clip-panel"))).toBe(true); + expect(screen.queryByRole("button", { name: "player.exitMaximize" })).toBeNull(); expect(screen.getByRole("slider", { name: "clip.out" }).getAttribute("aria-valuenow")).toBe( "10", ); + // Escape: the panel first, then the maximised view, then (not here) the player. + key("Escape"); + expect(screen.queryByTestId("clip-panel")).toBeNull(); + expect(document.querySelector(".player-stage--max")).toBe(stage); + expect(screen.getByRole("button", { name: "player.exitMaximize" })).toBeTruthy(); + }); + + it("draws a short's frame on its 9:16 picture and tells the cut so", async () => { + const enqueue = vi.spyOn(api, "enqueueClip").mockResolvedValue({ id: 9 } as never); + vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); + const S = { ...vid("S", 0), is_short: true } as Video; + renderModal( + {}} onState={() => {}} />, + ); + await waitFor(() => expect(lastStart()?.videoId).toBe("S")); + key("c"); + fireEvent.click(screen.getByRole("button", { name: "clip.crop" })); + fireEvent.click(screen.getByRole("button", { name: "clip.cut" })); + await waitFor(() => expect(enqueue).toHaveBeenCalled()); + expect(enqueue.mock.calls[0]![0].crop?.aspect).toBe(0.5625); + }); + + it("lays the crop frame over the video, and Escape turns it off before closing the panel", async () => { + // jsdom lays nothing out: the frame's layer measures a 16:9 box. + vi.spyOn(Element.prototype, "clientWidth", "get").mockReturnValue(1600); + vi.spyOn(Element.prototype, "clientHeight", "get").mockReturnValue(900); + open(); + await waitFor(() => expect(lastStart()?.videoId).toBe("A")); + key("c"); + fireEvent.click(screen.getByRole("button", { name: "clip.crop" })); + const frame = screen.getByTestId("crop-frame"); + expect(document.querySelector(".player-stage")!.contains(frame)).toBe(true); + act(() => screen.getByRole("group", { name: "common.cropFrame.frame" }).focus()); + key("Escape"); + expect(screen.queryByTestId("crop-frame")).toBeNull(); + expect(screen.getByTestId("clip-panel")).toBeTruthy(); + // The frame took the focus with it: it is on the Crop toggle, not on . + expect(document.activeElement).toBe(screen.getByRole("button", { name: "clip.crop" })); + key("Escape"); + expect(screen.queryByTestId("clip-panel")).toBeNull(); }); }); diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 8ac0246c..e5eb765f 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -51,7 +51,7 @@ import { } from "../lib/layerStack"; import { trapTab, useDialogChrome } from "./ui/dialogFocus"; import DownloadButton from "./DownloadButton"; -import ClipPanel from "./ClipPanel"; +import ClipPanel, { ClipCropFrame, type ClipCrop } from "./ClipPanel"; import { DEFAULT_CLIP_S, markIn, markOut, openAt, type ClipRange } from "../lib/clipRange"; import { api, type Video, type VideoStatus } from "../lib/api"; import type { YTPlayer } from "../lib/youtube"; @@ -274,6 +274,10 @@ export default function PlayerModal({ // queue steps on (a next video plays in this same modal), and a video opened from a card mounts a // fresh player, which starts with the panel open (owner). const [panelShut, setPanelShut] = useState(false); + // Shut by clip mode rather than by the user (owner): the clip panel and the video take its width + // while a clip is marked, and it opens again when the clip panel closes. A press on its own toggle + // in between is the user's choice and holds. + const [shutForClip, setShutForClip] = useState(false); // The toggle that was pressed is gone after the switch (shut → the strip's "open", and back): focus // goes to its counterpart, not to , where the player's keys would no longer reach it. const panelToggleRef = useRef(null); @@ -281,6 +285,7 @@ export default function PlayerModal({ const togglePanel = (shut: boolean) => { refocusToggle.current = true; setPanelShut(shut); + setShutForClip(false); }; useEffect(() => { if (!refocusToggle.current) return; @@ -344,12 +349,37 @@ export default function PlayerModal({ // Clip mode: the range being marked of the video that is playing, or null. Its panel sits under // the video, which is its preview; another video starts without one. const [clip, setClip] = useState(null); + // The clip's crop frame over the picture, or null for the whole picture. + const [crop, setCrop] = useState(null); const [clipFor, setClipFor] = useState(currentVideoId); if (clipFor !== currentVideoId) { setClipFor(currentVideoId); setClip(null); + setCrop(null); + if (shutForClip) { + setPanelShut(false); + setShutForClip(false); + } } const clipOpen = clip != null; + // The picture the crop frame is drawn on. The iframe never tells its video's size: a short is + // 9:16, anything else is taken as the player's 16:9, and the cut fits the frame to the real picture + // (downloads/clip) — so a 4:3 video's frame drawn over its bars loses only what lies on the bars. + const picture = !navigated && active.is_short ? 9 / 16 : 16 / 9; + // The docked clip panel's height, measured (it wraps with the window and the text size): the video + // above it ends there. + const clipDockRef = useRef(null); + const [clipDockH, setClipDockH] = useState(0); + const clipDocked = clipOpen && (maximized || isFullscreen); + useLayoutEffect(() => { + const el = clipDockRef.current; + if (!clipDocked || !el) return; + const measure = () => setClipDockH(el.offsetHeight); + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + return () => ro.disconnect(); + }, [clipDocked]); // The player's clock, read while the panel is open: the iframe reports no time of its own. const [clipClock, setClipClock] = useState({ now: 0, duration: 0 }); useEffect(() => { @@ -372,6 +402,11 @@ export default function PlayerModal({ const closeClip = () => { refocusClip.current = true; setClip(null); + setCrop(null); + if (shutForClip) { + setPanelShut(false); + setShutForClip(false); + } }; useEffect(() => { if (clipOpen || !refocusClip.current) return; @@ -379,14 +414,19 @@ export default function PlayerModal({ clipButtonRef.current?.focus(); }, [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, so a big view steps back to the card first. */ + * panel lives under the video in the card, and in place of the docked bar when maximised; the + * browser's fullscreen steps back to the maximised view, which has room for it. */ const markClip = (mark: (r: ClipRange | null, t: number, d: number) => ClipRange) => { const p = playerRef.current; if (!p || typeof p.getCurrentTime !== "function") return; const t = p.getCurrentTime() || 0; const d = p.getDuration() || 0; if (d <= 0) return; - if (maximized || isFullscreen) exitBigView(); + if (isFullscreen) toggleTrueFullscreen(); + if (!clip && wide && !panelShut) { + setPanelShut(true); + setShutForClip(true); + } setClipClock({ now: t, duration: d }); setClip((r) => mark(r, t, d)); }; @@ -669,10 +709,12 @@ export default function PlayerModal({ // stack synchronously, so `anyLayerAbove` alone would miss it and close the player too. if (escapeHandled(ev)) return; if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return; - // The clip panel is the player's own layer: it closes before the player does. + // The clip panel is the player's own layer: it closes before the player does, and a crop + // turns off before the panel closes. if (clip) { ev.preventDefault(); - closeClip(); + if (crop) setCrop(null); + else closeClip(); return; } // In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal. @@ -751,6 +793,29 @@ export default function PlayerModal({ onLoadVideo: loadVideo, }; + // The clip panel: under the video in the card, in the docked bar's place in the big views. + const clipPanel = clip && ( + { + // A seek past what is buffered fetches from there: only once the drag settles. + playerRef.current?.seekTo?.(time, final); + setClipClock((c) => ({ ...c, now: time })); + }} + onPlayFrom={(time) => { + seekTo(time); + setClipClock((c) => ({ ...c, now: time })); + }} + onClose={closeClip} + /> + ); // The card's width budget (its maxWidth, below): the video's, and the side panel's when wide. const fitWidth = `(100dvh - 3rem - ${below}px) * 16 / 9`; const panelWidth = panelShut ? PANEL_STRIP : SIDE_PANEL; @@ -792,23 +857,26 @@ export default function PlayerModal({ // window's height less the backdrop's padding (`p-6` = 3rem). // Wide: no design cap — the video is as wide as the window's height allows, plus the side panel, // whose content sits beside the video (`below` is ~0), so the fit is exact and the card, which - // does not scroll there, always holds the whole video. + // does not scroll there, always holds the whole video. Clip mode is the exception: its panel + // sits under the video, so the wide card takes the stacked one's floor and scroll too. // Stacked: never narrower than MIN_VIDEO. The chrome under the video grows as the card narrows // (its text wraps), so on a short window fitting it was a loop that ran the card down to 2 px - // (a laptop at 100%, a 1080p screen at 175%). Past the floor the stacked card scrolls instead. + // (a laptop at 100%, a 1080p screen at 175%; the wide clip panel did the same at 133% with the + // largest text). Past the floor the card scrolls instead. style={{ maxWidth: wide - ? `calc(${fitWidth} + ${panelWidth})` + ? `calc(${clipOpen ? `max(${MIN_VIDEO}, ${fitWidth})` : fitWidth} + ${panelWidth})` : `min(56rem, max(${MIN_VIDEO}, ${fitWidth}))`, }} >
e.stopPropagation()} > @@ -832,9 +900,11 @@ export default function PlayerModal({
))} + {/* The clip's crop frame, over the picture (this box is the iframe's). Pointers pass + it outside the frame, so a click elsewhere on the video is still play / pause. */} + {crop && playerError == null && ( + + )}
{/* Our own control bar for the big views (maximise / our fullscreen), where the card's controls are off-screen. It's IN the stage (so it survives true fullscreen, where a @@ -879,7 +954,12 @@ export default function PlayerModal({ the exit button also re-arms our shortcuts. Both of its dialogs work in true fullscreen: add-to-playlist's popover and download's Modal portal INTO the fullscreen element. */} {isFullscreen && } - {(maximized || isFullscreen) && ( + {clipDocked && ( +
+ {clipPanel} +
+ )} + {(maximized || isFullscreen) && !clipDocked && (
e.stopPropagation()} className="absolute inset-x-0 bottom-0 z-menu flex h-14 items-center justify-start gap-x-3 overflow-x-auto border-t border-white/10 bg-media-chrome/95 px-3 text-sm text-white" @@ -907,7 +987,7 @@ export default function PlayerModal({ {!navigated && ( )} - {/* The clip panel lives under the video in the card: this steps out of the big view. */} + {/* Clip mode here: the clip panel takes this bar's place. */} {playerError == null && ( @@ -1006,25 +1086,7 @@ export default function PlayerModal({ )}
- {clip && !maximized && !isFullscreen && ( - { - // A seek past what is buffered fetches from there: only once the drag settles. - playerRef.current?.seekTo?.(time, final); - setClipClock((c) => ({ ...c, now: time })); - }} - onPlayFrom={(time) => { - seekTo(time); - setClipClock((c) => ({ ...c, now: time })); - }} - onClose={closeClip} - /> - )} + {!clipDocked && clipPanel}
{/* 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. */} diff --git a/frontend/src/components/PlexPlayer.controls.dom.test.tsx b/frontend/src/components/PlexPlayer.controls.dom.test.tsx index bdc7b0e8..81e91931 100644 --- a/frontend/src/components/PlexPlayer.controls.dom.test.tsx +++ b/frontend/src/components/PlexPlayer.controls.dom.test.tsx @@ -983,4 +983,44 @@ describe("PlexPlayer — clip mode", () => { audio_track: 0, }); }); + it("crops on the picture's own shape, and Escape turns the crop off before the panel", async () => { + const enqueue = vi.spyOn(api, "enqueueClip").mockResolvedValue({ id: 9 } as never); + // A 720×480 stream: the frame is drawn on a 3:2 picture, and the cut is told so. + const proto = HTMLVideoElement.prototype; + const own = ["videoWidth", "videoHeight"].map( + (k) => [k, Object.getOwnPropertyDescriptor(proto, k)] as const, + ); + Object.defineProperty(proto, "videoWidth", { configurable: true, get: () => 720 }); + Object.defineProperty(proto, "videoHeight", { configurable: true, get: () => 480 }); + try { + mount({ details: { a: item("a") } }); + await playingAt("/media/a", 60); + key("c"); + fireEvent.click(screen.getByRole("button", { name: "clip.crop" })); + // The frame's layer is the video's box: both end above the panel, both at the player's top + // (its flex box centred the shortened video, and the frame sat 155 px above the picture). + const video = document.querySelector("video")!; + const layer = screen.getByTestId("crop-frame").parentElement!; + expect(video.style.alignSelf).toBe("flex-start"); + expect(layer.className).toContain("top-0"); + expect(layer.style.height).toBe(video.style.height); + key("Escape"); + expect(screen.queryByTestId("crop-frame")).toBeNull(); + expect(screen.getByTestId("clip-panel")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "clip.crop" })); + fireEvent.click(screen.getByRole("button", { name: "clip.cut" })); + await waitFor(() => expect(enqueue).toHaveBeenCalled()); + expect(enqueue.mock.calls[0]![0].crop).toEqual({ + x: 0.1, + y: 0.1, + w: 0.8, + h: 0.8, + aspect: 1.5, + }); + } finally { + for (const [k, d] of own) + if (d) Object.defineProperty(proto, k, d); + else delete (proto as unknown as Record)[k]; + } + }); }); diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index ce402233..728faacb 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -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(null); + // The clip's crop frame over the picture, or null for the whole picture. + const [crop, setCrop] = useState(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(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(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 } /> )} + {crop && ( +
+ +
+ )} { if (final) seekTo(time); }} diff --git a/frontend/src/components/VideoEditor.tsx b/frontend/src/components/VideoEditor.tsx index b38f6f34..32fb3c93 100644 --- a/frontend/src/components/VideoEditor.tsx +++ b/frontend/src/components/VideoEditor.tsx @@ -7,7 +7,8 @@ import clsx from "clsx"; import Modal, { ModalFooter } from "./ui/Modal"; import { notify } from "../lib/notifications"; import { api, type DownloadJob, type EditSpec } from "../lib/api"; -import { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry"; +import { cropToSourcePx, type Frac } from "../lib/cropGeometry"; +import { CropFrame } from "./ui/CropFrame"; import { Button } from "./ui/Button"; import { failureReason, notifyActionError } from "../lib/actionErrors"; import { markReported } from "../lib/api/core"; @@ -48,11 +49,9 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos const [current, setCurrent] = useState(0); const [playing, setPlaying] = useState(false); const [trackW, setTrackW] = useState(0); - // Intrinsic (source) video dims and the video element's rendered box, both needed to map the - // crop marquee onto the letterboxed picture. `vdim` starts from asset metadata so the overlay - // can size before the media decodes; `box` is measured off the element (max-h can clamp it). + // Intrinsic (source) video dims: the crop frame's picture shape, and the crop's source pixels. + // From the asset's metadata, so the frame sizes before the media decodes. const [vdim, setVdim] = useState({ w: job.asset?.width ?? 0, h: job.asset?.height ?? 0 }); - const [box, setBox] = useState({ w: 0, h: 0 }); // The cut-list: contiguous segments covering [0, duration]. Starts as the whole video, kept. const [segments, setSegments] = useState([{ id: 0, start: 0, end: srcDur, keep: true }]); @@ -93,21 +92,9 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos return () => ro.disconnect(); }, []); - // Measure the video element's rendered box (max-h can shrink it below its intrinsic size, which - // then pillarboxes the picture). The crop overlay is anchored to the picture, not this box. - useEffect(() => { - const v = videoRef.current; - if (!v || typeof ResizeObserver === "undefined") return; - const ro = new ResizeObserver(() => setBox({ w: v.clientWidth, h: v.clientHeight })); - ro.observe(v); - setBox({ w: v.clientWidth, h: v.clientHeight }); - return () => ro.disconnect(); - }, []); - - // The picture's rendered rectangle inside the (possibly letterboxed) video box — the overlay's - // frame, so a crop fraction is a fraction of the real picture, not of the black bars. + // The picture's shape, which the crop frame is drawn on (it finds the picture inside the video's + // box itself: max-h can pillarbox it). const va = vdim.w && vdim.h ? vdim.w / vdim.h : aspect; - const picRect = contentRect(box.w, box.h, va); const onLoaded = () => { const v = videoRef.current; @@ -187,47 +174,6 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos // render never reads the track's geometry (a DOM read in render made the React Compiler skip it). const [hover, setHover] = useState<{ x: number; time: number } | null>(null); - // --- crop drag --- - const overlayRef = useRef(null); - const cropDrag = useRef( - null, - ); - const onCropDown = (e: React.PointerEvent, mode: "move" | "resize") => { - e.preventDefault(); - e.stopPropagation(); - (e.target as Element).setPointerCapture?.(e.pointerId); - cropDrag.current = { mode, px: e.clientX, py: e.clientY, orig: { ...crop } }; - }; - useEffect(() => { - const move = (e: PointerEvent) => { - const d = cropDrag.current; - const el = overlayRef.current; - if (!d || !el) return; - const r = el.getBoundingClientRect(); - const dx = (e.clientX - d.px) / r.width; - const dy = (e.clientY - d.py) / r.height; - if (d.mode === "move") - setCrop({ - ...d.orig, - x: Math.max(0, Math.min(1 - d.orig.w, d.orig.x + dx)), - y: Math.max(0, Math.min(1 - d.orig.h, d.orig.y + dy)), - }); - else - setCrop({ - ...d.orig, - w: Math.max(0.05, Math.min(1 - d.orig.x, d.orig.w + dx)), - h: Math.max(0.05, Math.min(1 - d.orig.y, d.orig.h + dy)), - }); - }; - const up = () => (cropDrag.current = null); - window.addEventListener("pointermove", move); - window.addEventListener("pointerup", up); - return () => { - window.removeEventListener("pointermove", move); - window.removeEventListener("pointerup", up); - }; - }, []); - // --- filmstrip tile helpers (percentage sprite positioning; aspect-correct cells) --- const tileStyle = (idx: number): React.CSSProperties => { const cols = strip?.cols ?? 12; @@ -370,35 +316,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos className="w-full max-h-[42dvh] mx-auto block object-contain" playsInline /> - {cropOn && picRect && ( -
-
onCropDown(e, "move")} - > -
onCropDown(e, "resize")} - /> -
-
- )} + {cropOn && }
{/* transport */} diff --git a/frontend/src/components/ui/CropFrame.dom.test.tsx b/frontend/src/components/ui/CropFrame.dom.test.tsx new file mode 100644 index 00000000..e344bc80 --- /dev/null +++ b/frontend/src/components/ui/CropFrame.dom.test.tsx @@ -0,0 +1,113 @@ +// @vitest-environment jsdom +// +// The crop frame: it sits on the picture, not on the box's bars; the arrows move it and, on a focused +// corner, resize it, and the player underneath never sees those arrows; a drag moves it in picture +// fractions; and the layer lets every press outside the frame through. jsdom lays nothing out, so the +// box's size is stubbed. +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { useState } from "react"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useTranslation: () => ({ t: (k: string) => k }) }; +}); +import { CropFrame } from "./CropFrame"; +import type { Frac } from "../../lib/cropGeometry"; + +afterEach(cleanup); + +beforeAll(() => { + class PointerEventStub extends MouseEvent { + pointerId: number; + constructor(type: string, init: PointerEventInit = {}) { + super(type, init); + this.pointerId = init.pointerId ?? 1; + } + } + globalThis.PointerEvent ??= PointerEventStub as unknown as typeof PointerEvent; + // A 1600×900 box for every element: the frame's layer measures its own. + Object.defineProperty(HTMLElement.prototype, "clientWidth", { configurable: true, value: 1600 }); + Object.defineProperty(HTMLElement.prototype, "clientHeight", { configurable: true, value: 900 }); +}); + +const WIDE = 16 / 9; + +function Harness({ aspect = WIDE, ratio = null }: { aspect?: number; ratio?: number | null }) { + const [rect, setRect] = useState({ x: 0.2, y: 0.2, w: 0.4, h: 0.4 }); + return ( +
+ + {[rect.x, rect.y, rect.w, rect.h].map((v) => v.toFixed(3)).join(" ")} + + +
+ ); +} + +const rect = () => screen.getByTestId("rect").textContent; +const picture = () => screen.getByRole("group", { name: "common.cropFrame.frame" }).parentElement!; + +describe("CropFrame", () => { + it("lies on the picture, between a 4:3 picture's bars in a 16:9 box", () => { + render(); + const p = picture(); + expect([p.style.left, p.style.top, p.style.width, p.style.height]).toEqual([ + "200px", + "0px", + "1200px", + "900px", + ]); + }); + + it("moves with the arrows, further with Shift, and keeps them from the player", () => { + const player = vi.fn(); + render( +
+ +
, + ); + const frame = screen.getByRole("group", { name: "common.cropFrame.frame" }); + fireEvent.keyDown(frame, { key: "ArrowRight" }); + expect(rect()).toBe("0.210 0.200 0.400 0.400"); + fireEvent.keyDown(frame, { key: "ArrowUp", shiftKey: true }); + expect(rect()).toBe("0.210 0.100 0.400 0.400"); + expect(player).not.toHaveBeenCalled(); + // Not an arrow: the player's (Escape steps out of the crop there). + fireEvent.keyDown(frame, { key: "Escape" }); + expect(player).toHaveBeenCalledTimes(1); + }); + + it("resizes from a focused corner, the opposite corner staying put", () => { + render(); + const se = screen.getByRole("group", { name: "common.cropFrame.se" }); + fireEvent.keyDown(se, { key: "ArrowRight", shiftKey: true }); + fireEvent.keyDown(se, { key: "ArrowDown" }); + expect(rect()).toBe("0.200 0.200 0.500 0.410"); + const nw = screen.getByRole("group", { name: "common.cropFrame.nw" }); + fireEvent.keyDown(nw, { key: "ArrowLeft" }); + expect(rect()).toBe("0.190 0.200 0.510 0.410"); + }); + + it("moves with a drag, in fractions of the picture", () => { + render(); + const frame = screen.getByRole("group", { name: "common.cropFrame.frame" }); + fireEvent.pointerDown(frame, { button: 0, clientX: 500, clientY: 300 }); + fireEvent.pointerMove(frame, { clientX: 660, clientY: 390 }); + fireEvent.pointerUp(frame, { clientX: 660, clientY: 390 }); + // 160 px of 1600 and 90 px of 900: a tenth each way. + expect(rect()).toBe("0.300 0.300 0.400 0.400"); + }); + + it("lets a press outside the frame through to the player, and keeps one on the frame", () => { + const click = vi.fn(); + render( +
+ +
, + ); + expect(screen.getByTestId("crop-frame").className).toContain("pointer-events-none"); + fireEvent.click(screen.getByRole("group", { name: "common.cropFrame.frame" })); + expect(click).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/ui/CropFrame.tsx b/frontend/src/components/ui/CropFrame.tsx new file mode 100644 index 00000000..460f7c04 --- /dev/null +++ b/frontend/src/components/ui/CropFrame.tsx @@ -0,0 +1,207 @@ +import { + useEffect, + useRef, + useState, + type KeyboardEvent, + type MouseEvent, + type PointerEvent, +} from "react"; +import { useTranslation } from "react-i18next"; +import clsx from "clsx"; +import { + contentRect, + moveCrop, + resizeCrop, + type CropHandle, + type Frac, +} from "../../lib/cropGeometry"; + +// A crop frame over a picture: drawn on the picture itself, not on the box it is shown in (a +// letterboxed video leaves bars there), in fractions of it. The frame is dragged to move, its edges +// and corners to resize; from the keyboard the frame moves with the arrows and each corner, focused, +// resizes with them (Shift for a bigger step). Outside the frame the picture is dimmed. The frame and +// its corners are groups, not buttons: nothing happens on Enter, and the name says what the arrows do. +// +// It fills its positioned parent and measures it, so the caller only places it over the picture's +// box (a