From 50157cc2fd389c5d023adc685209945ca7e0c4f9 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 10 Oct 2026 03:58:27 +0200 Subject: [PATCH] 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 `