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.
This commit is contained in:
@@ -51,6 +51,15 @@ 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).
|
||||
export interface ClipRequest {
|
||||
source: "youtube" | "plex";
|
||||
ref: string;
|
||||
start_s: number;
|
||||
end_s: number;
|
||||
audio_track?: number;
|
||||
display_name?: string;
|
||||
}
|
||||
|
||||
export interface EditSpec {
|
||||
trim?: { start_s: number; end_s?: number };
|
||||
segments?: { start_s: number; end_s: number }[];
|
||||
@@ -226,6 +235,10 @@ export const downloadsApi = {
|
||||
display_name?: string;
|
||||
}): Promise<DownloadJob> =>
|
||||
req("/api/downloads/edit", { method: "POST", body: JSON.stringify(body) }),
|
||||
// An ad-hoc clip marked in a player: a YouTube video's or a Plex item's range, cut into a new
|
||||
// download of its own (`audio_track`: the Plex audio track that was playing).
|
||||
enqueueClip: (body: ClipRequest): Promise<DownloadJob> =>
|
||||
req("/api/downloads/clip", { method: "POST", body: JSON.stringify(body) }),
|
||||
downloadStoryboard: (id: number): Promise<StoryboardMeta> =>
|
||||
req(`/api/downloads/${id}/storyboard`),
|
||||
downloads: (): Promise<DownloadJob[]> => req("/api/downloads"),
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_CLIP_S,
|
||||
MAX_CLIP_S,
|
||||
handleBounds,
|
||||
markIn,
|
||||
markOut,
|
||||
openAt,
|
||||
zoomWindow,
|
||||
} from "./clipRange";
|
||||
|
||||
const D = 3600;
|
||||
|
||||
describe("clipRange", () => {
|
||||
it("opens a default-length range at the playhead, shorter at the item's end", () => {
|
||||
expect(openAt(100, D)).toEqual({ start: 100, end: 100 + DEFAULT_CLIP_S });
|
||||
expect(openAt(3590, D)).toEqual({ start: 3590, end: 3600 });
|
||||
expect(openAt(3600, D)).toEqual({ start: 3599, end: 3600 });
|
||||
});
|
||||
|
||||
it("moves In alone while the range stays one the server takes", () => {
|
||||
expect(markIn({ start: 100, end: 130 }, 90, D)).toEqual({ start: 90, end: 130 });
|
||||
});
|
||||
|
||||
it("carries Out along when In passes it, and keeps the cap when In goes too far back", () => {
|
||||
expect(markIn({ start: 100, end: 130 }, 200, D)).toEqual({ start: 200, end: 230 });
|
||||
expect(markIn({ start: 1000, end: 1030 }, 100, D)).toEqual({
|
||||
start: 100,
|
||||
end: 100 + MAX_CLIP_S,
|
||||
});
|
||||
});
|
||||
|
||||
it("carries In along when Out goes before it or too far ahead", () => {
|
||||
expect(markOut({ start: 100, end: 130 }, 150, D)).toEqual({ start: 100, end: 150 });
|
||||
expect(markOut({ start: 100, end: 130 }, 50, D)).toEqual({ start: 20, end: 50 });
|
||||
expect(markOut({ start: 100, end: 130 }, 1000, D)).toEqual({ start: 400, end: 1000 });
|
||||
});
|
||||
|
||||
it("every marked range is at least a second and at most the cap", () => {
|
||||
for (const [r, t] of [
|
||||
[{ start: 0, end: 30 }, 0],
|
||||
[{ start: 0, end: 30 }, 3600],
|
||||
[{ start: 3000, end: 3600 }, 0],
|
||||
] as const) {
|
||||
for (const next of [markIn(r, t, D), markOut(r, t, D)]) {
|
||||
expect(next.end - next.start).toBeGreaterThanOrEqual(1);
|
||||
expect(next.end - next.start).toBeLessThanOrEqual(MAX_CLIP_S);
|
||||
expect(next.start).toBeGreaterThanOrEqual(0);
|
||||
expect(next.end).toBeLessThanOrEqual(D);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("bounds each handle so a drag never breaks the range", () => {
|
||||
expect(handleBounds({ start: 100, end: 130 }, D)).toEqual({
|
||||
in: { min: 0, max: 129 },
|
||||
out: { min: 101, max: 700 },
|
||||
});
|
||||
});
|
||||
|
||||
it("zooms to the range with room either side, inside the item", () => {
|
||||
expect(zoomWindow({ start: 100, end: 140 }, D)).toEqual({ from: 90, to: 150 });
|
||||
expect(zoomWindow({ start: 2, end: 10 }, D)).toEqual({ from: 0, to: 15 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
// The range an ad-hoc clip is cut from, marked in a player: the rules the clip panel's handles, its
|
||||
// In / Out fields and the players' `[` / `]` keys all go by, so every way of marking keeps a range the
|
||||
// server takes (backend downloads/clip.py: at least MIN_CLIP_S, at most MAX_CLIP_S — the owner's cap).
|
||||
|
||||
const MIN_CLIP_S = 1;
|
||||
export const MAX_CLIP_S = 600;
|
||||
/** What a fresh mark spans before the second end is set: a moment, not the cap. */
|
||||
export const DEFAULT_CLIP_S = 30;
|
||||
|
||||
export interface ClipRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
|
||||
|
||||
/** A range opened at `t`: from there, DEFAULT_CLIP_S long (shorter at the item's end). */
|
||||
export function openAt(t: number, duration: number): ClipRange {
|
||||
const start = clamp(t, 0, Math.max(0, duration - MIN_CLIP_S));
|
||||
return { start, end: Math.min(duration, start + DEFAULT_CLIP_S) };
|
||||
}
|
||||
|
||||
/** In moved to `t`. Out stays where it is while the range stays one the server takes; otherwise it
|
||||
* follows (a fresh DEFAULT_CLIP_S after an In past it, the cap after an In too far before it). */
|
||||
export function markIn(r: ClipRange, t: number, duration: number): ClipRange {
|
||||
const start = clamp(t, 0, Math.max(0, duration - MIN_CLIP_S));
|
||||
let end = r.end;
|
||||
if (end - start < MIN_CLIP_S) end = Math.min(duration, start + DEFAULT_CLIP_S);
|
||||
else if (end - start > MAX_CLIP_S) end = start + MAX_CLIP_S;
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
/** Out moved to `t`; In follows the same way when the range would stop being one. */
|
||||
export function markOut(r: ClipRange, t: number, duration: number): ClipRange {
|
||||
const end = clamp(t, Math.min(MIN_CLIP_S, duration), duration);
|
||||
let start = r.start;
|
||||
if (end - start < MIN_CLIP_S) start = Math.max(0, end - DEFAULT_CLIP_S);
|
||||
else if (end - start > MAX_CLIP_S) start = end - MAX_CLIP_S;
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
/** Where each end may be dragged to without the other moving. */
|
||||
export function handleBounds(r: ClipRange, duration: number) {
|
||||
return {
|
||||
in: { min: Math.max(0, r.end - MAX_CLIP_S), max: r.end - MIN_CLIP_S },
|
||||
out: { min: r.start + MIN_CLIP_S, max: Math.min(duration, r.start + MAX_CLIP_S) },
|
||||
};
|
||||
}
|
||||
|
||||
/** The zoomed strip's window: the range with a margin either side (a quarter of it, at least 5 s),
|
||||
* so both ends sit inside with room to move them out. */
|
||||
export function zoomWindow(r: ClipRange, duration: number): { from: number; to: number } {
|
||||
const pad = Math.max(5, (r.end - r.start) * 0.25);
|
||||
return { from: Math.max(0, r.start - pad), to: Math.min(duration, r.end + pad) };
|
||||
}
|
||||
@@ -41,6 +41,19 @@ describe("matches", () => {
|
||||
expect(matches(press({ key: "k", ctrlKey: true, altKey: true }), "Mod+k")).toBe(false);
|
||||
});
|
||||
|
||||
// …but a symbol the layout can only type through AltGr IS that symbol: on the HU layout `[` is
|
||||
// AltGr+F (Ctrl+Alt on Windows), and a bare `[` row must answer it, or the clip marks are dead keys.
|
||||
it("matches a bare-symbol row typed through AltGr, never a letter row or a Mod row", () => {
|
||||
const altGr = (key: string) =>
|
||||
press({ key, ctrlKey: true, altKey: true, modifierAltGraph: true } as KeyboardEventInit);
|
||||
expect(matches(altGr("["), "[")).toBe(true);
|
||||
expect(matches(altGr("]"), "]")).toBe(true);
|
||||
expect(matches(altGr("a"), "a")).toBe(false);
|
||||
expect(matches(altGr("k"), "Mod+k")).toBe(false);
|
||||
// A real Ctrl+Alt chord (no AltGr) on a symbol is still not the bare symbol.
|
||||
expect(matches(press({ key: "[", ctrlKey: true, altKey: true }), "[")).toBe(false);
|
||||
});
|
||||
|
||||
it("treats Mod as Ctrl or Cmd", () => {
|
||||
expect(matches(press({ key: "k", ctrlKey: true }), "Mod+k")).toBe(true);
|
||||
expect(matches(press({ key: "k", metaKey: true }), "Mod+k")).toBe(true);
|
||||
@@ -111,10 +124,23 @@ describe("dispatch", () => {
|
||||
subtitle: noop,
|
||||
info: noop,
|
||||
stats: noop,
|
||||
clip: () => calls.push("clip"),
|
||||
markIn: () => calls.push("markIn"),
|
||||
markOut: () => calls.push("markOut"),
|
||||
back: () => calls.push("back"),
|
||||
escape: () => calls.push("escape"),
|
||||
}) as const;
|
||||
|
||||
it("routes the clip marks, typed plain or through AltGr", () => {
|
||||
const calls: string[] = [];
|
||||
const h = plexHandlers(calls);
|
||||
dispatch(press({ key: "c" }), "plexPlayer", h);
|
||||
dispatch(press({ key: "[" }), "plexPlayer", h);
|
||||
const altGr = { ctrlKey: true, altKey: true, modifierAltGraph: true } as KeyboardEventInit;
|
||||
dispatch(press({ key: "]", ...altGr }), "plexPlayer", h);
|
||||
expect(calls).toEqual(["clip", "markIn", "markOut"]);
|
||||
});
|
||||
|
||||
it("routes each arrow variant to its own id", () => {
|
||||
const calls: string[] = [];
|
||||
const h = plexHandlers(calls);
|
||||
|
||||
@@ -166,6 +166,11 @@ export const SHORTCUTS = [
|
||||
{ id: "nextVideo", scope: "ytPlayer", keys: ["Shift+ArrowRight"], labelKey: "keys.video" },
|
||||
{ id: "volumeUp", scope: "ytPlayer", keys: ["ArrowUp"], labelKey: "keys.volume" },
|
||||
{ id: "volumeDown", scope: "ytPlayer", keys: ["ArrowDown"], labelKey: "keys.volume" },
|
||||
// Clip mode (the clip panel): `c` opens / closes it, `[` and `]` mark its ends at the playhead
|
||||
// (opening it when it is shut).
|
||||
{ id: "clip", scope: "ytPlayer", keys: ["c"], labelKey: "keys.clip" },
|
||||
{ id: "markIn", scope: "ytPlayer", keys: ["["], labelKey: "keys.clipMark" },
|
||||
{ id: "markOut", scope: "ytPlayer", keys: ["]"], labelKey: "keys.clipMark" },
|
||||
{
|
||||
id: "close",
|
||||
scope: "ytPlayer",
|
||||
@@ -202,6 +207,9 @@ export const SHORTCUTS = [
|
||||
{ id: "subtitle", scope: "plexPlayer", keys: ["s"], labelKey: "keys.subtitle" },
|
||||
{ id: "info", scope: "plexPlayer", keys: ["i"], labelKey: "keys.info" },
|
||||
{ id: "stats", scope: "plexPlayer", keys: ["d"], labelKey: "keys.stats" },
|
||||
{ id: "clip", scope: "plexPlayer", keys: ["c"], labelKey: "keys.clip" },
|
||||
{ id: "markIn", scope: "plexPlayer", keys: ["["], labelKey: "keys.clipMark" },
|
||||
{ id: "markOut", scope: "plexPlayer", keys: ["]"], labelKey: "keys.clipMark" },
|
||||
// Two rows, one help line: ⌫ is the plain "stop & out", Escape is the layer-aware cascade (defer to
|
||||
// anything above → cancel auto-skip → close the topmost player overlay → close). Sharing a labelKey
|
||||
// keeps them a single "⌫ Esc Stop & go back" row in the sheet.
|
||||
@@ -266,6 +274,11 @@ export function isActivationTarget(e: KeyboardEvent): boolean {
|
||||
return tag === "button" || tag === "a";
|
||||
}
|
||||
|
||||
/** One printable character that is neither a letter nor a digit (`[`, `]`, `,`). */
|
||||
function isSymbol(name: string): boolean {
|
||||
return [...name].length === 1 && !/[\p{L}\p{N}\s]/u.test(name);
|
||||
}
|
||||
|
||||
function keyMatches(e: KeyboardEvent, name: string): boolean {
|
||||
if (name === "Space") return e.key === " " || e.code === "Space";
|
||||
if (name.length === 1) return e.key.toLowerCase() === name.toLowerCase();
|
||||
@@ -280,6 +293,10 @@ export function matches(e: KeyboardEvent, chord: string): boolean {
|
||||
const parts = chord.split("+");
|
||||
const key = parts[parts.length - 1] ?? "";
|
||||
const mods = parts.slice(0, -1).map((m) => m.toLowerCase());
|
||||
// A symbol the layout types through AltGr (the HU `[` is AltGr+F, Ctrl+Alt on Windows) is that
|
||||
// symbol: a bare-symbol row answers it. A letter or a Mod row never does — AltGr+K is not Ctrl+K.
|
||||
if (mods.length === 0 && isSymbol(key) && e.getModifierState("AltGraph"))
|
||||
return keyMatches(e, key);
|
||||
if ((e.ctrlKey || e.metaKey) !== mods.includes("mod")) return false;
|
||||
if (e.shiftKey !== mods.includes("shift")) return false;
|
||||
if (e.altKey !== mods.includes("alt")) return false;
|
||||
|
||||
@@ -11,7 +11,7 @@ import { expect, it } from "vitest";
|
||||
const css = readFileSync(new URL("../index.css", import.meta.url), "utf8");
|
||||
|
||||
it("a maximised player stage takes the blur off the glass card it sits in", () => {
|
||||
const at = css.indexOf("html .glass:has(> .player-stage--max)");
|
||||
const at = css.indexOf("html .glass:has(.player-stage--max)");
|
||||
expect(at, "the escape rule").toBeGreaterThan(css.indexOf('html[data-theme="dark"] .glass'));
|
||||
const body = css.slice(at, css.indexOf("}", at));
|
||||
expect(body).toMatch(/(^|[\s;{])backdrop-filter:\s*none/);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatTimecode, parseTimecode } from "./timecode";
|
||||
|
||||
describe("formatTimecode", () => {
|
||||
it("is m:ss.d below an hour and h:mm:ss.d from one", () => {
|
||||
expect(formatTimecode(12.44)).toBe("0:12.4");
|
||||
expect(formatTimecode(75)).toBe("1:15.0");
|
||||
expect(formatTimecode(3723.4)).toBe("1:02:03.4");
|
||||
});
|
||||
|
||||
it("shows hours when the item reaches one, so an item's fields agree", () => {
|
||||
expect(formatTimecode(65, true)).toBe("0:01:05.0");
|
||||
});
|
||||
|
||||
it("carries a rounded tenth into the next second, never a 60.0", () => {
|
||||
expect(formatTimecode(59.96)).toBe("1:00.0");
|
||||
});
|
||||
|
||||
it("reads a bad clock as zero", () => {
|
||||
expect(formatTimecode(NaN)).toBe("0:00.0");
|
||||
expect(formatTimecode(-3)).toBe("0:00.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTimecode", () => {
|
||||
it("reads every shape it writes, and bare seconds", () => {
|
||||
expect(parseTimecode("0:12.4")).toBeCloseTo(12.4);
|
||||
expect(parseTimecode("1:02:03.4")).toBeCloseTo(3723.4);
|
||||
expect(parseTimecode(" 90.5 ")).toBeCloseTo(90.5);
|
||||
});
|
||||
|
||||
it("is null for what is not a timecode", () => {
|
||||
for (const v of ["", "abc", "1::2", "1:2:3:4", "-5", "1:-2"])
|
||||
expect(parseTimecode(v)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
// An editing timecode: to the tenth of a second, the precision a cut is marked at (the editor's
|
||||
// segment fields, the clip panel's In / Out). Hours appear only when the item reaches an hour, so a
|
||||
// short's "0:12.4" stays short and a film reads "1:02:03.4". `formatDuration` is the whole-second
|
||||
// clock for a card or a player bar; this one is for a field someone types a cut into.
|
||||
|
||||
/** `s` as `m:ss.d`, or `h:mm:ss.d` when `hours` (pass `duration >= 3600` so one item's fields agree). */
|
||||
export function formatTimecode(s: number, hours = false): string {
|
||||
if (!isFinite(s) || s < 0) s = 0;
|
||||
const tenths = Math.round(s * 10);
|
||||
const h = Math.floor(tenths / 36000);
|
||||
const m = Math.floor((tenths % 36000) / 600);
|
||||
const sec = ((tenths % 600) / 10).toFixed(1).padStart(4, "0");
|
||||
return hours || h > 0 ? `${h}:${String(m).padStart(2, "0")}:${sec}` : `${m}:${sec}`;
|
||||
}
|
||||
|
||||
/** A typed timecode back to seconds: `h:mm:ss.d`, `m:ss.d` or bare seconds. Null when it is not one. */
|
||||
export function parseTimecode(v: string): number | null {
|
||||
const parts = v.trim().split(":");
|
||||
if (parts.length > 3 || parts.some((p) => p.trim() === "")) return null;
|
||||
let total = 0;
|
||||
for (const p of parts) {
|
||||
const n = Number(p);
|
||||
if (!isFinite(n) || n < 0) return null;
|
||||
total = total * 60 + n;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
Reference in New Issue
Block a user