Files
siftlode/frontend/src/lib/clipRange.test.ts
T
peter 50157cc2fd 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.
2026-10-10 03:58:27 +02:00

66 lines
2.3 KiB
TypeScript

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 });
});
});