release: 0.105.0 (feature/e38-ad-hoc-clips)

This commit is contained in:
2026-10-10 23:38:13 +02:00
105 changed files with 6539 additions and 899 deletions
+11
View File
@@ -7,6 +7,17 @@ edit the release notes and re-run the generator.
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
## 0.105.0 — 2026-10-10
### New
- Clips: mark a range of what you're watching — a YouTube video or a Plex item — set its ends to the tenth, crop it to a frame drawn on the picture, and cut it straight into your downloads.
### Fixed
- Downloads: a download, a clip or an editor clip that doesn't fit in the storage you have left says so, and is never stored over your cap; YouTube downloads reach their format's height again.
- Player: focus rings, the chosen track or tab, sliders and the progress bar's head are amber on the dark player in every theme, so they show in a light theme too.
## 0.104.0 — 2026-10-10
### New
+1 -1
View File
@@ -1 +1 @@
0.104.0
0.105.0
-16
View File
@@ -437,14 +437,6 @@
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 12,
"endColumn": 13,
"lineCount": 10
}
},
{
"code": "reportAttributeAccessIssue",
"range": {
@@ -1439,14 +1431,6 @@
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 38,
"endColumn": 43,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
+9 -6
View File
@@ -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
+2 -1
View File
@@ -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
+289
View File
@@ -0,0 +1,289 @@
"""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.
A clip may also crop the picture to a frame the user drew over the playing video. The frame is
given in fractions of the box it was drawn in, with that box's aspect: the YouTube iframe never
tells its picture's size, so the frame there is drawn over the player's 16:9 box (a short's 9:16),
and the cut maps it onto the real picture (`crop_filter`) — what lies over a letterbox bar is dropped.
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
from app.plex.sync import HDR_TRANSFERS
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
# 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)'"
# A crop frame is at least this share of its box on each side (the player's frame holds the same
# floor), and what is left of it on the real picture at least _MIN_VISIBLE of it.
MIN_CROP = 0.1
_MIN_VISIBLE = 0.05
# The box aspects a frame may be drawn in: a 9:16 short to a 21:9 film, with room either side.
_CROP_ASPECTS = (0.25, 4.0)
# Float slack on the frame's far edges (x + w, y + h), which the client rounds to 4 decimals.
_EDGE_SLACK = 1e-3
class ClipError(Exception):
"""A clip request that cannot be cut; `reason` is an i18n-mappable key, `params` the numbers its
message names (the cap itself, so the client never says a stale one)."""
def __init__(self, reason: str, **params: float):
self.reason = reason
self.params = params
super().__init__(reason)
def normalize(
source: str,
ref: str,
start_s: float,
end_s: float,
audio_track: int | None = None,
crop: dict | None = None,
) -> dict:
"""The validated, canonical clip spec. Times are rounded to the millisecond and a crop to four
decimals, 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", limit_s=MAX_CLIP_S)
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
frame = _crop(crop)
if frame is not None:
spec["crop"] = frame
return spec
def _crop(raw: dict | None) -> dict | None:
"""A crop frame, canonical — or None for no crop, which a frame over the whole box also is (the
same clip as one cut without a frame, so the same cache key)."""
if raw is None:
return None
try:
x, y, w, h, aspect = (float(raw[k]) for k in ("x", "y", "w", "h", "aspect"))
except (KeyError, TypeError, ValueError) as e:
raise ClipError("bad_crop") from e
if not all(math.isfinite(v) for v in (x, y, w, h, aspect)):
raise ClipError("bad_crop")
lo, hi = _CROP_ASPECTS
if not lo <= aspect <= hi or x < 0 or y < 0:
raise ClipError("bad_crop")
if w < MIN_CROP - _EDGE_SLACK or h < MIN_CROP - _EDGE_SLACK:
raise ClipError("bad_crop")
if x + w > 1 + _EDGE_SLACK or y + h > 1 + _EDGE_SLACK:
raise ClipError("bad_crop")
frame = {"x": x, "y": y, "w": min(w, 1 - x), "h": min(h, 1 - y), "aspect": aspect}
frame = {k: round(v, 4) for k, v in frame.items()}
if (frame["x"], frame["y"], frame["w"], frame["h"]) == (0, 0, 1, 1):
return None
return frame
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":
# PQ / HLG: an 8-bit SDR encode must tonemap them (the one set the Plex stream uses).
return s.get("color_transfer") in HDR_TRANSFERS
return False
def display_aspect(probe: dict) -> float | None:
"""The picture's shape as a player shows it, from an ffprobe `-show_streams` JSON: the first video
stream's width × its sample aspect ratio over its height, turned a quarter when the stream says
it is rotated (ffmpeg turns such a stream upright before any filter of ours, so a crop is cut
from the upright picture). None when the stream has no size."""
for s in probe.get("streams") or []:
if s.get("codec_type") != "video":
continue
w, h = s.get("width"), s.get("height")
if not isinstance(w, int) or not isinstance(h, int) or w <= 0 or h <= 0:
return None
dar = w * (_ratio(s.get("sample_aspect_ratio")) or 1.0) / h
return 1 / dar if _quarter_turned(s) else dar
return None
def _ratio(v: object) -> float | None:
"""'32:27' → 1.185…; '0:1', 'N/A', a missing or malformed value → None."""
if not isinstance(v, str) or ":" not in v:
return None
num, _, den = v.partition(":")
try:
n, d = float(num), float(den)
except ValueError: # silent-ok: a malformed ratio reads as none given; the caller takes 1:1
return None
return n / d if n > 0 and d > 0 else None
def _quarter_turned(stream: dict) -> bool:
rotation: object = (stream.get("tags") or {}).get("rotate")
for sd in stream.get("side_data_list") or []:
if "rotation" in sd:
rotation = sd["rotation"]
try:
return round(float(str(rotation))) % 180 == 90
except ValueError: # silent-ok: no (None) or a malformed rotation: the stream is upright
return False
def crop_filter(crop: dict, dar: float) -> str | None:
"""The ffmpeg `crop` for a frame drawn in a box of `crop["aspect"]`, over a picture of display
aspect `dar` fitted into that box as a player fits it (whole, centred, bars on the short sides).
The frame's part over a bar is dropped: "" when what is left is the whole picture, None when too
little of the frame lies on the picture at all.
In fractions of the input's own width and height, so a sample aspect ratio other than 1:1 (an
anamorphic DVD) needs nothing of its own, and floored to even pixels: an even 4:2:0 frame, and a
window that never runs past the picture's far edge."""
box = crop["aspect"]
pic_w, pic_h = (1.0, box / dar) if dar > box else (dar / box, 1.0)
def span(start: float, size: float, extent: float) -> tuple[float, float]:
origin = (1 - extent) / 2
a = min(1.0, max(0.0, (start - origin) / extent))
b = min(1.0, max(0.0, (start + size - origin) / extent))
return a, b - a
x, w = span(crop["x"], crop["w"], pic_w)
y, h = span(crop["y"], crop["h"], pic_h)
if w < _MIN_VISIBLE or h < _MIN_VISIBLE:
return None
if w > 1 - 1e-4 and h > 1 - 1e-4:
return ""
def px(axis: str, f: float) -> str:
return f"trunc({axis}*{f:.6f}/2)*2"
return f"crop={px('iw', w)}:{px('ih', h)}:{px('iw', x)}:{px('ih', y)}"
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,
crop: str = "",
) -> 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.
A `crop` (crop_filter) comes first: the tonemap and the scale then work on the frame it keeps."""
vf = ",".join([*([crop] if crop else []), *([_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),
]
+31
View File
@@ -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.
+18 -3
View File
@@ -37,9 +37,10 @@ from app.downloads.credentials import host_key_for_url
# Worker-authored strings that this module owns because each is coupled to `_RULES`.
#
# QUOTA_FULL_MESSAGE is raised by the worker and must classify back to `quota_exceeded`; its rule
# below matches a fragment of it, and test_download_errors pins the pair, because a message and its
# rule drifting apart is exactly how a category silently becomes unreachable.
# QUOTA_FULL_MESSAGE and the three *_TOO_BIG_MESSAGEs are raised by the worker and must classify
# back to `quota_exceeded`; their rules below match a fragment of each, and test_download_errors pins
# each one, because a message and its rule drifting apart is exactly how a
# category silently becomes unreachable.
#
# NETWORK_REDACTED_MESSAGE REPLACES a message after classification (`user_error_text`). It is written
# to the shared asset too, and a second job that inherits that failed asset re-diagnoses the stored
@@ -53,6 +54,19 @@ from app.downloads.credentials import host_key_for_url
# constants, imported above; each has a rule below that gives its category back, and a catalog entry
# that gives the site's sentence.
QUOTA_FULL_MESSAGE = "Your download storage is full — free up space, then try again."
# A download, a cut clip, or an upload's converted file, held to the space left, which is not zero:
# "full" would be untrue. All three classify by the one phrase they share.
DOWNLOAD_TOO_BIG_MESSAGE = (
"The video is larger than the download storage you have left — free up space, then try again."
)
CLIP_TOO_BIG_MESSAGE = (
"The clip is larger than the download storage you have left — "
"free up space or cut a shorter clip, then try again."
)
CONVERTED_TOO_BIG_MESSAGE = (
"The converted file is larger than the download storage you have left — "
"free up space, then try again."
)
UPLOAD_MISSING_MESSAGE = "The uploaded file is missing."
NETWORK_REDACTED_MESSAGE = "The server could not reach that address."
@@ -62,6 +76,7 @@ _RULES: tuple[tuple[str, str], ...] = (
# Our own refusal: the worker found the user already at their storage cap (SEC-13). Placed
# first — it is our wording, so it can never be a false match on an upstream message.
("download storage is full", "quota_exceeded"),
("larger than the download storage you have left", "quota_exceeded"),
# Our own redaction of a transport failure (see NETWORK_REDACTED_MESSAGE) — our wording too.
(NETWORK_REDACTED_MESSAGE.lower(), "network"),
# Our own verdicts on a post link (adapters.py) — our wording too: its page holds no single video,
+29 -2
View File
@@ -105,6 +105,28 @@ def log_safe(text: object) -> str:
return _PATH_RE.sub("…", text)
# yt-dlp's HTTP downloader, refusing a file announced larger than `max_filesize`, logs this line and
# RETURNS — no exception and no file (measured; test_formats holds it against the pinned yt-dlp).
_OVER_CAP_LINE = "File is larger than max-filesize"
_OVER_CAP: contextvars.ContextVar[list[bool] | None] = contextvars.ContextVar(
"formats_over_cap", default=None
)
@contextlib.contextmanager
def watching_size_cap() -> Iterator[list[bool]]:
"""While yt-dlp works inside it: a one-slot flag, set when it refused a file over the size cap
(`build_ydl_opts(max_bytes=…)`). It then returns as if it had downloaded, and all the caller
would find is no file — a processing fault, to anyone reading it, for what is the owner's
storage. A context variable, like `hiding`: yt-dlp's logger calls run on the extraction's thread."""
flag = [False]
token = _OVER_CAP.set(flag)
try:
yield flag
finally:
_OVER_CAP.reset(token)
class _YdlLog:
"""yt-dlp's logger protocol, pointed at ours.
@@ -124,6 +146,8 @@ class _YdlLog:
is the one line whose level should say so."""
def _emit(self, level: int, msg: str) -> None:
if _OVER_CAP_LINE in msg and (over := _OVER_CAP.get()) is not None:
over[0] = True
# The line goes as an ARGUMENT, not as the format string. yt-dlp writes progress as
# `[download] 53.2% of 12.00MiB`, and while stdlib logging only applies `%` formatting to
# a record that has args, this way the question never arises — and it is the shape ruff's
@@ -281,8 +305,11 @@ def build_ydl_opts(
after the host-scope + private-asset gate in `downloads.credentials`. It is set AFTER the `extra`
merge so a source adapter can never clobber it.
`max_bytes` (SEC-13) is the caller's remaining storage budget; yt-dlp refuses a format larger
than it rather than filling the disk and only then being caught by the quota accounting."""
`max_bytes` (SEC-13) is the caller's remaining storage budget; yt-dlp's HTTP downloader refuses
a file announced larger than it rather than filling the disk and only then being caught by the
quota accounting. Its ffmpeg downloader (a `download_ranges` fetch) does not read it, nor does
a fragmented download (YouTube's formats: measured, 718 MB fetched with 1 MB left) — so the
pipeline also holds every produced file to what is left by its real size."""
s = normalize(spec)
h = s["max_height"]
opts: dict = {
+294 -16
View File
@@ -7,10 +7,12 @@ from datetime import date, datetime, timedelta, timezone
from pathlib import Path
import yt_dlp
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 +28,9 @@ 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 lookups as plex_lookups
from app.plex import paths as plex_paths
from app.notifications import create_notification
from app.titles import normalize_title
@@ -37,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.
@@ -213,7 +226,7 @@ def _cancel_poll(job_id: int, interval_s: float = _CANCEL_POLL_INTERVAL_S, *, no
def cancelled() -> bool:
if not state["cancelled"] and allow():
state["cancelled"] = _job_status(job_id) not in ("running", None)
state["cancelled"] = _job_status(job_id) not in (JobStatus.RUNNING, None)
return state["cancelled"]
return cancelled
@@ -333,7 +346,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
@@ -347,9 +360,16 @@ 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."""
`max_bytes` (SEC-13) is the owner's remaining storage budget, applied to every attempt. A
`section` fetch goes through yt-dlp's ffmpeg downloader, which does not read it: a clip holds
its own output to the cap (`_process_clip`).
`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 +395,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
@@ -502,8 +525,8 @@ def _download(
# SEC-13: what this user has left. `check_enqueue` only compared the footprint as it
# stood when the job was QUEUED — a download's size is unknown until it finishes, so a
# single job could previously blow through the cap by any amount. Handing the remainder
# to yt-dlp as `max_filesize` makes the format selector refuse an oversized file up
# front instead.
# to yt-dlp as `max_filesize` makes it refuse an oversized file (its HTTP downloader
# checks the announced size) instead.
remaining_bytes = quota.remaining_bytes(cdb, user_id)
# …and zero is a REFUSAL, not "no limit". Earlier jobs of this user may have filled the cap
# while this one sat in the queue, and 0 is not an expressible yt-dlp `max_filesize`, so
@@ -512,8 +535,8 @@ def _download(
# free space, which is what the `quota_exceeded` category tells them.
if remaining_bytes == 0:
raise _OwnerRefused(dlerrors.QUOTA_FULL_MESSAGE)
info = _media_info(
_extract_with_fallback(
with formats.watching_size_cap() as over_cap:
fetched = _extract_with_fallback(
job_id,
asset_id,
url,
@@ -523,7 +546,11 @@ def _download(
cookiefile=str(cookiefile) if cookiefile else None,
max_bytes=remaining_bytes,
)
)
info = _media_info(dict(fetched))
# yt-dlp refused a file larger than what is left: it returns with no file, which would
# otherwise read as a processing fault. The owner's storage, so the owner's refusal.
if over_cap[0]:
raise _OwnerRefused(dlerrors.DOWNLOAD_TOO_BIG_MESSAGE)
# Re-resolve now that yt-dlp's info reveals the true source (a bare/redirected url only
# identifies post-extraction) so the .nfo uniqueid type + uploader_url agree on one source.
adapter = adapters.resolve_adapter(source_kind, source_ref, info)
@@ -536,6 +563,13 @@ def _download(
CONVERTING,
int(info["duration"]) if info.get("duration") else None,
)
# Held to the cap by its real size, as a clip is: yt-dlp's `max_filesize` only stops a plain
# HTTP file that announces its size, and YouTube's fragmented formats never meet it
# (measured: 718 MB fetched with 1 MB left). Read again — other jobs may have landed.
with SessionLocal() as qdb:
room = quota.remaining_bytes(qdb, user_id)
if room is not None and produced.stat().st_size > room:
raise _OwnerRefused(dlerrors.DOWNLOAD_TOO_BIG_MESSAGE)
ext = produced.suffix.lstrip(".").lower()
# Codecs/dims/duration come from a PROBE of the produced file — the ground truth — NOT yt-dlp's
# `info` dict, whose fields are absent for some sources (Facebook among them): deriving
@@ -736,11 +770,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
(`<what>.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
(`<what>.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."""
@@ -755,9 +796,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()
@@ -834,6 +878,13 @@ def _process_edit(job_id: int, asset_id: int) -> None:
# playable as its source: hold it to the same rule as every other ready file.
dest_staging = _ensure_phone_safe(job_id, dest_staging, staging, "editing", total_s)
out_ext = dest_staging.suffix.lstrip(".").lower() or out_ext
# New storage on the owner's footprint, held to what is left by its real size, as a
# download and a player clip are.
if user_id is not None:
with SessionLocal() as qdb:
room = quota.remaining_bytes(qdb, user_id)
if room is not None and dest_staging.stat().st_size > room:
raise StatedFailure(dlerrors.CLIP_TOO_BIG_MESSAGE)
rel = storage.edit_rel_path(user_id, asset_id, out_ext)
storage.place_file(dest_staging, settings.download_root, rel)
@@ -896,6 +947,233 @@ def _process_edit(job_id: int, asset_id: int) -> None:
shutil.rmtree(staging, ignore_errors=True)
def _clip_room(user_id: int) -> int | None:
"""What the clip's owner may still store (`quota.remaining_bytes`), refusing at zero — see
`_download`: zero is a refusal, not "no limit"."""
with SessionLocal() as db:
remaining = quota.remaining_bytes(db, user_id)
if remaining == 0:
raise _OwnerRefused(dlerrors.QUOTA_FULL_MESSAGE)
return remaining
def _clip_source(
job_id: int, asset_id: int, spec: dict, staging: Path, remaining: int | None
) -> 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":
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)
# 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
)
_set_job(job_id, phase="downloading", progress=0)
try:
with formats.watching_size_cap() as over_cap:
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"]),
)
# Should yt-dlp's own cap ever refuse the range (see `_download`): the owner's storage.
if over_cap[0]:
raise _OwnerRefused(dlerrors.CLIP_TOO_BIG_MESSAGE)
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
with SessionLocal() as db:
item = plex_lookups.by_rating_key(db, PlexItem, spec["ref"])
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"], None
def _clip_crop(spec: dict, data: dict) -> str:
"""The clip's crop filter, from its frame and the picture the file really holds ("" for none)."""
frame = spec.get("crop")
if not frame:
return ""
dar = clipmod.display_aspect(data)
if dar is None:
raise StatedFailure("The picture's size could not be read, so the crop cannot be placed.")
crop = clipmod.crop_filter(frame, dar)
if crop is None:
raise StatedFailure("The crop frame lies outside the picture.")
return crop
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)
# Refused before the fetch and the encode when the owner's storage is already full.
remaining = _clip_room(user_id)
src, start, fetched_title = _clip_source(job_id, asset_id, spec, staging, remaining)
data = probe.probe_json(src)
if not data:
raise StatedFailure("The source file could not be read.")
audio_track = spec.get("audio_track")
tracks = clipmod.audio_track_count(data)
if tracks == 0:
# A silent source: the player still names its track 0, and the clip is silent too.
audio_track = None
elif audio_track is not None and audio_track >= tracks:
raise StatedFailure("The chosen audio track is not in the file.")
crop = _clip_crop(spec, data)
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(),
crop=crop,
),
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))
# Held to the cap by its real size: a range fetch passes yt-dlp's `max_filesize` to its
# ffmpeg downloader, which never reads it, and a Plex clip fetches nothing at all. Read
# again, since other jobs of this owner may have landed during the encode.
remaining = _clip_room(user_id)
if remaining is not None and out.stat().st_size > remaining:
raise _OwnerRefused(dlerrors.CLIP_TOO_BIG_MESSAGE)
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)
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, **fields)
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 _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")
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,
@@ -904,7 +1182,7 @@ def _check_converted_fits(owner_id: int | None, old_size: int, new_size: int) ->
return
with SessionLocal() as db:
if not quota.fits_after_conversion(db, owner_id, old_size, new_size):
raise StatedFailure(dlerrors.QUOTA_FULL_MESSAGE)
raise StatedFailure(dlerrors.CONVERTED_TOO_BIG_MESSAGE)
def _unplace(placed: Path | None) -> None:
+6 -4
View File
@@ -15,7 +15,8 @@ the footprint as it stood when the job was created and earlier jobs may have fil
That is a per-job ceiling, not an aggregate ledger: concurrent jobs of one user each read the same
remainder (in-flight bytes are not `ready`, so `footprint` cannot see them), so a wave of
max_concurrent downloads can still land over the cap — the deliberate trade, since a download's size
is unknown until it finishes. Edit jobs do not consult it at all (tracked as SEC-19).
is unknown until it finishes. CLIP jobs consult it before their fetch or encode and again with
their output's real size before it is placed. Edit jobs do not consult it at all (tracked as SEC-19).
"""
from dataclasses import dataclass
@@ -24,7 +25,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
@@ -66,7 +67,8 @@ def resolve(db: Session, user_id: int) -> QuotaLimits:
# "at limit" checks — the worker's claim-window exclusion vs at_concurrency_limit — since a
# 0-limit user is "capped" with zero running jobs, which the SQL exclusion can't see. Keep
# them consistent by never letting the effective limit drop below 1. (To BLOCK a user's
# downloads, suspend them or set max_jobs=0, not max_concurrent=0.)
# downloads, suspend them — the worker leaves a suspended user's queued jobs unclaimed — or
# set max_jobs=0, not max_concurrent=0.)
return QuotaLimits(row.max_bytes, max(1, row.max_concurrent), row.max_jobs, row.unlimited)
return QuotaLimits(
max_bytes=sysconfig.get_int(db, "download_default_max_bytes"),
@@ -86,7 +88,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),
)
+6 -3
View File
@@ -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
+113 -12
View File
@@ -16,11 +16,22 @@ 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 lookups as plex_lookups
from app.plex import paths as plex_paths
class EditError(Exception):
@@ -40,10 +51,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"):
@@ -180,7 +194,7 @@ def enqueue(
profile_snapshot=spec,
display_name=display_name
or (asset.title[:DISPLAY_TEXT_MAX] if asset.status == "ready" and asset.title else None),
status="queued",
status=JobStatus.QUEUED,
queue_pos=next_queue_pos(db),
)
db.add(job)
@@ -188,7 +202,7 @@ def enqueue(
# Cache hit: the file already exists — no worker round-trip needed.
if asset.status == "ready":
job.status = "done"
job.status = JobStatus.DONE
job.progress = 100
asset.last_access_at = datetime.now(timezone.utc)
@@ -197,7 +211,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.
@@ -239,7 +253,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
@@ -251,7 +265,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,
@@ -259,14 +273,101 @@ def enqueue_edit(
edit_spec=spec,
profile_snapshot={},
display_name=display_name or asset.title[:DISPLAY_TEXT_MAX],
status="queued",
status=JobStatus.QUEUED,
queue_pos=next_queue_pos(db),
)
db.add(job)
incr_ref_count(db, asset.id)
if asset.status == "ready": # identical clip already produced for this user → instant
job.status = "done"
job.status = JobStatus.DONE
job.progress = 100
asset.last_access_at = datetime.now(timezone.utc)
db.commit()
db.refresh(job)
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: 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 {
"title": video.title,
"uploader": channel.title if channel else None,
"thumbnail_url": video.thumbnail_url,
"length_s": video.duration_seconds,
}
item = plex_lookups.by_rating_key(db, PlexItem, spec["ref"])
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=JobStatus.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 = JobStatus.DONE
job.progress = 100
asset.last_access_at = datetime.now(timezone.utc)
+17
View File
@@ -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})
+2 -2
View File
@@ -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={},
+8 -4
View File
@@ -123,7 +123,8 @@ class User(Base, TimestampMixin, CanonicalEmailMixin):
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
# Admin-imposed access block, distinct from is_active (approval lifecycle). A suspended
# account can't sign in by any method and its live sessions are rejected; the user is told
# why and pointed at the operator's contact.
# why and pointed at the operator's contact. The worker leaves its queued downloads unclaimed
# until the block is lifted (`worker._claim_job`).
is_suspended: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
# Server-side session revocation (SA4): a monotonic counter bumped on password reset, password
# change, or "log out everywhere". A signed session cookie records the epoch it was minted under;
@@ -933,8 +934,9 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
)
source_kind: Mapped[str] = mapped_column(String(16)) # "youtube" | "url"
source_ref: Mapped[str] = mapped_column(String(512))
# "download" (default), "edit" (a per-user trim/crop derivative of another job) or "upload" (a
# user's own file, converted by the worker when it is not phone-safe — see `downloads.upload`).
# "download" (default), "edit" (a per-user trim/crop derivative of another job), "upload" (a
# user's own file, converted by the worker when it is not phone-safe — see `downloads.upload`) or
# "clip" (a range cut straight from a YouTube video or a Plex item — see `downloads.clip`).
job_kind: Mapped[str] = mapped_column(String(16), default="download", server_default="download")
# For edit jobs: the parent download this clip is cut from (SET NULL if the parent is deleted).
source_job_id: Mapped[int | None] = mapped_column(
@@ -943,7 +945,9 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
source_asset_id: Mapped[int | None] = mapped_column(
ForeignKey("media_assets.id", ondelete="SET NULL")
)
# For edit jobs: the trim/crop recipe {trim?:{start_s,end_s}, crop?:{x,y,w,h}}.
# For edit jobs: the trim/crop recipe {trim?:{start_s,end_s}, crop?:{x,y,w,h}} in pixels. For clip
# jobs: the clip spec {source, ref, start_s, end_s, audio_track?, crop?:{x,y,w,h,aspect}}, its crop
# in fractions of the box it was drawn in (`downloads.clip.normalize`).
edit_spec: Mapped[dict | None] = mapped_column(JSON)
profile_id: Mapped[int | None] = mapped_column(
ForeignKey("download_profiles.id", ondelete="SET NULL")
+3 -2
View File
@@ -35,7 +35,7 @@ from app.config import settings
from app.downloads.edit import encode_thread_cap
from app.models import PlexItem
from app.plex import paths
from app.plex.sync import HW_DECODABLE_CODECS
from app.plex.sync import HDR_TRANSFERS, HW_DECODABLE_CODECS
log = logs.get_logger(__name__)
_HLS_ROOT = (
@@ -203,7 +203,8 @@ _HW_DECODABLE = HW_DECODABLE_CODECS
# plain scale_vaapi=format=nv12 path (Gen 9.5 VPP refuses it) — it needs the OpenCL tonemap graph
# (_venc_opts tonemap branch) when the OpenCL runtime is present, else software libx264. A 10-bit source
# WITHOUT one of these transfers is SDR, not HDR, and must not be tone-mapped. See _probe_video_kind.
_HDR_TRANSFERS = {"smpte2084", "arib-std-b67"}
# THE single source is plex.sync (a clip's tonemap reads it too).
_HDR_TRANSFERS = HDR_TRANSFERS
# The three source classes that drive the transcode-pipeline choice (see _probe_video_kind).
+6 -4
View File
@@ -64,9 +64,11 @@ def classify_playable(vcodec: str | None, acodec: str | None, container: str | N
# outside it (AV1, MPEG-4 ASP/XviD…) is software-only even on the iGPU (it can't decode it to feed its
# own encoder).
HW_DECODABLE_CODECS = frozenset({"h264", "hevc", "h265", "vp9", "vc1", "mpeg2video"})
# Color-transfer values marking an HDR source (PQ / HLG). A 10-bit/HDR source breaks the VAAPI
# scale=nv12 path on Gen 9.5 (see plex.stream) → software transcode, so it counts as NOT HW-capable.
_HDR_TRANSFERS = frozenset({"smpte2084", "arib-std-b67"})
# Color-transfer values marking an HDR source (PQ / HLG). THE single source of this set: plex.stream
# (the tonemap choice) and downloads.clip (a clip's tonemap) import it here, so a transfer added for
# one is tonemapped by all. A 10-bit/HDR source breaks the VAAPI scale=nv12 path on Gen 9.5 (see
# plex.stream) → software transcode, so it counts as NOT HW-capable.
HDR_TRANSFERS = frozenset({"smpte2084", "arib-std-b67"})
def transcode_hw_capable(
@@ -107,7 +109,7 @@ def _video_stream_props(meta: dict) -> tuple[int | None, bool]:
# whole library would drown the sync's own lines.
except (TypeError, ValueError):
depth = None
hdr = str(s.get("colorTrc") or "").lower() in _HDR_TRANSFERS
hdr = str(s.get("colorTrc") or "").lower() in HDR_TRANSFERS
return depth, hdr
return None, False
+2 -1
View File
@@ -484,7 +484,8 @@ def set_user_suspended(
db: Session = Depends(get_db),
) -> dict:
"""Suspend or un-suspend an account. A suspended user can't sign in by any method and any
live session is dropped on its next request. Guards: can't suspend yourself, and can't suspend the last active admin (that would lock admin out). On
live session is dropped on its next request; their queued downloads wait (the worker skips them)
until they are un-suspended. Guards: can't suspend yourself, and can't suspend the last active admin (that would lock admin out). On
un-suspend the user is emailed they can return (suspend is announced reactively, on a blocked
sign-in)."""
suspended = body.suspended
+50 -5
View File
@@ -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,49 @@ def enqueue_edit(
return _serialize_job(db, job)
class ClipCropIn(BaseModel):
x: float
y: float
w: float
h: float
aspect: float
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
# A crop frame (downloads/clip): fractions of the box it was drawn in, and that box's aspect.
crop: ClipCropIn | 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:
crop = body.crop.model_dump() if body.crop else None
spec = clipmod.normalize(
body.source, body.ref, body.start_s, body.end_s, body.audio_track, crop
)
display_name = display_text(body.display_name)
job = service.enqueue_clip(db, user.id, spec, display_name=display_name)
except clipmod.ClipError as e:
status = 404 if e.reason == "not_found" else 422
detail = {"code": "clip", "reason": e.reason, **e.params}
raise HTTPException(status_code=status, detail=detail) 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 +813,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")
+13 -2
View File
@@ -17,7 +17,7 @@ from app.db import get_db
from app.downloads import links as linksmod
from app.downloads import storage
from app.downloads.status import JobStatus
from app.models import DownloadJob, DownloadLink, MediaAsset
from app.models import DownloadJob, DownloadLink, MediaAsset, User
from app.ratelimit import RateLimiter, client_key
from app.security import verify_password
from app.refusal import refused
@@ -44,6 +44,17 @@ def _link_or_404(db: Session, token: str) -> DownloadLink:
return link
def _being_made(db: Session, job: DownloadJob) -> bool:
"""The job is running, or queued for a worker that will take it: the worker leaves a suspended
owner's queued jobs where they are (`worker._claim_job`), as long as the suspension lasts."""
if job.status == JobStatus.RUNNING:
return True
if job.status != JobStatus.QUEUED:
return False
owner = db.get(User, job.user_id)
return owner is not None and not owner.is_suspended
def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAsset]:
job = db.get(DownloadJob, link.job_id)
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
@@ -51,7 +62,7 @@ def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAs
job is not None
and asset is not None
and asset.status in ("pending", "downloading")
and job.status in (JobStatus.QUEUED, JobStatus.RUNNING)
and _being_made(db, job)
):
# Being (re)made — a re-download, or a conversion — so the link works again once it is ready:
# its own reason, so the page can say "try again shortly" rather than "this link is gone".
+39 -11
View File
@@ -29,6 +29,7 @@ from datetime import datetime, timezone
from functools import partial
from sqlalchemy import text
from sqlalchemy.orm import Session
from app import logs
from app import logsink
@@ -44,7 +45,7 @@ from app.downloads.jobdb import (
_reset_asset_pending,
_staging_dir,
)
from app.downloads.status import STOPPED_BY_ROUTE, JobStatus
from app.downloads.status import STOPPED_BY_ROUTE, JobKind, JobStatus
from app.models import DISPLAY_TEXT_MAX, DownloadJob, MediaAsset
log = logs.get_logger(__name__)
@@ -162,7 +163,12 @@ def _claim_job() -> int | None:
seen.) The per-user limit is the quota row's `max_concurrent` (else the sysconfig default);
`unlimited` users are never capped. A final Python `at_concurrency_limit` re-check still guards
the tiny race between the exclusion subquery and the claim (it can only ever reject, never
over-claim), so correctness never depends on the two observations agreeing."""
over-claim), so correctness never depends on the two observations agreeing.
A suspended user's queued jobs are left where they are: suspension blocks their downloads (not
only their sign-in), and lifting it lets them run in their old place. A job already running
when the user is suspended finishes on that worker; one a worker restart puts back in the queue
(`_requeue_if_running`, `_recover_orphans`) waits with the rest."""
with SessionLocal() as db:
default_mc = sysconfig.get_int(db, "download_default_max_concurrent")
rows = db.execute(
@@ -170,6 +176,10 @@ def _claim_job() -> int | None:
"""
SELECT id, user_id FROM download_jobs
WHERE status='queued'
AND NOT EXISTS (
SELECT 1 FROM users u
WHERE u.id = download_jobs.user_id AND u.is_suspended
)
AND NOT EXISTS (
SELECT 1 FROM media_assets a
WHERE a.id = download_jobs.asset_id AND a.status = 'downloading'
@@ -437,7 +447,7 @@ def _run_supervised(job_id: int, asset_id: int) -> None:
if status is None or status in STOPPED_BY_ROUTE:
reason = "cancelled" # a route stopped it (or the row vanished)
break
if status == "running":
if status == JobStatus.RUNNING:
if stale is not None and stale > stall_s:
reason = "stalled"
break
@@ -448,7 +458,7 @@ def _run_supervised(job_id: int, asset_id: int) -> None:
# teardown. A still-running job over the cap is failed ("timeout"); a terminal one (already
# done/error) just gets its lingering child killed, its DB status left intact ("abandon").
if time.monotonic() - started > wall_s:
reason = "timeout" if status == "running" else "abandon"
reason = "timeout" if status == JobStatus.RUNNING else "abandon"
break
# else: a terminal status under the cap (done/error, or a re-queued contended asset) means
# the child is exiting on its own — keep polling; NEVER kill a finishing child (that could
@@ -479,7 +489,7 @@ def _run_supervised(job_id: int, asset_id: int) -> None:
_reset_asset_pending(asset_id)
if proc.returncode != _CHILD_OK:
status, _ = _job_liveness(job_id)
if status == "running":
if status == JobStatus.RUNNING:
_cleanup_staging(asset_id)
_fail_running(
job_id,
@@ -560,15 +570,12 @@ def _process_job(job_id: int) -> None:
asset_id = asset.id if asset else None
asset_status = asset.status if asset else None
asset_error = asset.error if asset else None
if asset_status == "error":
if asset is not None and asset_status == "error":
# A job that meets an already-failed asset gets that failure's explanation — not a bare
# "Download failed". Only the text is on the asset, so it is diagnosed again, for this
# link, with whether a cookie goes with it asked of the same gate the download child
# used (`credentials.usable_cookie`) — as it stands now, so the advice fits this job's Retry.
url = service.source_url(job.source_kind, job.source_ref)
cookie = credentials.usable_cookie(
db, job.user_id, url, asset, formats.normalize(job.profile_snapshot)
)
url, cookie = _failed_link_and_cookie(db, job, asset)
diagnosis = dlerrors.diagnose(asset_error, url=url, cookie_attached=cookie is not None)
del cookie # only whether there is one matters here
@@ -605,13 +612,34 @@ def _process_job(job_id: int) -> None:
_run_supervised(job_id, asset_id)
def _failed_link_and_cookie(
db: Session, job: DownloadJob, asset: MediaAsset
) -> tuple[str | None, str | None]:
"""The link a job's failed asset was fetched from, and the cookie that fetch would go with now.
A clip's link is its source's (a `clip` ref names the user's range, no page), asked of the
clip's own cookie gate, as its fetch was (`_clip_source`); a Plex clip fetches nothing."""
if job.job_kind == JobKind.CLIP:
spec = dict(job.edit_spec or {})
if spec.get("source") != "youtube":
return None, None
url = service.source_url("youtube", spec["ref"])
return url, credentials.usable_clip_cookie(db, job.user_id, url, asset, spec)
url = service.source_url(job.source_kind, job.source_ref)
spec = formats.normalize(job.profile_snapshot)
return url, credentials.usable_cookie(db, job.user_id, url, asset, spec)
def _finish_from_cache(job_id: int, asset_id: int) -> None:
with SessionLocal() as db:
job = db.get(DownloadJob, job_id)
asset = db.get(MediaAsset, asset_id)
if job and asset:
fields = {"status": JobStatus.DONE, "progress": 100, "phase": None}
if not job.display_name and asset.title:
# A clip queued under the placeholder takes the title its sibling's fetch found.
unnamed = not job.display_name or (
job.job_kind == JobKind.CLIP and job.display_name == service.clip_title(None, None)
)
if unnamed and asset.title:
# display_name is VARCHAR(255); a title can be longer (e.g. a recipe in the title).
fields["display_name"] = asset.title[:DISPLAY_TEXT_MAX]
asset.last_access_at = datetime.now(timezone.utc)
+5 -4
View File
@@ -2,9 +2,10 @@
# Source of truth is requirements.txt (the floor ranges); regenerate after editing it with:
# docker run --rm -v "$PWD":/b python:3.12-slim sh -c 'pip install -q -r /b/requirements.txt && pip freeze --exclude pip --exclude setuptools --exclude wheel' | sort
# 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).
# scans it, blocking. A security fix, or a yt-dlp release YouTube's changes force, 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); 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
+6 -4
View File
@@ -16,12 +16,14 @@ argon2-cffi>=23.1,<26
# Download center: yt-dlp does the extraction/download; ffmpeg (OS-level in the Dockerfile)
# does the merge + postprocessing. The [default] extra pulls yt-dlp-ejs (the challenge-solver
# scripts Deno runs for YouTube's n-signature). yt-dlp is release-often (YouTube changes), so
# keep the floor loose and bump it when extraction breaks.
yt-dlp[default]>=2025.5.22
# keep the floor loose and bump it when extraction breaks. 2026.8.19 marks the clients that need a
# stream PO token, which the shipped player clients (config.download_player_clients) rely on.
yt-dlp[default]>=2026.8.19
# Browser impersonation (`impersonate`) for Facebook downloads made with the user's cookie: some
# links answer a signed-in plain request with "Cannot parse data" and only parse as Chrome
# (measured). The range is YT-DLP's, not ours — yt-dlp 2026.07.04 imports curl_cffi only in
# 0.10.x–0.15.x and silently reports every target "unavailable" outside it. Move it when bumping
# (measured). The range is YT-DLP's, not ours — yt-dlp 2026.07.04 imported curl_cffi only in
# 0.10.x–0.15.x and silently reported every target "unavailable" outside it (2026.08.19 takes up to
# 0.16.x; the cap stays on the measured 0.15 until a bump re-measures it). Move it when bumping
# yt-dlp; `tests/test_facebook_impersonation.py::test_the_locked_curl_cffi_can_impersonate_chrome`
# fails the gate if the locked pair can no longer impersonate.
curl_cffi>=0.10,<0.16
+15
View File
@@ -175,6 +175,21 @@ def test_the_cookie_capable_clients_match_the_installed_ytdlp():
assert set(YouTubeAdapter._AUTHED_DEFAULT) <= live
def test_the_shipped_player_clients_exist_in_the_installed_ytdlp():
# yt-dlp skips a client it does not know with a warning and falls back to its own defaults, so
# a bump that renames or drops one (tv_simply, the one measured to deliver HD) would change
# what every download is fetched with, silently. Read from the Settings defaults, not from
# `settings`, which an environment may override.
from yt_dlp.extractor.youtube._base import INNERTUBE_CLIENTS
from app.config import Settings
fields = Settings.model_fields
for knob in ("download_player_clients", "download_player_clients_fallback"):
names = Settings._clients(fields[knob].default)
assert names and set(names) <= set(INNERTUBE_CLIENTS), (knob, names)
def test_youtube_ydl_opts_extra_carries_player_client_and_pot():
ea = YouTubeAdapter().ydl_opts_extra(["android_vr"])["extractor_args"]
assert ea["youtube"]["player_client"] == ["android_vr"]
+810
View File
@@ -0,0 +1,810 @@
"""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 import worker as workermod
from app.config import settings
from app.downloads import clip as clipmod
from app.downloads import errors as dlerrors
from app.downloads import formats, 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 crop ----------------------------------------------------------------------------------------
_FRAME = {"x": 0.5, "y": 0.25, "w": 0.25, "h": 0.5, "aspect": 16 / 9}
def test_a_crop_frame_is_canonical_and_a_frame_over_the_whole_box_is_no_crop():
a = clipmod.normalize("plex", "42", 0, 10, crop={**_FRAME, "x": 0.50004})
b = clipmod.normalize("plex", "42", 0, 10, crop=_FRAME)
assert a["crop"] == b["crop"] == {"x": 0.5, "y": 0.25, "w": 0.25, "h": 0.5, "aspect": 1.7778}
assert clipmod.sig(a) == clipmod.sig(b)
whole = clipmod.normalize(
"plex", "42", 0, 10, crop={"x": 0, "y": 0, "w": 1, "h": 1, "aspect": 1}
)
assert whole == clipmod.normalize("plex", "42", 0, 10)
# A far edge a hair past the box (the client's rounding) is the box's edge.
assert clipmod.normalize("plex", "42", 0, 10, crop={**_FRAME, "w": 0.5004})["crop"]["w"] == 0.5
@pytest.mark.parametrize(
"frame",
[
{**_FRAME, "w": 0.05},
{**_FRAME, "h": 0.09},
{**_FRAME, "x": -0.1},
{**_FRAME, "x": 0.8},
{**_FRAME, "y": 0.6},
{**_FRAME, "aspect": 0},
{**_FRAME, "aspect": 9},
{**_FRAME, "x": float("nan")},
{"x": 0.1, "y": 0.1, "w": 0.5},
{**_FRAME, "h": "half"},
],
)
def test_a_crop_frame_it_cannot_cut_is_refused(frame):
with pytest.raises(clipmod.ClipError) as e:
clipmod.normalize("plex", "42", 0, 10, crop=frame)
assert e.value.reason == "bad_crop"
@pytest.mark.parametrize(
("stream", "dar"),
[
({"width": 1920, "height": 1080}, 16 / 9),
# An anamorphic DVD: 720×480 stored, shown 16:9.
({"width": 720, "height": 480, "sample_aspect_ratio": "32:27"}, 16 / 9),
({"width": 640, "height": 480, "sample_aspect_ratio": "0:1"}, 4 / 3),
# A phone video stored landscape, shown upright.
({"width": 1280, "height": 720, "side_data_list": [{"rotation": -90}]}, 9 / 16),
({"width": 1280, "height": 720, "tags": {"rotate": "270"}}, 9 / 16),
({"width": 1280, "height": 720, "side_data_list": [{"rotation": 180}]}, 16 / 9),
({"width": None, "height": 720}, None),
],
)
def test_the_display_aspect_is_the_picture_as_a_player_shows_it(stream, dar):
got = clipmod.display_aspect(
{"streams": [{"codec_type": "audio"}, {"codec_type": "video", **stream}]}
)
assert got == pytest.approx(dar) if dar else got is None
def _crop_args(f: str) -> list[float]:
# crop=trunc(iw*W/2)*2:trunc(ih*H/2)*2:trunc(iw*X/2)*2:trunc(ih*Y/2)*2 → [W, H, X, Y]
assert f.startswith("crop=")
return [float(p.split("*")[1].split("/")[0]) for p in f[5:].split(":")]
def test_a_frame_on_a_picture_that_fills_its_box_is_cut_as_drawn():
assert _crop_args(clipmod.crop_filter(_FRAME, 16 / 9)) == pytest.approx([0.25, 0.5, 0.5, 0.25])
def test_a_frame_drawn_in_a_16_9_box_over_a_4_3_picture_maps_onto_the_picture():
# The 4:3 picture fills 3/4 of the box's width from 1/8 in: a frame over its right half's middle.
frame = {"x": 0.5, "y": 0.25, "w": 0.1875, "h": 0.5, "aspect": 16 / 9}
assert _crop_args(clipmod.crop_filter(frame, 4 / 3)) == pytest.approx([0.25, 0.5, 0.5, 0.25])
def test_the_part_of_a_frame_over_a_bar_is_dropped_and_a_frame_on_a_bar_alone_is_no_cut():
over = {"x": 0.0, "y": 0.0, "w": 0.25, "h": 0.5, "aspect": 16 / 9}
w, h, x, y = _crop_args(clipmod.crop_filter(over, 4 / 3))
assert (w, h, x, y) == pytest.approx([1 / 6, 0.5, 0, 0], abs=1e-6)
assert clipmod.crop_filter({**over, "w": 0.13}, 4 / 3) is None
# A letterboxed film: the frame over the whole box is the whole picture.
whole = {"x": 0.0, "y": 0.0, "w": 1.0, "h": 1.0, "aspect": 16 / 9}
assert clipmod.crop_filter(whole, 2.39) == ""
def test_the_crop_comes_first_so_the_tonemap_and_the_scale_work_on_what_it_keeps():
f = clipmod.crop_filter(_FRAME, 16 / 9)
vf = _cmd(hdr=True, crop=f)[_cmd(hdr=True, crop=f).index("-vf") + 1]
assert vf.startswith(f + ",") and vf.index("tonemap") < vf.index("min(1080,ih)")
assert "crop" not in _cmd()[_cmd().index("-vf") + 1]
# --- 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", "params"),
[
# The cap travels with its refusal, so the client never states a stale one.
({"end_s": 700}, 422, "too_long", {"limit_s": clipmod.MAX_CLIP_S}),
({"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, params
):
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, **params}
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_cropped_plex_clip_cuts_the_frame_from_the_picture_and_is_its_own_clip(
db, make_user, as_user, plex_episode, worker
):
worker["probe"] = {
"streams": [
{"codec_type": "video", "width": 720, "height": 480, "sample_aspect_ratio": "32:27"}
]
}
c = as_user(make_user())
plain = _clip(c, source="plex", ref="777").json()
job = _clip(c, source="plex", ref="777", crop=_FRAME).json()
assert job["asset"]["id"] != plain["asset"]["id"]
assert job["edit_spec"]["crop"]["w"] == 0.25
job, asset = worker["run"](job)
assert (job.status, asset.status) == ("done", "ready")
vf = worker["cmd"][worker["cmd"].index("-vf") + 1]
# Stored to four decimals, the box's 16:9 is a hair off the file's: well under a pixel.
assert _crop_args(vf.split(",")[0]) == pytest.approx([0.25, 0.5, 0.5, 0.25], abs=1e-4)
def test_a_crop_frame_that_misses_the_picture_fails_and_says_so(
db, make_user, as_user, plex_episode, worker
):
# A 4:3 file, a frame drawn over its pillar bar in a 16:9 box.
worker["probe"] = {"streams": [{"codec_type": "video", "width": 640, "height": 480}]}
frame = {"x": 0.0, "y": 0.0, "w": 0.12, "h": 0.5, "aspect": 16 / 9}
job = _clip(as_user(make_user()), source="plex", ref="777", crop=frame).json()
job, asset = worker["run"](job)
assert (job.status, asset.status) == ("error", "error")
assert "crop" in job.error
assert "cmd" not in worker
def test_a_crop_frame_the_route_cannot_cut_says_why(db, make_user, as_user, yt_video):
r = _clip(as_user(make_user()), source="youtube", ref=yt_video, crop={**_FRAME, "w": 0.01})
assert r.status_code == 422
assert r.json()["detail"] == {"code": "clip", "reason": "bad_crop"}
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"
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)",
)
# --- 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)
# --- held to the owner's storage cap, and a clip that cannot or need not go on ---------------------
def _fetch_range(monkeypatch):
def extract(job_id, asset_id, url, spec, staging, adapter, **kw):
f = Path(staging) / "abcdefghijk.mp4"
f.write_bytes(b"range")
return {"requested_downloads": [{"filepath": str(f)}]}
monkeypatch.setattr(pipeline, "_extract_with_fallback", extract)
@pytest.mark.parametrize("source", ["youtube", "plex"])
def test_a_clip_for_an_owner_whose_storage_is_full_is_refused_before_it_is_cut(
db, make_user, as_user, yt_video, plex_episode, worker, monkeypatch, source
):
_fetch_range(monkeypatch)
ref = yt_video if source == "youtube" else "777"
job = _clip(as_user(make_user()), source=source, ref=ref).json()
monkeypatch.setattr(quota, "remaining_bytes", lambda _db, _uid: 0)
job, asset = worker["run"](job)
# Refused like a download at its cap: the job says why, the asset waits for a Retry.
assert (job.status, job.error, asset.status) == (
"error",
dlerrors.QUOTA_FULL_MESSAGE,
"pending",
)
assert "cmd" not in worker
@pytest.mark.parametrize("source", ["youtube", "plex"])
def test_a_clip_larger_than_what_its_owner_has_left_is_refused_and_not_placed(
db, make_user, as_user, yt_video, plex_episode, worker, monkeypatch, source
):
# The range fetch's own ceiling never reaches yt-dlp's ffmpeg downloader, and a Plex clip
# fetches nothing: the cut file itself is held to the cap.
_fetch_range(monkeypatch)
ref = yt_video if source == "youtube" else "777"
job = _clip(as_user(make_user()), source=source, ref=ref).json()
monkeypatch.setattr(quota, "remaining_bytes", lambda _db, _uid: 3) # the cut is 4 bytes
job, asset = worker["run"](job)
# Not "full": there is room left, just not this much.
assert (job.status, job.error, asset.status, asset.rel_path) == (
"error",
dlerrors.CLIP_TOO_BIG_MESSAGE,
"pending",
None,
)
def test_a_youtube_clip_range_yt_dlp_refuses_for_its_cap_says_it_is_too_big(
db, make_user, as_user, yt_video, worker, monkeypatch
):
# Should yt-dlp's own `max_filesize` refuse the range, it returns with no file (test_formats):
# the owner's storage, not a processing fault, and nothing is cut.
def refused(*_a, max_bytes=None, **_k):
formats._YDL_LOG.debug(
f"[download] File is larger than max-filesize (9000 bytes > {max_bytes} bytes). Aborting."
)
return {"requested_downloads": [{}]}
monkeypatch.setattr(pipeline, "_extract_with_fallback", refused)
job = _clip(as_user(make_user()), source="youtube", ref=yt_video).json()
monkeypatch.setattr(quota, "remaining_bytes", lambda _db, _uid: 500)
job, asset = worker["run"](job)
assert (job.status, job.error_code, job.error, asset.status) == (
"error",
"quota_exceeded",
dlerrors.CLIP_TOO_BIG_MESSAGE,
"pending",
)
assert "cmd" not in worker
def test_a_plex_clip_of_a_silent_file_is_cut_silent(db, make_user, as_user, plex_episode, worker):
# The player names the track it plays 0 even when the file has none.
worker["probe"] = {"streams": [{"codec_type": "video"}]}
job = _clip(as_user(make_user()), source="plex", ref="777", audio_track=0).json()
job, asset = worker["run"](job)
assert (job.status, asset.status) == ("done", "ready")
assert "0:a:0?" in worker["cmd"]
def test_a_plex_clip_of_a_file_the_probe_cannot_read_says_so(
db, make_user, as_user, plex_episode, worker
):
worker["probe"] = None
job = _clip(as_user(make_user()), source="plex", ref="777", audio_track=0).json()
job, asset = worker["run"](job)
assert (job.status, asset.status) == ("error", "error")
assert job.error == "The source file could not be read."
assert "cmd" not in worker
@pytest.mark.parametrize("where", ["fetch", "encode"])
def test_a_paused_clip_frees_its_asset_for_a_later_retry(
db, make_user, as_user, yt_video, worker, monkeypatch, where
):
def stopped(*_a, **_k):
raise pipeline._Aborted() if where == "fetch" else pipeline.editmod.EditAborted()
if where == "fetch":
monkeypatch.setattr(pipeline, "_extract_with_fallback", stopped)
else:
_fetch_range(monkeypatch)
monkeypatch.setattr(pipeline.editmod, "run_ffmpeg", stopped)
job = _clip(as_user(make_user()), source="youtube", ref=yt_video).json()
job, asset = worker["run"](job)
# The route already wrote the job's own state (paused / canceled); the asset is free again.
assert (job.status, job.error, asset.status) == ("running", None, "pending")
# --- a second job on the same clip -----------------------------------------------------------------
def _second_job(db, client, **body):
"""Two jobs for one range: the second shares the first's asset."""
first = _clip(client, **body).json()
second = _clip(client, **body).json()
assert first["asset"]["id"] == second["asset"]["id"]
return db.get(DownloadJob, first["id"]), db.get(DownloadJob, second["id"])
def test_a_second_clip_job_on_a_failed_clip_gets_the_same_youtube_diagnosis(
db, make_user, as_user, yt_video
):
_first, second = _second_job(db, as_user(make_user()), source="youtube", ref=yt_video)
asset = db.get(MediaAsset, second.asset_id)
asset.status = "error"
asset.error = f"ERROR: [youtube] {yt_video}: Sign in to confirm you’re not a bot."
second.status = "running"
db.commit()
workermod._process_job(second.id)
db.expire_all()
job = db.get(DownloadJob, second.id)
assert (job.status, job.error_code, job.error_detail) == (
"error",
"login_required",
"youtube.bot_wall",
)
def test_a_second_clip_job_takes_the_title_its_sibling_found(db, make_user, as_user):
_first, second = _second_job(db, as_user(make_user()), source="youtube", ref="zyxwvutsrqp")
assert second.display_name == "Clip (clip)"
asset = db.get(MediaAsset, second.asset_id)
asset.status, asset.title = "ready", "Found On YouTube (clip)"
second.status = "running"
db.commit()
workermod._finish_from_cache(second.id, asset.id)
db.expire_all()
job = db.get(DownloadJob, second.id)
assert (job.status, job.display_name) == ("done", "Found On YouTube (clip)")
+3
View File
@@ -63,6 +63,9 @@ def test_our_own_worker_refusals_classify_as_themselves():
# fragment of the text. Pinning message and rule together is what stops an edit to the wording
# from silently orphaning the category — the UI would then fall back to "Download failed".
assert classify(dlerrors.QUOTA_FULL_MESSAGE) == "quota_exceeded"
assert classify(dlerrors.CLIP_TOO_BIG_MESSAGE) == "quota_exceeded"
assert classify(dlerrors.CONVERTED_TOO_BIG_MESSAGE) == "quota_exceeded"
assert classify(dlerrors.DOWNLOAD_TOO_BIG_MESSAGE) == "quota_exceeded"
def test_the_redacted_network_text_classifies_back_to_network():
+115 -1
View File
@@ -11,7 +11,8 @@ its OWN session, so every test COMMITS its setup before calling it.
"""
from app.config import settings
from app.downloads import pipeline, quota
from app.downloads import errors as dlerrors
from app.downloads import formats, pipeline, quota
from app.models import DownloadJob, DownloadQuota, MediaAsset, User
from app.worker import _claim_job
@@ -155,6 +156,25 @@ def test_unlimited_user_is_never_excluded(db):
assert _claim_job() == q.id
def test_a_suspended_users_queued_jobs_wait(db):
# Suspending a user blocks their downloads (quota.resolve's advice): a job they queued before
# stays queued, unclaimed, and runs once the suspension is lifted.
s = User(email="suspended@example.com", is_suspended=True)
o = User(email="other@example.com")
db.add_all([s, o])
db.flush()
waits = _job(db, s.id, queue_pos=1)
served = _job(db, o.id, queue_pos=2)
db.commit()
assert _claim_job() == served.id
assert _claim_job() is None
db.expire_all()
assert db.get(DownloadJob, waits.id).status == "queued"
db.get(User, s.id).is_suspended = False
db.commit()
assert _claim_job() == waits.id
# --- #3 · list cap --------------------------------------------------------------------------
@@ -278,3 +298,97 @@ def test_a_refusal_for_one_user_does_not_poison_the_shared_asset(db, monkeypatch
assert db.get(MediaAsset, shared.id).status == "pending"
assert db.get(MediaAsset, shared.id).error is None
assert db.get(DownloadJob, theirs.id).status == "queued"
def test_a_download_over_the_space_left_says_so_and_releases_the_shared_asset(
db, monkeypatch, tmp_path
):
# yt-dlp refuses a file over `max_filesize` by saying so and returning with no file (pinned in
# test_formats). That is the owner's storage, not a processing fault: their refusal, in words
# true of a cap with space still left, and the shared asset goes back for the next job.
monkeypatch.setattr(settings, "download_root", str(tmp_path))
u = User(email="nearcap@example.com")
db.add(u)
db.flush()
db.add(
DownloadQuota(user_id=u.id, max_bytes=1000, max_concurrent=2, max_jobs=100, unlimited=False)
)
held = _sized_asset(db, 400, "near-held")
_job(db, u.id, queue_pos=0, status="done", asset_id=held.id)
shared = MediaAsset(
source_kind="youtube",
source_ref="y" * 11,
format_sig="s",
status="downloading",
ref_count=1,
)
db.add(shared)
db.flush()
mine = _job(db, u.id, queue_pos=1, status="running", asset_id=shared.id)
db.commit()
caps = []
def refuses_over_the_cap(*args, max_bytes=None, **kwargs):
caps.append(max_bytes)
formats._YDL_LOG.debug(
f"[download] File is larger than max-filesize (2000 bytes > {max_bytes} bytes). Aborting."
)
return {"id": "y" * 11, "requested_downloads": [{}]}
monkeypatch.setattr(pipeline, "_extract_with_fallback", refuses_over_the_cap)
pipeline._download(mine.id, shared.id, "youtube", "y" * 11, {}, u.id)
db.expire_all()
job = db.get(DownloadJob, mine.id)
assert caps == [600]
assert (job.status, job.error_code) == ("error", "quota_exceeded")
assert job.error == dlerrors.DOWNLOAD_TOO_BIG_MESSAGE
assert db.get(MediaAsset, shared.id).status == "pending"
def test_a_download_that_outgrows_the_space_left_unannounced_is_refused_and_not_placed(
db, monkeypatch, tmp_path
):
# yt-dlp's `max_filesize` only stops a plain HTTP file that announces its size: YouTube's
# fragmented formats never meet it (measured on localdev: 718 MB fetched with 1 MB left). The
# file it produced is held to what is left by its real size, as a clip is.
monkeypatch.setattr(settings, "download_root", str(tmp_path))
u = User(email="unannounced@example.com")
db.add(u)
db.flush()
db.add(
DownloadQuota(user_id=u.id, max_bytes=1000, max_concurrent=2, max_jobs=100, unlimited=False)
)
held = _sized_asset(db, 400, "unannounced-held")
_job(db, u.id, queue_pos=0, status="done", asset_id=held.id)
shared = MediaAsset(
source_kind="youtube",
source_ref="z" * 11,
format_sig="s",
status="downloading",
ref_count=1,
)
db.add(shared)
db.flush()
mine = _job(db, u.id, queue_pos=1, status="running", asset_id=shared.id)
db.commit()
def fetched_past_the_cap(job_id, asset_id, url, spec, staging, adapter, **kw):
f = staging / "zzzzzzzzzzz.mp4"
f.write_bytes(b"v" * 2000) # 600 left
return {"id": "z" * 11, "requested_downloads": [{"filepath": str(f)}]}
monkeypatch.setattr(pipeline, "_extract_with_fallback", fetched_past_the_cap)
monkeypatch.setattr(pipeline, "_ensure_phone_safe", lambda _j, produced, *_a: produced)
pipeline._download(mine.id, shared.id, "youtube", "z" * 11, {}, u.id)
db.expire_all()
job = db.get(DownloadJob, mine.id)
assert (job.status, job.error_code, job.error) == (
"error",
"quota_exceeded",
dlerrors.DOWNLOAD_TOO_BIG_MESSAGE,
)
asset = db.get(MediaAsset, shared.id)
assert (asset.status, asset.rel_path) == ("pending", None)
assert not any(p.suffix == ".mp4" for p in tmp_path.rglob("*") if "staging" not in p.parts)
+63
View File
@@ -0,0 +1,63 @@
"""An editor clip is held to its owner's storage cap by its real size, as a download and a player
clip are: its file is new storage on the owner's footprint, and nothing else checked it.
Real ffmpeg — the size the check reads is the encoder's.
"""
import shutil
import subprocess
import pytest
from app.config import settings
from app.downloads import errors as dlerrors
from app.downloads import pipeline, quota, service
from app.downloads import upload as uploadmod
from app.models import DownloadJob, DownloadQuota, MediaAsset
pytestmark = pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
def test_an_editor_clip_larger_than_what_is_left_is_refused_and_not_placed(
db, make_user, tmp_path, monkeypatch
):
monkeypatch.setattr(settings, "download_root", str(tmp_path))
src = tmp_path / "in.mp4"
subprocess.run(
[
"ffmpeg", "-y", "-loglevel", "error",
"-f", "lavfi", "-i", "testsrc2=size=160x120:rate=10:duration=2",
"-c:v", "libx264", "-pix_fmt", "yuv420p", str(src),
],
check=True,
) # fmt: skip
u = make_user()
source = uploadmod.ingest_upload(
db, u.id, filename="clip.mp4", src_path=src, size=src.stat().st_size
)
job = service.enqueue_edit(db, u.id, source, {"trim": {"start_s": 0, "end_s": 1.5}})
asset = db.get(MediaAsset, job.asset_id)
job.status, asset.status = "running", "downloading" # the worker's claim
# Ten bytes left: no clip of a real video fits.
db.add(
DownloadQuota(
user_id=u.id,
max_bytes=quota.footprint(db, u.id) + 10,
max_concurrent=2,
max_jobs=100,
unlimited=False,
)
)
db.commit()
pipeline._process_edit(job.id, asset.id)
db.expire_all()
job, asset = db.get(DownloadJob, job.id), db.get(MediaAsset, job.asset_id)
assert (job.status, job.error_code, job.error) == (
"error",
"quota_exceeded",
dlerrors.CLIP_TOO_BIG_MESSAGE,
)
assert asset.rel_path is None
assert not list(tmp_path.glob(".edits/**/*.mp4"))
+44
View File
@@ -3,6 +3,13 @@ playability guarantee) and must SELECT H.264 (not merely prefer it), so shared d
every browser incl. iOS/Safari. Non-mp4 containers and non-H.264 vcodecs are coerced, not honoured.
See the iPad AV1 broken-player fix + the format narrowing."""
import contextlib
import functools
import http.server
import threading
import yt_dlp
from app.downloads import edit as editmod
from app.downloads import formats
@@ -162,3 +169,40 @@ def test_the_mp3_fix_moved_the_audio_cache_identity(monkeypatch):
current = formats.format_sig(audio)
monkeypatch.setattr(formats, "_AUDIO_SIG_VERSION", 4)
assert formats.format_sig(audio) != current
class _QuietFiles(http.server.SimpleHTTPRequestHandler):
def log_message(self, *args): # the test's output is yt-dlp's, not the server's
pass
def handle(self):
# yt-dlp hangs up on a file over the cap as soon as it reads its size.
with contextlib.suppress(ConnectionError):
super().handle()
def test_yt_dlp_refuses_a_file_over_the_cap_without_raising_and_the_watch_sees_it(tmp_path):
# The boundary `watching_size_cap` reads, held against the yt-dlp this image pins: over
# `max_filesize` its HTTP downloader logs one line and RETURNS — no exception, no file — so
# without the watch the pipeline would only find "no output file" and call it a processing fault.
served = tmp_path / "served"
served.mkdir()
(served / "v.mp4").write_bytes(b"\0" * 200_000)
srv = http.server.ThreadingHTTPServer(
("127.0.0.1", 0), functools.partial(_QuietFiles, directory=str(served))
)
threading.Thread(target=srv.serve_forever, daemon=True).start()
url = f"http://127.0.0.1:{srv.server_port}/v.mp4"
try:
for cap, refused in ((1_000, True), (10_000_000, False)):
out = tmp_path / f"out-{cap}"
opts = formats.build_ydl_opts(
{}, str(out / "%(id)s.%(ext)s"), lambda d: None, max_bytes=cap
)
with formats.watching_size_cap() as over, yt_dlp.YoutubeDL(opts) as ydl:
ydl.extract_info(url, download=True)
assert over[0] is refused, cap
files = [p for p in out.glob("*") if p.suffix == ".mp4"] if out.exists() else []
assert bool(files) is not refused, cap
finally:
srv.shutdown()
+9 -2
View File
@@ -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}"
+37 -5
View File
@@ -451,7 +451,7 @@ def test_a_non_ascii_filename_survives_the_header_round_trip(
def test_a_long_display_name_is_truncated_on_EVERY_route_that_takes_one(
db, as_user, make_user, _root, monkeypatch, upload_headers
):
"""The sweep, held end to end rather than at one site.
"""The sweep, held end to end rather than at one site (the clip route joined it later).
`display_text` was introduced for the upload path and applied to three of the five writers of
this varchar(255) column; `POST /api/downloads` and `POST /api/downloads/edit` kept the old
@@ -503,15 +503,32 @@ def test_a_long_display_name_is_truncated_on_EVERY_route_that_takes_one(
assert patched.json()["display_name"] == bound
assert patched.json()["display_uploader"] == bound
# 6. the clip route — the clip panel's name field.
clip = c.post(
"/api/downloads/clip",
json={
"source": "youtube",
"ref": "dQw4w9WgXcQ",
"start_s": 0,
"end_s": 5,
"display_name": long_name,
},
)
assert clip.status_code == 200, clip.text
assert clip.json()["display_name"] == bound
def _display_write_sites() -> dict[tuple[str, str], set[str]]:
"""Every place under `backend/app/` that puts a value into a display field, as
(module relative to `app/`, enclosing function) -> the set of value expressions written there.
TWO SHAPES, because the first version of this guard walked only `ast.Assign` and was therefore
THREE SHAPES, because the first version of this guard walked only `ast.Assign` and was therefore
blind to the shape the upload route itself uses — `SpooledUpload(..., display_name=...)` is an
`ast.keyword`, not an assignment, so the one site with an unbounded REQUEST HEADER behind it was
the site the guard could not see.
the site the guard could not see. The third, `fields["display_name"] = …`, is a dict a finalize
call is spread from (the worker's), which the first two missed as well. A value passed
POSITIONALLY is still unseen: a writer binds it to a name or a keyword first, as every route here
does.
ANNOTATED assignments are excluded on purpose: `display_name: Mapped[str | None] =
mapped_column(...)` in `models.py` and `display_name: str | None = None` in a Pydantic model are
@@ -537,6 +554,13 @@ def _display_write_sites() -> dict[tuple[str, str], set[str]]:
t.id if isinstance(t, ast.Name) else t.attr
for t in node.targets
if isinstance(t, (ast.Name, ast.Attribute))
} | {
# `fields["display_name"] = …` — a dict the write is then spread from.
t.slice.value
for t in node.targets
if isinstance(t, ast.Subscript)
and isinstance(t.slice, ast.Constant)
and isinstance(t.slice.value, str)
}
value = node.value
elif isinstance(node, ast.keyword):
@@ -558,9 +582,12 @@ 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.
# routes enqueue_clip the `display_name=` keyword passes on the name bound above it.
# pipeline._download / _process_clip / worker._finish_from_cache
# slice a fetched or stored title to the column's width.
#
# Held as EXACT EQUALITY rather than as a list of offenders. The offender form could only fail
# upward: refactoring the writers into a shape the walker did not recognise left it asserting
@@ -573,11 +600,16 @@ 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/pipeline.py", "_download"): {"meta.title[:DISPLAY_TEXT_MAX]"},
("downloads/pipeline.py", "_process_clip"): {"named[:DISPLAY_TEXT_MAX]"},
("worker.py", "_finish_from_cache"): {"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'))"},
("routes/downloads.py", "enqueue_download"): {"display_text(body.display_name)"},
("routes/downloads.py", "enqueue_edit"): {"display_text(body.display_name)"},
("routes/downloads.py", "enqueue_clip"): {"display_text(body.display_name)", "display_name"},
("routes/downloads.py", "update_download_meta"): {
"display_text(body.display_name)",
"display_text(body.display_uploader)",
+30
View File
@@ -15,6 +15,7 @@ import pytest
from app import sysconfig
from app.config import settings
from app.downloads import errors as dlerrors
from app.downloads import gc as gcmod
from app.downloads import pipeline, quota, storage
from app.downloads import probe as probemod
@@ -124,6 +125,8 @@ def test_a_conversion_that_outgrows_the_cap_is_refused_and_keeps_the_original(
db.expire_all()
job = db.get(DownloadJob, job.id)
assert (job.status, job.error_code) == ("error", "quota_exceeded")
# 90 of the 100 are still free: the converted file is too big, the storage is not full.
assert job.error == dlerrors.CONVERTED_TOO_BIG_MESSAGE
assert original.exists()
assert [p.name for p in _uploads_dir(_root, u.id).iterdir()] == [original.name]
@@ -558,6 +561,33 @@ def test_a_link_says_preparing_only_while_its_video_is_being_made(
assert r.json()["detail"]["reason"] == reason
def test_a_link_to_a_suspended_owners_waiting_rebuild_says_it_is_gone(
db, as_user, make_user, _root, monkeypatch, upload_headers
):
# The worker leaves a suspended user's queued jobs where they are, maybe for good: like a paused
# rebuild, nothing is making the video, so "try again shortly" would never come true.
_probe(monkeypatch, vcodec="h264")
owner = make_user()
c = as_user(owner)
job = c.post(
"/api/downloads/upload", content=b"h264 bytes", headers=upload_headers("clip.mp4")
).json()
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": False}).json()
db.get(MediaAsset, job["asset"]["id"]).status = "pending"
db.get(DownloadJob, job["id"]).status = "queued"
owner.is_suspended = True
db.commit()
r = c.get(f"/api/public/watch/{link['token']}")
assert r.status_code == 404
assert r.json()["detail"]["reason"] == "video_gone"
# A rebuild already running when the owner was suspended still finishes.
db.get(DownloadJob, job["id"]).status = "running"
db.commit()
assert c.get(f"/api/public/watch/{link['token']}").json()["detail"]["reason"] == (
"video_preparing"
)
def test_the_admin_storage_view_counts_a_waiting_upload(db, as_user, make_user, _root, monkeypatch):
# The same disk the owner's footprint and the GC's cap count (`quota.ON_DISK`).
_probe(monkeypatch)
+45 -1
View File
@@ -112,7 +112,18 @@ style guide.
own chip), never `--warn`: a score is not a warning. What sits on a picture looks the same in both themes,
because the picture does: the broadcast red `--live` under `--live-fg` (never `--danger`: "live" is not an
error), `--rating-media` on a poster's scrim, and `--media-chrome` for the always-dark player's menus
(owner decision F16). Black and white are the media scrims and their text. Five schemes × dark
(owner decision F16). On that chrome `--media-mark` is a marked place (the playhead, the crop frame, a
Plex marker), the focus ring and what is chosen, and `--media-danger` an error, as a light theme's
accent and danger are dark on the chrome: `media-marks` re-points `--focus` and `--chosen` on the
player (the Plex player, the YouTube stage) and on every surface painted `--media-chrome` (its menus,
panels and bars), and a form there (`media-scope`) re-points `--danger` too. A chosen item there is
white like the rest, marked by an amber tick (a track) or underline (a tab, `ui/mediaChrome`), never
the accent as text; a choice filled with the accent keeps it, its own text holds on it. A fill that
must be seen there — a native checkbox or slider (`accent-chosen`), the skip button's countdown — is
the amber too; the Plex progress bar plays white with an amber head, so an amber marker ahead of
the head never reads as already played.
`theme.contract.test.ts` measures the red on the chrome and holds every class string that paints it
to name one of the two. Black and white are the media scrims and their text. Five schemes × dark
and light (B2): **Midnight** (blue), **Forest** (cyan), **Slate** (fuchsia), **Matrix** (moss) each earn their
place with their own hue family; **Fjord** (Midnight's navy with a calm steel-blue accent) is the one kept
exception, the calm twin of the vivid Midnight. A new scheme still needs its own hue family. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode.
@@ -545,6 +556,36 @@ 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, and the picture stands
above it. YouTube: it sits under the video (the card's fit counts it, so the video shrinks to make room)
and the wide side panel shuts to its strip while the clip panel is open (owner; it opens again after, unless
the user opened it meanwhile); maximised, the panel takes the docked bar's place; the browser's fullscreen
steps back to maximised. 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, every focus ring in the panel and the playhead the media amber `--media-mark` (the same
in both themes; a light theme's accent and warn all but vanished there). A typed time that cannot be
read stays in its field once offered (Enter, leaving it), marked invalid with a line under the fields'
row saying how to type one (in the flow: the row below moves down rather than being covered); a time typed over a mark that then moves (a nudge, another segment) is dropped; one
past the end shows where it landed (decimal point or comma).
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; a mark on the very end seeks a
moment short of it, and while a clip is marked the end of the item never closes the player or steps the
queue, and Plex's auto-skip waits. Escape closes the panel before the player, but after anything over
the picture (Plex: the info, the up-next card, the countdown); ⌫ under a dialog is the dialog's. The
scissors the user can see gets the focus back (maximised: the docked bar's), else the player. The panel
keeps its name, Loop and focus when the view is maximised or restored under it. **Crop (owner's picks K1 + a):** a Crop toggle in the panel puts a
frame over the picture (`ui/CropFrame`: on the picture, not its bars; the outside dimmed; drag to move, edges
and corners to resize, thirds while dragging; the arrows move it, a focused corner's resize it, Shift
further; focus ring amber) with a shape — Free, 16:9, 9:16, 1:1 (the largest of it the picture holds) — and
Whole picture; off, the clip is the whole picture. Escape turns the crop off before it closes the panel. A
press outside the frame is still the video's. 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`, held to
`downloads/clip.py` by `clipLimits.contract.test.ts`; the "too long" message names the server's cap).
## Dialogs — _Decided_ (C4, built in S3f)
@@ -730,3 +771,6 @@ 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) |
| 2026-10-10 | Crop in clip mode (E38) | A Crop toggle in the clip panel: a frame over the playing picture (`ui/CropFrame`, the editor's crop too), Free / 16:9 / 9:16 / 1:1, Whole picture; maximised YouTube keeps the panel in the bar's place; the wide side panel shuts while clipping | A frame always on the picture in clip mode (K2); a free shape only | Owner's K1 + a: a crop is rare, so it never covers the picture unasked; a phone or square clip needs a held shape. The iframe hides its picture's size: drawn on the 16:9 box, the cut fits it to the real picture |
| 2026-10-10 | Marks on the player chrome | `--media-mark` (amber) and `--media-danger` (light red), the same in both themes; `media-marks` points the focus ring (`--focus`) and the chosen mark (`--chosen`: a track's tick, a tab's underline; the label stays white) at the amber on the player and every chrome surface; `media-scope` (a form) also the errors at the red | The theme's accent ring, chosen text, warn and danger | On the chrome (#171717) a light theme's accent ring (Slate 2.84:1), warn (2.99:1) and danger (2.77:1) all fall under 3:1; the accent as chosen text under 4.5:1 |
@@ -0,0 +1,265 @@
// @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<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
});
const enqueueClip = vi.fn<(body: ClipRequest) => Promise<unknown>>();
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
return { ...actual, api: { ...actual.api, enqueueClip: (b: ClipRequest) => enqueueClip(b) } };
});
const notify = vi.fn();
vi.mock("../lib/notifications", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/notifications")>();
return { ...actual, notify: (n: unknown) => notify(n) };
});
const notifyActionError = vi.fn();
vi.mock("../lib/actionErrors", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/actionErrors")>();
return { ...actual, notifyActionError: (e: unknown, t: string) => notifyActionError(e, t) };
});
import ClipPanel, { type ClipCrop, 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 = () => {},
duration = 3600,
}: {
src?: ClipSource;
start?: ClipRange;
current?: number;
onClose?: () => void;
onPlayFrom?: (t: number) => void;
onSeek?: (t: number, final: boolean) => void;
duration?: number;
}) {
const [clip, setClip] = useState(start);
const [crop, setCrop] = useState<ClipCrop | null>(null);
return (
<QueryClientProvider client={new QueryClient()}>
<output data-testid="range">{`${clip.start}-${clip.end}`}</output>
<output data-testid="crop">{crop ? JSON.stringify(crop) : "none"}</output>
<ClipPanel
src={src}
clip={clip}
onChange={setClip}
duration={duration}
current={current}
picture={16 / 9}
crop={crop}
onCrop={setCrop}
onSeek={onSeek}
onPlayFrom={onPlayFrom}
onClose={onClose}
/>
</QueryClientProvider>
);
}
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(<Harness />);
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(<Harness />);
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("keeps the focus in a time field that took a typed time on Enter", () => {
render(<Harness />);
const field = () => screen.getAllByRole("textbox", { name: "clip.in" })[0]!;
field().focus();
fireEvent.change(field(), { target: { value: "2:20" } });
fireEvent.keyDown(field(), { key: "Enter" });
expect(range()).toBe("140-170");
expect(document.activeElement).toBe(field());
expect((field() as HTMLInputElement).value).toBe("0:02:20.0");
});
it("shows the mark it holds, not a time it could not take", () => {
render(<Harness start={{ start: 3500, end: 3600 }} />);
const out = () => screen.getAllByRole("textbox", { name: "clip.out" })[0] as HTMLInputElement;
// Past the end: clamped back to the end, and the field says so.
fireEvent.change(out(), { target: { value: "1:15:00" } });
fireEvent.blur(out());
expect(range()).toBe("3500-3600");
expect(out().value).toBe("1:00:00.0");
expect(out().getAttribute("aria-invalid")).toBeNull();
// Not a time at all: kept for fixing, and marked as not taken.
fireEvent.change(out(), { target: { value: "abc" } });
fireEvent.blur(out());
expect(out().value).toBe("abc");
expect(out().getAttribute("aria-invalid")).toBe("true");
expect(range()).toBe("3500-3600");
});
it("says an unreadable time on a line of its own under the fields, read out, over nothing", () => {
render(<Harness />);
const out = () => screen.getAllByRole("textbox", { name: "clip.out" })[0] as HTMLInputElement;
fireEvent.change(out(), { target: { value: "abc" } });
fireEvent.blur(out());
const line = screen.getByRole("alert");
expect(line.textContent).toBe("common.timecodeInvalidHours");
expect(out().getAttribute("aria-describedby")).toBe(line.id);
// In the panel's flow, after the fields' row: it pushes the crop row down rather than lying over
// its buttons and taking their clicks.
expect(line.closest('[role="group"]')).toBeNull();
expect(line.className).not.toMatch(/\babsolute\b/);
expect(line.parentElement?.className).toMatch(/\bmedia-scope\b/);
});
it("takes a decimal comma, as a Hungarian hand types it", () => {
render(<Harness />);
const field = screen.getAllByRole("textbox", { name: "clip.in" })[0]!;
fireEvent.change(field, { target: { value: "1:42,5" } });
fireEvent.keyDown(field, { key: "Enter" });
expect(range()).toBe("102.5-130");
});
it("never seeks the player onto the item's very end (that would finish the item)", () => {
const onSeek = vi.fn();
render(<Harness onSeek={onSeek} duration={600} start={{ start: 590, end: 595 }} />);
fireEvent.keyDown(screen.getByRole("slider", { name: "clip.out" }), { key: "End" });
expect(range()).toBe("590-600");
const [time, final] = onSeek.mock.lastCall!;
expect(final).toBe(true);
expect(time).toBeLessThan(600);
expect(time).toBeGreaterThan(599);
});
it("marks an end at the playhead", () => {
render(<Harness current={115.5} />);
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(<Harness onClose={onClose} start={{ start: 100.12345, end: 130 }} />);
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(<Harness src={{ source: "youtube", ref: "abcdefghijk" }} />);
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(<Harness onClose={onClose} />);
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(<Harness onPlayFrom={onPlayFrom} current={110} />);
fireEvent.click(screen.getByRole("button", { name: "clip.loop" }));
expect(onPlayFrom).toHaveBeenLastCalledWith(100);
onPlayFrom.mockClear();
act(() => rerender(<Harness onPlayFrom={onPlayFrom} current={125} />));
expect(onPlayFrom).not.toHaveBeenCalled();
act(() => rerender(<Harness onPlayFrom={onPlayFrom} current={130.2} />));
expect(onPlayFrom).toHaveBeenLastCalledWith(100);
});
it("cuts the whole picture until the crop is turned on, then the frame with its box's shape", async () => {
render(<Harness />);
expect(screen.queryByRole("group", { name: "clip.shape" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
expect(screen.getByRole("button", { name: "clip.crop" }).getAttribute("aria-pressed")).toBe(
"true",
);
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
await waitFor(() => expect(enqueueClip).toHaveBeenCalled());
expect(enqueueClip.mock.calls[0]![0].crop).toEqual({
x: 0.1,
y: 0.1,
w: 0.8,
h: 0.8,
aspect: 1.7778,
});
});
it("holds the frame to a shape without cutting, and goes back to the whole picture", () => {
render(<Harness />);
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
// A segmented choice inside the panel's form: never a submit.
fireEvent.click(screen.getByRole("button", { name: "9:16" }));
expect(enqueueClip).not.toHaveBeenCalled();
const tall = JSON.parse(screen.getByTestId("crop").textContent!) as ClipCrop;
expect(tall.shape).toBe("9:16");
expect(tall.rect.h).toBeCloseTo(1, 6);
expect((tall.rect.w * 16) / 9 / tall.rect.h).toBeCloseTo(9 / 16, 6);
fireEvent.click(screen.getByRole("button", { name: "clip.whole" }));
expect(JSON.parse(screen.getByTestId("crop").textContent!)).toEqual({
rect: { x: 0, y: 0, w: 1, h: 1 },
shape: "free",
});
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
expect(screen.getByTestId("crop").textContent).toBe("none");
});
});
+468
View File
@@ -0,0 +1,468 @@
import { useEffect, useEffectEvent, useId, useRef, useState, type SubmitEvent } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Crop, Crosshair, Maximize, Minus, Plus, Repeat, Scissors, X } from "lucide-react";
import { api, type ClipRequest } from "../lib/api";
import { DISPLAY_NAME_MAX } from "../lib/api/downloads";
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 } from "../lib/timecode";
import { handleBounds, markIn, markOut, zoomWindow, type ClipRange } from "../lib/clipRange";
import { fitRatio, fracRatio, WHOLE, type Frac } from "../lib/cropGeometry";
import { RangeTrack, RulerTicks, type RangeWindow } from "./ui/RangeTrack";
import { CropFrame } from "./ui/CropFrame";
import SegmentedToggle from "./ui/SegmentedToggle";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Input } from "./ui/Input";
import { TimecodeError, TimecodeField } from "./ui/TimecodeField";
import { useTimecodeField, type TimecodeDraft } from "./ui/useTimecodeField";
// 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).
//
// A clip is the whole picture unless the user turns the crop on: then a frame over the video
// (`ClipCropFrame`, which the player places over its picture) marks the part to keep, free or held
// to an output shape. The crop is the caller's state too, so the player's Escape can turn it off
// before it closes the panel.
/** The output shapes a crop frame can be held to (width / height of the clip), or a free frame. */
type ClipShape = "free" | "16:9" | "9:16" | "1:1";
const SHAPES: Record<Exclude<ClipShape, "free">, number> = {
"16:9": 16 / 9,
"9:16": 9 / 16,
"1:1": 1,
};
/** A clip's crop: the frame, in fractions of the picture, and the shape it is held to. */
export interface ClipCrop {
rect: Frac;
shape: ClipShape;
}
const CROP_START: ClipCrop = { rect: { x: 0.1, y: 0.1, w: 0.8, h: 0.8 }, shape: "free" };
const round4 = (v: number) => Math.round(v * 10_000) / 10_000;
const lockOf = (crop: ClipCrop, picture: number) =>
crop.shape === "free" ? null : fracRatio(SHAPES[crop.shape], picture);
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;
/** The shape (width / height) of the picture the crop frame is drawn on. */
picture: number;
crop: ClipCrop | null;
onCrop: (c: ClipCrop | null) => void;
/** 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;
// How far short of the item's end a seek stops: a mark on the very end moves the player to its last
// moment, not past it. A player put at its end ENDS, and the item counts as finished — YouTube marks
// it watched and shows its end screen, a Plex position reads as the whole — while a mark there is only
// a place. (The players keep themselves and the clip open at an end while one is marked; this keeps
// the item unfinished.)
const LAST_MOMENT_S = 0.25;
// 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.
// The media amber (`--media-mark`), not the theme's warn: a light theme's is dark on this bar. Every
// focus ring in the panel is that amber as well (media-scope re-points --focus), as the crop frame's
// is: the theme accent's ring was under 3:1 on the chrome.
const PLAYHEAD = "bg-media-mark";
export default function ClipPanel({
src,
clip,
onChange,
duration,
current,
picture,
crop,
onCrop,
onSeek,
onPlayFrom,
onClose,
}: Props) {
const { t } = useTranslation();
const qc = useQueryClient();
const rootRef = useRef<HTMLFormElement>(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<RangeWindow | null>(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. Without
// scrolling there: the player's card is still fitting itself around the new panel at this point,
// and a card that has to scroll (a short window) scrolled the top of the video out of sight.
useEffect(() => {
rootRef.current
?.querySelector<HTMLElement>('[data-handle="in"]')
?.focus({ preventScroll: true });
}, []);
// The crop turned off from its frame (Escape there): the frame took the focus with it, so it goes to
// the Crop toggle rather than to <body>, where the player's dialog no longer holds it.
const cropButtonRef = useRef<HTMLButtonElement>(null);
const cropOn = crop != null;
useEffect(() => {
if (cropOn) return;
const lost = document.activeElement == null || document.activeElement === document.body;
if (lost) cropButtonRef.current?.focus();
}, [cropOn]);
// "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 seek = (time: number, final: boolean) =>
onSeek(Math.max(0, Math.min(time, duration - LAST_MOMENT_S)), final);
const moveIn = (time: number, final: boolean) => {
setFrozen(final ? null : (frozen ?? view));
const next = markIn(clip, time, duration);
onChange(next);
seek(next.start, final);
};
const moveOut = (time: number, final: boolean) => {
setFrozen(final ? null : (frozen ?? view));
const next = markOut(clip, time, duration);
onChange(next);
seek(next.end, final);
};
const inField = useTimecodeField(clip.start, hours, (v) => moveIn(v, true));
const outField = useTimecodeField(clip.end, hours, (v) => moveOut(v, true));
const timeErrorId = useId();
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 } : {}),
...(crop
? {
crop: {
x: round4(crop.rect.x),
y: round4(crop.rect.y),
w: round4(crop.rect.w),
h: round4(crop.rect.h),
aspect: round4(picture),
},
}
: {}),
...(name.trim() ? { display_name: name.trim() } : {}),
};
const submit = async (e: SubmitEvent<HTMLFormElement>) => {
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 (
<form
ref={rootRef}
onSubmit={submit}
aria-label={t("clip.title")}
data-testid="clip-panel"
className="media-scope flex flex-col gap-2 bg-media-chrome/95 px-3 py-2.5 text-sm"
// The panel's own presses are not the video's (play/pause) nor the backdrop's (close).
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-3">
<span className="flex shrink-0 items-center gap-1.5 font-medium">
<Scissors className="h-4 w-4" aria-hidden="true" />
{t("clip.title")}
</span>
<RangeTrack
label={t("clip.overview")}
duration={duration}
playhead={current}
playheadClassName={PLAYHEAD}
onSeek={(time, final) => final && seek(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" },
]}
/>
<IconButton variant="overlay" size="sm" onClick={onClose} label={t("clip.close")}>
<X />
</IconButton>
</div>
<RangeTrack
label={t("clip.strip")}
duration={duration}
view={view}
playhead={current}
playheadClassName={PLAYHEAD}
onSeek={seek}
className="h-14 cursor-pointer rounded-md bg-white/10 text-white/45"
background={
<RulerTicks view={view} format={(s) => 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",
},
]}
/>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<MarkField
label={t("clip.in")}
value={clip.start}
field={inField}
errorId={timeErrorId}
onSet={(v) => moveIn(v, true)}
nudge={NUDGE_S}
hereLabel={t("clip.inHere")}
onHere={() => moveIn(current, true)}
/>
<MarkField
label={t("clip.out")}
value={clip.end}
field={outField}
errorId={timeErrorId}
onSet={(v) => moveOut(v, true)}
nudge={NUDGE_S}
hereLabel={t("clip.outHere")}
onHere={() => moveOut(current, true)}
/>
<span className="tabular-nums text-muted" aria-live="polite">
{t("clip.length", { length: formatTimecode(length) })}
</span>
<Button
variant="overlay"
size="sm"
pressed={looping}
icon={<Repeat />}
onClick={() => {
if (!looping) onPlayFrom(clip.start);
setLooping(!looping);
}}
>
{t("clip.loop")}
</Button>
</div>
{(inField.invalid || outField.invalid) && <TimecodeError id={timeErrorId} hours={hours} />}
<div className="flex flex-wrap items-center gap-2">
<Button
ref={cropButtonRef}
variant="overlay"
size="sm"
pressed={crop != null}
icon={<Crop />}
onClick={() => onCrop(crop ? null : CROP_START)}
>
{t("clip.crop")}
</Button>
{crop && (
<>
<SegmentedToggle
ariaLabel={t("clip.shape")}
value={crop.shape}
onChange={(shape) => {
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" },
]}
/>
<Button
variant="overlay"
size="sm"
icon={<Maximize />}
onClick={() => onCrop({ rect: WHOLE, shape: "free" })}
>
{t("clip.whole")}
</Button>
</>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<Input
size="sm"
className="min-w-48 flex-1"
value={name}
maxLength={DISPLAY_NAME_MAX}
onChange={(e) => setName(e.target.value)}
placeholder={t("clip.namePlaceholder")}
aria-label={t("clip.name")}
/>
<Button variant="overlay" size="sm" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button type="submit" variant="primary" size="sm" loading={busy} icon={<Scissors />}>
{t("clip.cut")}
</Button>
</div>
</form>
);
}
/** 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,
field,
errorId,
onSet,
nudge,
hereLabel,
onHere,
}: {
label: string;
value: number;
field: TimecodeDraft;
errorId: string;
onSet: (v: number) => void;
nudge: number;
hereLabel: string;
onHere: () => void;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-1" role="group" aria-label={label}>
<span className="mr-1 text-muted">{label}</span>
<IconButton
variant="overlay"
size="sm"
onClick={() => onSet(value - nudge)}
label={t("clip.earlier", { label })}
>
<Minus />
</IconButton>
<TimecodeField
size="sm"
className="w-24 text-center tabular-nums"
field={field}
errorId={errorId}
aria-label={label}
/>
<IconButton
variant="overlay"
size="sm"
onClick={() => onSet(value + nudge)}
label={t("clip.later", { label })}
>
<Plus />
</IconButton>
<IconButton variant="overlay" size="sm" onClick={onHere} label={hereLabel}>
<Crosshair />
</IconButton>
</div>
);
}
/** 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 (
<CropFrame
aspect={picture}
rect={crop.rect}
ratio={lockOf(crop, picture)}
onChange={(rect) => onCrop({ ...crop, rect })}
className="z-base"
/>
);
}
@@ -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; onStateChange?: (e: { data: number }) => 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?: typeof playerEvents },
) {
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");
@@ -436,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<HTMLElement>(".player-stage")!.parentElement!.parentElement!;
// stage → its wrapper (with the clip panel) → the card → the row
const row =
document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!
.parentElement!;
expect(row.className).not.toMatch(/px-14/);
});
@@ -534,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(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const row = document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!;
// stage → its wrapper (with the clip panel) → the card → the row
const row =
document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!
.parentElement!;
expect(row.style.maxWidth).toBe("min(56rem, max(36rem, (100dvh - 3rem - 160px) * 16 / 9))");
});
});
@@ -586,7 +632,7 @@ describe("PlayerModal — the side panel on a wide window", () => {
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const stage = document.querySelector<HTMLElement>(".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|$)/);
@@ -616,7 +662,8 @@ describe("PlayerModal — the side panel on a wide window", () => {
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const row = () =>
document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!;
document.querySelector<HTMLElement>(".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");
@@ -644,6 +691,60 @@ describe("PlayerModal — the side panel on a wide window", () => {
expect(row().style.maxWidth).toBe("calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem)");
});
it("gives the focus to the player when the scissors that opened the clip is not there to take it", async () => {
wideWindow(true);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
// Shut by the user: closing the clip leaves it shut, and its strip has no scissors.
fireEvent.click(screen.getByRole("button", { name: "player.collapsePanel" }));
act(
() =>
void window.dispatchEvent(
new KeyboardEvent("keydown", { key: "c", bubbles: true, cancelable: true }),
),
);
fireEvent.click(screen.getByRole("button", { name: "clip.close" }));
await waitFor(() => expect(document.activeElement).not.toBe(document.body));
expect(screen.getByRole("dialog").contains(document.activeElement)).toBe(true);
});
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(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} 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<HTMLElement>(".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({
@@ -682,7 +783,7 @@ describe("PlayerModal — the side panel on a wide window", () => {
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const stage = document.querySelector<HTMLElement>(".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) => {
@@ -778,3 +879,156 @@ 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; 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(
() =>
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(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={onClose} onState={() => {}} />,
);
};
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 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");
const stage = document.querySelector(".player-stage--max")!;
expect(stage).toBeTruthy();
key("]");
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(
<PlayerModal video={S} queue={[S]} startIndex={0} onClose={() => {}} 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 <body>.
expect(document.activeElement).toBe(screen.getByRole("button", { name: "clip.crop" }));
key("Escape");
expect(screen.queryByTestId("clip-panel")).toBeNull();
});
it("gives the focus back to the scissors the user can see: in the maximised view, the bar's", async () => {
open();
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
key("f");
key("c");
key("Escape");
const stage = document.querySelector(".player-stage--max")!;
await waitFor(() =>
expect(document.activeElement?.getAttribute("aria-label")).toBe("clip.open"),
);
// The card's own scissors lies under the maximised stage: a Space there reopened the panel.
expect(stage.contains(document.activeElement)).toBe(true);
});
it("keeps what was typed in the panel when the view is maximised under it", async () => {
open();
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
key("c");
fireEvent.change(screen.getByRole("textbox", { name: "clip.name" }), {
target: { value: "The bit" },
});
const loop = screen.getByRole("button", { name: "clip.loop" });
fireEvent.click(loop);
act(() => loop.focus());
const panel = screen.getByTestId("clip-panel");
key("f");
expect(document.querySelector(".player-stage--max")!.contains(panel)).toBe(true);
expect(screen.getByTestId("clip-panel")).toBe(panel);
expect((screen.getByRole("textbox", { name: "clip.name" }) as HTMLInputElement).value).toBe(
"The bit",
);
expect(loop.getAttribute("aria-pressed")).toBe("true");
expect(document.activeElement).toBe(loop);
// And back: the dock the panel sat in goes, the panel and its focus stay.
key("f");
expect(document.querySelector(".player-stage--max")).toBeNull();
expect(screen.getByTestId("clip-panel")).toBe(panel);
expect(document.activeElement).toBe(loop);
});
it("does not step on to the next video when one ends while a clip is marked", async () => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
const B = vid("B", 0);
renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
// Auto-advance on: an ending video steps on to B.
fireEvent.click(screen.getAllByRole("button", { name: /^player\.autoAdvance\.label/ })[0]!);
key("c");
act(() => playerEvents.onStateChange?.({ data: 0 }));
await settle();
expect(lastStart()?.videoId).toBe("A");
expect(screen.getByTestId("clip-panel")).toBeTruthy();
});
});
+403 -159
View File
@@ -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, { ClipCropFrame, type ClipCrop } from "./ClipPanel";
import { markInAt, markOutAt, openAt, type ClipRange } from "../lib/clipRange";
import { api, type Video, type VideoStatus } from "../lib/api";
import type { YTPlayer } from "../lib/youtube";
import {
@@ -242,6 +245,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
@@ -255,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 <body>, where the player's keys would no longer reach it.
const panelToggleRef = useRef<HTMLButtonElement>(null);
@@ -262,6 +285,7 @@ export default function PlayerModal({
const togglePanel = (shut: boolean) => {
refocusToggle.current = true;
setPanelShut(shut);
setShutForClip(false);
};
useEffect(() => {
if (!refocusToggle.current) return;
@@ -300,12 +324,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,13 +339,146 @@ 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.
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<ClipRange | null>(null);
// The clip's crop frame over the picture, or null for the whole picture.
const [crop, setCrop] = useState<ClipCrop | null>(null);
const [clipFor, setClipFor] = useState(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<HTMLDivElement>(null);
const [clipDockH, setClipDockH] = useState(0);
const clipDocked = clipOpen && (maximized || isFullscreen);
// Its two places are two parents (the dock is IN the stage, the card's slot under it), and a panel
// rendered in the other one is a new panel: the typed name, Loop and the focus were lost on every
// F. So it renders once, into a host node of its own, and the host moves between the two.
const [clipHost] = useState(() => document.createElement("div"));
const clipSlotRef = useRef<HTMLDivElement>(null);
// The control focused in the panel. A move is a remove and an insert, and the focus goes with
// neither — leaving the big view, React has already removed the dock (and the host in it) before
// the move below runs, so the focus is on <body> by then and only this still knows where it was.
const clipFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
const onIn = (e: FocusEvent) => {
clipFocusRef.current = e.target instanceof HTMLElement ? e.target : null;
};
const onOut = (e: FocusEvent) => {
// Left for somewhere else (not a removal, which fires nothing).
if (e.relatedTarget instanceof Node && !clipHost.contains(e.relatedTarget))
clipFocusRef.current = null;
};
clipHost.addEventListener("focusin", onIn);
clipHost.addEventListener("focusout", onOut);
return () => {
clipHost.removeEventListener("focusin", onIn);
clipHost.removeEventListener("focusout", onOut);
};
}, [clipHost]);
useLayoutEffect(() => {
const slot = clipDocked ? clipDockRef.current : clipSlotRef.current;
if (!clipOpen || !slot || clipHost.parentElement === slot) return;
const active = document.activeElement;
const lost = active == null || active === document.body;
const focused = clipHost.contains(active) ? active : lost ? clipFocusRef.current : null;
slot.appendChild(clipHost);
if (focused instanceof HTMLElement && clipHost.contains(focused))
focused.focus({ preventScroll: true });
}, [clipOpen, clipDocked, clipHost]);
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(() => {
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 gets the focus back when the panel closes: the one the user can see (the docked
// bar's in the big views — the card's lies under the maximised stage, and a Space on it reopened the
// panel), else the player itself (the card's is not there in a shut wide panel or for a video opened
// from a link). Also when the panel went with its video (a queue step), not only when closed.
const clipButtonRef = useRef<HTMLButtonElement>(null);
const clipDockButtonRef = useRef<HTMLButtonElement>(null);
const refocusClip = useRef(false);
const clipOpenRef = useLatest(clipOpen);
const closeClip = () => {
refocusClip.current = true;
setClip(null);
setCrop(null);
if (shutForClip) {
setPanelShut(false);
setShutForClip(false);
}
};
// Read at the moment the panel closes: the view then decides, a later F is not a close.
const refocusAfterClip = useEffectEvent(() => {
const lost = document.activeElement == null || document.activeElement === document.body;
if (!refocusClip.current && !lost) return;
refocusClip.current = false;
const big = maximized || isFullscreen;
const target = big ? clipDockButtonRef.current : clipButtonRef.current;
if (target) target.focus();
else focusModal();
});
useEffect(() => {
if (!clipOpen) refocusAfterClip();
}, [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 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 (isFullscreen) toggleTrueFullscreen();
if (!clip && wide && !panelShut) {
setPanelShut(true);
setShutForClip(true);
}
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.
@@ -479,6 +636,8 @@ export default function PlayerModal({
// advance in the chosen direction, wrapping at the ends only when loop is "all" (a single-item
// list repeats). Uses the live queue + settings via refs so it's correct from the bound handler.
const advanceOnEnd = () => {
// Marking a clip, the end is a place, not a finish: stepping on would drop the clip.
if (clipOpenRef.current) return;
const { autoMode: a, loopMode: l } = modeRef.current;
if (l === "one") return replayCurrent();
const { queue: q, index: i } = navRef.current;
@@ -550,7 +709,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
@@ -584,6 +744,12 @@ export default function PlayerModal({
return;
}
dispatch(e, "ytPlayer", {
clip: () => {
if (clip) closeClip();
else openClip();
},
markIn: () => markClip(markInAt),
markOut: () => markClip(markOutAt),
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
@@ -592,6 +758,14 @@ 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, and a crop
// turns off before the panel closes.
if (clip) {
ev.preventDefault();
if (crop) setCrop(null);
else 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.
@@ -654,7 +828,7 @@ export default function PlayerModal({
setPlayerError,
setLiveData,
reapplyStored,
focusModal,
reclaimFocus,
advanceOnEnd,
sessionPosRef,
qc,
@@ -668,6 +842,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 && (
<ClipPanel
src={{ source: "youtube", ref: currentVideoId }}
clip={clip}
onChange={setClip}
duration={clipClock.duration}
current={clipClock.now}
picture={picture}
crop={crop}
onCrop={setCrop}
onSeek={(time, final) => {
// 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;
@@ -709,52 +906,60 @@ 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}))`,
}}
>
<div
ref={cardRef}
tabIndex={-1}
// Stacked, the card is what scrolls (its details under the video): the wheel's, while it can.
data-wheel-scrolls={wide ? undefined : ""}
// Stacked (and wide in clip mode), the card is what scrolls (what sits under the video): the
// wheel's, while it can.
data-wheel-scrolls={wide && !clipOpen ? undefined : ""}
className={`${SURFACE.panel} relative flex-1 min-w-0 max-h-full outline-hidden ${
wide ? "flex overflow-hidden" : "overflow-y-auto"
wide ? `flex ${clipOpen ? "overflow-y-auto" : "overflow-hidden"}` : "overflow-y-auto"
}`}
onClick={(e) => e.stopPropagation()}
>
<div
ref={stageRef}
// The wheel over the video is its volume, even inside a stacked card that scrolls.
data-wheel-volume=""
className={`player-stage relative aspect-video w-full bg-black overflow-hidden ${
wide ? "flex-1 min-w-0 self-start rounded-l-2xl" : "rounded-t-2xl"
} ${maximized ? "player-stage--max" : ""}`}
>
{/* 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). */}
<div className={wide ? "flex-1 min-w-0 self-start" : undefined}>
<div
ref={stageRef}
// The wheel over the video is its volume, even inside a stacked card that scrolls.
data-wheel-volume=""
className={`player-stage media-marks relative aspect-video w-full bg-black overflow-hidden ${
wide ? (clipOpen ? "rounded-tl-2xl" : "rounded-l-2xl") : "rounded-t-2xl"
} ${maximized ? "player-stage--max" : ""}`}
>
{/* 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. */}
<div
className={
maximized || isFullscreen
? "absolute inset-x-0 top-0 bottom-14"
: "relative w-full h-full"
}
>
<div
ref={mountRef}
className={`w-full h-full ${playerError != null ? "invisible" : ""}`}
/>
{/* Interaction layer over the CENTRE of the video: catches click (play/pause) + wheel
className={
maximized || isFullscreen
? `absolute inset-x-0 top-0 ${clipDocked ? "" : "bottom-14"}`
: "relative w-full h-full"
}
// Docked, the clip panel takes the bar's place: the video stands above its height.
style={clipDocked ? { bottom: clipDockH } : undefined}
>
<div
ref={mountRef}
className={`w-full h-full ${playerError != null ? "invisible" : ""}`}
/>
{/* 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,
@@ -765,25 +970,30 @@ 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) => (
<div
key={i}
data-testid="yt-click-layer"
onClick={() => {
focusModal();
togglePlay();
}}
title={t("player.shortcutsHint")}
aria-hidden="true"
style={box}
className={`absolute z-base cursor-pointer ${
nativeControls ? "pointer-events-none" : ""
}`}
/>
))}
</div>
{/* Our own control bar for the big views (maximise / our fullscreen), where the card's
{playerError == null &&
YT_CLICK_LAYER.map((box, i) => (
<div
key={i}
data-testid="yt-click-layer"
onClick={() => {
focusModal();
togglePlay();
}}
title={t("player.shortcutsHint")}
aria-hidden="true"
style={box}
className={`absolute z-base cursor-pointer ${
nativeControls ? "pointer-events-none" : ""
}`}
/>
))}
{/* 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 && (
<ClipCropFrame picture={picture} crop={crop} onCrop={setCrop} />
)}
</div>
{/* 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
<body> 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
@@ -792,124 +1002,145 @@ 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 && <Toaster overlay order={overlayOrder} />}
{(maximized || isFullscreen) && (
<div
onClick={(e) => 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 && (
<>
<PlayerTransportControls
variant="docked"
index={index}
queueLength={queue!.length}
autoMode={autoMode}
loopMode={loopMode}
onPrev={goPrev}
onNext={goNext}
onCycleAuto={cycleAuto}
onCycleLoop={cycleLoop}
/>
<span className="h-4 w-px bg-white/20" />
</>
)}
{/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both
{isFullscreen && <Toaster overlay order={overlayOrder} />}
{clipDocked && (
<div ref={clipDockRef} className="absolute inset-x-0 bottom-0 z-menu" />
)}
{(maximized || isFullscreen) && !clipDocked && (
<div
onClick={(e) => 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 media-marks px-3 text-sm text-white"
>
{hasQueue && (
<>
<PlayerTransportControls
variant="docked"
index={index}
queueLength={queue!.length}
autoMode={autoMode}
loopMode={loopMode}
onPrev={goPrev}
onNext={goNext}
onCycleAuto={cycleAuto}
onCycleLoop={cycleLoop}
/>
<span className="h-4 w-px bg-white/20" />
</>
)}
{/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both
portal into the fullscreen element rather than <body> (a body-portaled node is
painted underneath the element holding the lock, i.e. invisible). */}
{!navigated && <AddToPlaylist videoId={active.id} variant="overlay" />}
{!navigated && (
<DownloadButton videoId={active.id} title={active.title} variant="overlay" />
)}
{!navigated && (
{!navigated && <AddToPlaylist videoId={active.id} variant="overlay" />}
{!navigated && (
<DownloadButton videoId={active.id} title={active.title} variant="overlay" />
)}
{/* Clip mode here: the clip panel takes this bar's place. */}
{playerError == null && (
<IconButton
ref={clipDockButtonRef}
variant="overlay"
onClick={openClip}
label={t("clip.open")}
>
<Scissors />
</IconButton>
)}
{!navigated && (
<Button
variant="overlay"
size="sm"
pressed={watched}
onClick={() => setWatched(!watched)}
label={watched ? t("player.watchedUnmark") : t("player.markWatched")}
icon={watched ? <CheckCheck /> : <Check />}
>
<span className="hidden md:inline">
{watched ? t("player.watched") : t("player.markWatched")}
</span>
</Button>
)}
<span className="h-4 w-px bg-white/20" />
{/* 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() && (
<IconButton
variant="overlay"
onClick={toggleTrueFullscreen}
label={isFullscreen ? t("player.exitFullscreen") : t("player.fullscreen")}
>
{isFullscreen ? <Minimize /> : <Maximize />}
</IconButton>
)}
<Button
variant="overlay"
size="sm"
pressed={watched}
onClick={() => setWatched(!watched)}
label={watched ? t("player.watchedUnmark") : t("player.markWatched")}
icon={watched ? <CheckCheck /> : <Check />}
onClick={(e) => {
e.stopPropagation();
exitBigView();
setNativeControls(false);
focusModal();
}}
label={t("player.exitMaximize")}
icon={<Minimize2 />}
>
<span className="hidden md:inline">
{watched ? t("player.watched") : t("player.markWatched")}
</span>
<span className="hidden sm:inline">{t("player.exitMaximize")}</span>
</Button>
)}
<span className="h-4 w-px bg-white/20" />
{/* 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() && (
<IconButton
variant="overlay"
onClick={toggleTrueFullscreen}
label={isFullscreen ? t("player.exitFullscreen") : t("player.fullscreen")}
>
{isFullscreen ? <Minimize /> : <Maximize />}
</IconButton>
)}
<Button
variant="overlay"
size="sm"
onClick={(e) => {
e.stopPropagation();
exitBigView();
setNativeControls(false);
focusModal();
}}
label={t("player.exitMaximize")}
icon={<Minimize2 />}
>
<span className="hidden sm:inline">{t("player.exitMaximize")}</span>
</Button>
</div>
)}
{/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */}
{playerError == null && nativeControls && (
<div className="pointer-events-none absolute top-3 left-3 z-menu flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-2xs text-white/90 shadow-lg backdrop-blur-xs">
<Settings className="h-3.5 w-3.5" />
{t("player.nativeControls")}
</div>
)}
{/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */}
{volumeUi != null && (
<div className="pointer-events-none absolute top-3 left-1/2 -translate-x-1/2 z-menu flex items-center gap-2 rounded-full bg-black/70 px-3 py-1.5 text-xs text-white shadow-lg">
{volumeUi === 0 ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
<div className="h-1.5 w-28 overflow-hidden rounded-full bg-white/25">
<div
className="h-full rounded-full bg-white transition-all"
style={{ width: `${volumeUi}%` }}
/>
</div>
<span className="w-7 text-right tabular-nums">{volumeUi}</span>
</div>
)}
{playerError != null && (
<div className="absolute inset-0 grid place-items-center bg-bg p-6 text-center">
<div className="max-w-sm">
<AlertTriangle className="w-8 h-8 text-warn mx-auto mb-3" />
<h3 className="text-base font-semibold">{t("player.unavailableTitle")}</h3>
<p className="text-sm text-muted mt-1.5 mb-4">
{playerError === YT_API_LOAD_FAILED
? t("player.apiLoadFailedBody")
: playerError === 101 || playerError === 150
? t("player.embedDisabledBody")
: t("player.unavailableBody")}
</p>
<LinkButton
href={`https://www.youtube.com/watch?v=${currentVideoId}`}
target="_blank"
rel="noreferrer"
variant="primary"
icon={<ExternalLink />}
>
{t("player.openOnYoutube")}
</LinkButton>
)}
{/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */}
{playerError == null && nativeControls && (
<div className="pointer-events-none absolute top-3 left-3 z-menu flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-2xs text-white/90 shadow-lg backdrop-blur-xs">
<Settings className="h-3.5 w-3.5" />
{t("player.nativeControls")}
</div>
</div>
)}
</div>
)}
{/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */}
{volumeUi != null && (
<div className="pointer-events-none absolute top-3 left-1/2 -translate-x-1/2 z-menu flex items-center gap-2 rounded-full bg-black/70 px-3 py-1.5 text-xs text-white shadow-lg">
{volumeUi === 0 ? (
<VolumeX className="h-4 w-4" />
) : (
<Volume2 className="h-4 w-4" />
)}
<div className="h-1.5 w-28 overflow-hidden rounded-full bg-white/25">
<div
className="h-full rounded-full bg-white transition-all"
style={{ width: `${volumeUi}%` }}
/>
</div>
<span className="w-7 text-right tabular-nums">{volumeUi}</span>
</div>
)}
{playerError != null && (
<div className="absolute inset-0 grid place-items-center bg-bg p-6 text-center">
<div className="max-w-sm">
<AlertTriangle className="w-8 h-8 text-warn mx-auto mb-3" />
<h3 className="text-base font-semibold">{t("player.unavailableTitle")}</h3>
<p className="text-sm text-muted mt-1.5 mb-4">
{playerError === YT_API_LOAD_FAILED
? t("player.apiLoadFailedBody")
: playerError === 101 || playerError === 150
? t("player.embedDisabledBody")
: t("player.unavailableBody")}
</p>
<LinkButton
href={`https://www.youtube.com/watch?v=${currentVideoId}`}
target="_blank"
rel="noreferrer"
variant="primary"
icon={<ExternalLink />}
>
{t("player.openOnYoutube")}
</LinkButton>
</div>
</div>
)}
</div>
<div ref={clipSlotRef} />
{clipPanel && createPortal(clipPanel, clipHost)}
</div>
{/* 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. */}
<div
@@ -1184,6 +1415,19 @@ export default function PlayerModal({
variant="outline"
size="md"
/>
{playerError == null && (
<IconButton
ref={clipButtonRef}
variant="outline"
size="md"
pressed={clipOpen}
onClick={() => (clip ? closeClip() : openClip())}
label={t("clip.open")}
title={`${t("clip.open")} · C`}
>
<Scissors />
</IconButton>
)}
{onRemoveFromList && (
<IconButton
variant="outline-danger"
@@ -24,6 +24,7 @@ vi.mock("react-i18next", async (importOriginal) => {
import PlexPlayer from "./PlexPlayer";
import { api, type PlexItemDetail } from "../lib/api";
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
import { pushLayer, removeLayer } from "../lib/layerStack";
type Media = HTMLMediaElement & { _t?: number; _paused?: boolean };
@@ -128,6 +129,7 @@ function mount(opts: {
queue?: string[];
first?: string;
fromStart?: boolean;
onClose?: () => void;
}) {
vi.spyOn(api, "plexItem").mockImplementation((id: string) => Promise.resolve(opts.details[id]!));
const session = vi
@@ -139,7 +141,12 @@ function mount(opts: {
const first = opts.first ?? opts.queue?.[0] ?? Object.keys(opts.details)[0]!;
render(
<QueryClientProvider client={qc}>
<PlexPlayer itemId={first} onClose={() => {}} queue={opts.queue} fromStart={opts.fromStart} />
<PlexPlayer
itemId={first}
onClose={opts.onClose ?? (() => {})}
queue={opts.queue}
fromStart={opts.fromStart}
/>
</QueryClientProvider>,
);
return { session };
@@ -927,3 +934,167 @@ describe("the fullscreen button", () => {
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,
});
});
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<string, unknown>)[k];
}
});
const prefs = (p: object) =>
localStorage.setItem("siftlode.plexPlayerPrefs.7", JSON.stringify({ subLang: "en", ...p }));
const infoOverlay = () => document.querySelector("[class*='bg-black/70']");
it("Escape closes what is over the picture first, then the crop and the panel", async () => {
const full = { ...item("a"), cast: [], genres: [], directors: [], collections: [] };
mount({ details: { a: full as PlexItemDetail } });
await playingAt("/media/a", 120);
key("c");
key("i");
expect(infoOverlay()).toBeTruthy();
key("Escape");
expect(infoOverlay()).toBeNull();
expect(screen.getByTestId("clip-panel")).toBeTruthy();
key("Escape");
expect(screen.queryByTestId("clip-panel")).toBeNull();
});
it("⌫ under a dialog over the player is the dialog's, not the clip's", async () => {
mount({ details: { a: item("a") } });
await playingAt("/media/a", 120);
key("c");
const layer = pushLayer();
try {
key("Backspace");
expect(screen.getByTestId("clip-panel")).toBeTruthy();
} finally {
removeLayer(layer);
}
key("Backspace");
expect(screen.queryByTestId("clip-panel")).toBeNull();
});
it("opens no range while the item's length is unknown", async () => {
mount({
details: { a: { ...item("a"), duration_seconds: null } as unknown as PlexItemDetail },
});
await playingAt("/media/a", 120);
for (const k of ["c", "[", "]"]) key(k);
expect(screen.queryByTestId("clip-panel")).toBeNull();
});
it("an item played to its end while a clip is marked stays, with the clip", async () => {
const onClose = vi.fn();
mount({ details: { a: item("a") }, onClose });
await playingAt("/media/a", 2990);
key("c");
act(() => void video().dispatchEvent(new Event("ended")));
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByTestId("clip-panel")).toBeTruthy();
});
it("does not auto-skip while a clip is marked: the skip would carry the player off it", async () => {
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
const withCredits = {
...item("a", 0, "b"),
markers: [{ type: "credits", start_s: 2900, end_s: 2995, final: true }],
} as unknown as PlexItemDetail;
const { session } = mount({ details: { a: withCredits, b: item("b") }, queue: ["a", "b"] });
await playingAt("/media/a", 2800);
key("c");
await playingAt("/media/a", 2910);
await act(() => new Promise((r) => setTimeout(r, 50)));
expect(lastStart(session)?.[0]).toBe("a");
expect(screen.getByTestId("clip-panel")).toBeTruthy();
});
});
+446 -298
View File
@@ -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, { ClipCropFrame, type ClipCrop } from "./ClipPanel";
import { markInAt, markOutAt, openAt, type ClipRange } from "../lib/clipRange";
import { PlexPlayerSettings } from "./PlexPlayerSettings";
import { PlexStatsOverlay } from "./PlexStatsOverlay";
import { createFragMeter } from "../lib/plexStats";
@@ -110,12 +113,13 @@ const BACK_TO_PREV_S = 3;
// inside the window skips again (the keyboard keeps ← / → at the viewer's own step).
// The player's menus sit on the always-dark media chrome, under white text, in every theme.
const PLAYER_MENU =
"rounded-xl border border-white/15 bg-media-chrome/95 text-sm text-white shadow-2xl";
"rounded-xl border border-white/15 bg-media-chrome/95 media-marks text-sm text-white shadow-2xl";
const PLAYER_ROW = "block w-full rounded-lg px-2 py-1.5 text-left hover:bg-white/10";
// A track choice: the chosen one wears the tick in the leading slot (DESIGN "one of several"), which
// the highlight's white text cannot wash out the way it did the accent-coloured label.
// A track choice: white like the rest, the chosen one marked by the tick in the leading slot (DESIGN
// "one of several"), which `media-marks` paints the media amber — a light theme's accent as the
// label's colour was dark on the chrome (Slate 2.84:1), and the highlight's white washed it out.
const TRACK_ROW =
"flex w-full min-h-9 items-center gap-2 rounded-lg px-2 py-1.5 text-left hover:bg-white/10";
"flex w-full min-h-9 items-center gap-2 rounded-lg px-2 py-1.5 text-left text-white hover:bg-white/10";
const TAP_SKIP_S = 10;
const DOUBLE_TAP_MS = 300;
@@ -288,8 +292,41 @@ 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<ClipRange | null>(null);
// The clip's crop frame over the picture, or null for the whole picture.
const [crop, setCrop] = useState<ClipCrop | null>(null);
const [clipFor, setClipFor] = useState(id);
if (clipFor !== id) {
setClipFor(id);
setClip(null);
setCrop(null);
}
const clipRef = useLatest(clip);
const cropRef = useLatest(crop);
// The scissors that opened the panel is gone while it is open; closing it gives the focus back.
const clipButtonRef = useRef<HTMLButtonElement>(null);
const refocusClip = useRef(false);
const closeClip = useCallback(() => {
refocusClip.current = true;
setClip(null);
setCrop(null);
}, []);
// Escape and Back inside clip mode: a crop turns off first, then the panel closes.
const stepOutOfClip = useCallback(() => {
if (cropRef.current) setCrop(null);
else closeClip();
}, [cropRef, closeClip]);
useEffect(() => {
if (clip != null || !refocusClip.current) return;
refocusClip.current = false;
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<HTMLDivElement>(null);
const menuTriggerRef = useRef<HTMLButtonElement | null>(null);
// Auto-hiding control bar (reveal on mouse move / any key; hide after 3s while playing).
@@ -600,6 +637,36 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// and play intent and read its own clock: the element's starts at the stream's start (a transcode
// begins at the resume point), and a seek outside the stream restarts it.
const [videoEl, setVideoEl] = useState<HTMLVideoElement | null>(null);
// The picture's shape, for the clip's crop frame: the element's own once it knows it (a stream
// switch can change it), 16:9 until then.
const [picture, setPicture] = useState(16 / 9);
useEffect(() => {
if (!videoEl) return;
const read = () => {
if (videoEl.videoWidth > 0 && videoEl.videoHeight > 0)
setPicture(videoEl.videoWidth / videoEl.videoHeight);
};
read();
videoEl.addEventListener("loadedmetadata", read);
videoEl.addEventListener("resize", read);
return () => {
videoEl.removeEventListener("loadedmetadata", read);
videoEl.removeEventListener("resize", read);
};
}, [videoEl]);
// In clip mode the picture stands above the clip panel rather than under it, so the crop frame
// reaches every edge of it: the panel's height, measured (it wraps with the window and the text).
const [clipBand, setClipBand] = useState(0);
const clipOpen = clip != null;
useLayoutEffect(() => {
const bar = bottomBarRef.current;
if (!clipOpen || !bar) return;
const measure = () => setClipBand(bar.offsetHeight);
measure();
const ro = new ResizeObserver(measure);
ro.observe(bar);
return () => ro.disconnect();
}, [clipOpen]);
useMediaSession(
videoEl,
detail && {
@@ -767,6 +834,9 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
seekTo,
videoRef,
reportWatchedError,
// Marking a clip, the viewer is where they want to be: a skip would carry the player off the
// range (an intro clip skipped past its Out, the credits' leaving for the next item).
suspended: clip != null,
});
// Download the ORIGINAL physical file (no re-encode/repackage). One user gesture, direct link.
@@ -836,6 +906,8 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// Only a genuine play-to-the-end counts. A next episode that failed to load never played
// (absRef 0) — its stray 'ended' must not mark anything watched or cascade another advance.
if (absRef.current <= 0) return;
// Marking a clip, the end is a place, not a finish: closing or moving on would drop the clip.
if (clipRef.current) return;
const watchedWrite = api.plexSetState(id, "watched");
watchedWrite.catch(reportWatchedError);
// Whatever follows (the up-next card, the next item, closing) leaves a FINISHED item — unless
@@ -852,7 +924,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
};
video.addEventListener("ended", onEnded);
return () => video.removeEventListener("ended", onEnded);
}, [id, nextId, go, onClose, finishProgress, reportWatchedError, absRef, upNextPrefRef]);
}, [id, nextId, go, onClose, finishProgress, reportWatchedError, absRef, upNextPrefRef, clipRef]);
// Up-next card actions: advance = go to the next item (it was already marked watched on 'ended');
// cancel = dismiss and stay on the finished frame. Loading a new item closes any lingering card.
@@ -871,21 +943,26 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// The ONE Back cascade for the player's own ways of saying "back": the on-screen arrow, ⌫ and
// Escape. It peels off what is layered over the video — the auto-skip countdown, then the open
// panel, then the stats readout — and only then leaves the player. The mouse/browser Back peels
// the layers the viewer OPENED one per press, the one opened last first (each holds its own history
// entry, see BackClose below); the ones that open by themselves have none, so Back leaves past
// them. The shortcuts sheet is not in it: a Modal above us, with its own Escape layer and
// Back-to-close. Leaving closes whatever is still up first (`useHistorySubview`'s back).
// panel, then the stats readout, then the clip being marked (its crop first) — and only then leaves
// the player. The mouse/browser Back peels the layers the viewer OPENED one per press, the one
// opened last first (each holds its own history entry, see BackClose below); the ones that open by
// themselves have none, so Back leaves past them. The shortcuts sheet is not in it: a Modal above
// us, with its own Escape layer and Back-to-close. Leaving closes whatever is still up first
// (`useHistorySubview`'s back).
// - The on-screen arrow is a click on "leave", not a cancel, so it does not stop a countdown.
// - Escape in fullscreen leaves fullscreen (the browser's own Escape) and not the player.
// - The arrow still closes an open panel or the stats first, but it leaves past a clip, as past a
// countdown: only the keys step out of a clip.
const back = useCallback(
(from: "arrow" | "key" | "escape") => {
if (from !== "arrow" && skipProgressRef.current != null) cancelAutoSkipRef.current();
else if (panelRef.current !== "none") setPanel("none");
else if (statsOpenRef.current) setStatsOpen(false);
else if (from !== "escape" || !fullscreenElement()) onClose();
else if (from === "escape" && fullscreenElement()) return;
else if (from !== "arrow" && clipRef.current) stepOutOfClip();
else onClose();
},
[onClose, panelRef, statsOpenRef, skipProgressRef, cancelAutoSkipRef],
[onClose, panelRef, statsOpenRef, skipProgressRef, cancelAutoSkipRef, clipRef, stepOutOfClip],
);
// The mouse/browser Back closes the layer whose history entry it popped (see BackClose below).
const closeStats = useCallback(() => setStatsOpen(false), []);
@@ -1006,6 +1083,21 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
},
info: () => togglePanel("info"),
stats: () => setStatsOpen((v) => !v),
// A range needs the item's length (unknown while its detail loads, or never given).
clip: () => {
if (clipRef.current) closeClip();
else if (durationRef.current > 0) setClip(openAt(absRef.current, durationRef.current));
},
markIn: () => {
const t = absRef.current;
const d = durationRef.current;
if (d > 0) setClip((c) => markInAt(c, t, d));
},
markOut: () => {
const t = absRef.current;
const d = durationRef.current;
if (d > 0) setClip((c) => markOutAt(c, t, d));
},
back: (ev) => {
// HTPC-style "stop & go back" to wherever the player was opened from — mirrors the mouse
// Back button (shared cascade).
@@ -1017,7 +1109,9 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// ⌫ stopped closing them), then naming just the sheet and palette (the second fix, the third
// 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.
// what closes them; anything else layered above us takes the key. The clip panel pushes no
// layer, so with it open a layer above is never the player's own (a refused Cut opens the
// ErrorDialog right over it): the clip is in the cascade, behind that layer.
const ownPanelOpen = panelRef.current !== "none" || statsOpenRef.current;
if (!ownPanelOpen && layerIdRef.current != null && anyLayerAbove(layerIdRef.current))
return;
@@ -1063,6 +1157,11 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
fineSeekStepRef,
panelRef,
statsOpenRef,
clipRef,
closeClip,
stepOutOfClip,
absRef,
durationRef,
]);
// Seekbar hover: a timestamp tooltip that follows the cursor. Store the position as a FRACTION
@@ -1093,7 +1192,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
return createPortal(
<div
ref={wrapRef}
className="fixed left-0 top-0 z-overlay bg-black flex items-center justify-center select-none"
className="media-marks fixed left-0 top-0 z-overlay bg-black flex items-center justify-center select-none"
onPointerDown={(e) => {
pointerRef.current = e.pointerType;
}}
@@ -1143,6 +1242,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
>
@@ -1163,6 +1267,14 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
/>
)}
</video>
{crop && (
<div
className="pointer-events-none absolute inset-x-0 top-0"
style={{ height: `calc(100% - ${clipBand}px)` }}
>
<ClipCropFrame picture={picture} crop={crop} onCrop={setCrop} />
</div>
)}
<PlexSubtitles
video={videoEl}
pos={prefs.subPos}
@@ -1317,12 +1429,12 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
<>
{/* draining fill across the whole button… */}
<span
className="absolute inset-y-0 left-0 bg-accent/50"
className="absolute inset-y-0 left-0 bg-chosen/50"
style={{ width: `${skipProgress * 100}%` }}
/>
{/* …plus a crisp bottom bar so the countdown is unmistakable */}
<span
className="absolute bottom-0 left-0 h-1 bg-accent"
className="absolute bottom-0 left-0 h-1 bg-chosen"
style={{ width: `${skipProgress * 100}%` }}
/>
</>
@@ -1345,309 +1457,345 @@ 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 ? (
<div className="overflow-hidden rounded-xl">
<ClipPanel
src={{ source: "plex", ref: id, audioTrack: audioOrd ?? 0 }}
clip={clip}
onChange={setClip}
duration={duration}
current={abs}
picture={picture}
crop={crop}
onCrop={setCrop}
onSeek={(time, final) => {
if (final) seekTo(time);
}}
onPlayFrom={(time) => seekTo(time, true)}
onClose={closeClip}
/>
</div>
) : (
<>
{/* 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. */}
<div
className="group relative flex h-11 cursor-pointer touch-none items-center"
onPointerDown={(e) => {
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 && (
<div
className="pointer-events-none absolute bottom-full mb-2 -translate-x-1/2 rounded-sm bg-black/90 px-1.5 py-0.5 text-2xs tabular-nums text-white"
style={{ left: `${hover.frac * 100}%` }}
className="group relative flex h-11 cursor-pointer touch-none items-center"
onPointerDown={(e) => {
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 && (
<div
className="pointer-events-none absolute bottom-full mb-2 -translate-x-1/2 rounded-sm bg-black/90 px-1.5 py-0.5 text-2xs tabular-nums text-white"
style={{ left: `${hover.frac * 100}%` }}
>
{fmt(hover.t)}
</div>
)}
<div className="relative h-1.5 w-full rounded-full bg-white/25">
<div
className="absolute inset-y-0 left-0 bg-white/30 rounded-full"
style={{ width: `${bufPct}%` }}
/>
{/* intro/credit marker regions */}
{detail?.markers.map((m, i) => (
<div
key={i}
className="absolute inset-y-0 bg-media-mark/50"
style={{
left: `${(m.start_s / duration) * 100}%`,
width: `${((m.end_s - m.start_s) / duration) * 100}%`,
}}
/>
))}
{/* The played part white, the head the media amber: amber is a marked place on
the chrome (the head, the markers above), and an amber played part made a
marker ahead of the head read as already played. */}
<div
className="absolute inset-y-0 left-0 bg-white rounded-full"
style={{ width: `${headPct}%` }}
/>
{/* The head: on hover for a mouse, always on touch (C3), where nothing hovers. */}
<div
className={`absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-media-mark transition-opacity ${
scrub != null
? "opacity-100"
: "opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100"
}`}
style={{ left: `${headPct}%` }}
/>
</div>
</div>
)}
<div className="relative h-1.5 w-full rounded-full bg-white/25">
<div
className="absolute inset-y-0 left-0 bg-white/30 rounded-full"
style={{ width: `${bufPct}%` }}
/>
{/* intro/credit marker regions */}
{detail?.markers.map((m, i) => (
<div
key={i}
className="absolute inset-y-0 bg-warn/50"
style={{
left: `${(m.start_s / duration) * 100}%`,
width: `${((m.end_s - m.start_s) / duration) * 100}%`,
}}
/>
))}
<div
className="absolute inset-y-0 left-0 bg-accent rounded-full"
style={{ width: `${headPct}%` }}
/>
{/* The head: on hover for a mouse, always on touch (C3), where nothing hovers. */}
<div
className={`absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-accent transition-opacity ${
scrub != null
? "opacity-100"
: "opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100"
}`}
style={{ left: `${headPct}%` }}
/>
</div>
</div>
{/* 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). */}
<div className="flex items-center gap-3 text-white">
<Ctrl label={t("plex.player.playPause")} onClick={togglePlay} align="start">
{playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />}
</Ctrl>
<Ctrl
label={backToPrev ? t("plex.player.prev") : t("plex.player.restart")}
onClick={backOrPrev}
>
<SkipBack className="w-5 h-5" />
</Ctrl>
{showNext && (
<Ctrl
label={t("plex.player.next")}
onClick={() => nextId && go(nextId)}
disabled={!nextId}
>
<SkipForward className="w-5 h-5" />
</Ctrl>
)}
<div className="flex items-center gap-2">
<Ctrl label={t("plex.player.mute")} onClick={toggleMute}>
{muted || volume === 0 ? (
<VolumeX className="w-5 h-5" />
) : (
<Volume2 className="w-5 h-5" />
<div className="flex items-center gap-3 text-white">
<Ctrl label={t("plex.player.playPause")} onClick={togglePlay} align="start">
{playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />}
</Ctrl>
<Ctrl
label={backToPrev ? t("plex.player.prev") : t("plex.player.restart")}
onClick={backOrPrev}
>
<SkipBack className="w-5 h-5" />
</Ctrl>
{showNext && (
<Ctrl
label={t("plex.player.next")}
onClick={() => nextId && go(nextId)}
disabled={!nextId}
>
<SkipForward className="w-5 h-5" />
</Ctrl>
)}
</Ctrl>
{/* The browser's own range, like every other slider (DESIGN.md A6): its hover, focus,
<div className="flex items-center gap-2">
<Ctrl label={t("plex.player.mute")} onClick={toggleMute}>
{muted || volume === 0 ? (
<VolumeX className="w-5 h-5" />
) : (
<Volume2 className="w-5 h-5" />
)}
</Ctrl>
{/* 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. */}
<div
className="relative w-24 [@media(hover:none)]:hidden"
onPointerEnter={() => setVolHover(true)}
onPointerLeave={() => setVolHover(false)}
>
<Slider
min={0}
max={1}
step={0.01}
value={muted ? 0 : volume}
aria-label={t("plex.player.volume")}
onChange={(v) => setVol(v, v === 0)}
onPointerUp={(e) => e.currentTarget.blur()}
/>
{volHover && (
<div className="pointer-events-none absolute bottom-full left-1/2 mb-1 -translate-x-1/2 rounded-sm bg-black/90 px-1.5 py-0.5 text-2xs tabular-nums text-white">
{Math.round((muted ? 0 : volume) * 100)}
<div
className="relative w-24 [@media(hover:none)]:hidden"
onPointerEnter={() => setVolHover(true)}
onPointerLeave={() => setVolHover(false)}
>
<Slider
min={0}
max={1}
step={0.01}
value={muted ? 0 : volume}
aria-label={t("plex.player.volume")}
onChange={(v) => setVol(v, v === 0)}
onPointerUp={(e) => e.currentTarget.blur()}
/>
{volHover && (
<div className="pointer-events-none absolute bottom-full left-1/2 mb-1 -translate-x-1/2 rounded-sm bg-black/90 px-1.5 py-0.5 text-2xs tabular-nums text-white">
{Math.round((muted ? 0 : volume) * 100)}
</div>
)}
</div>
)}
</div>
</div>
</div>
<div className="min-w-0 truncate text-xs tabular-nums text-white/90">
{fmt(abs)} / {fmt(duration)}
{/* Projected finish time (wall clock now + time remaining), in the clock's own format —
<div className="min-w-0 truncate text-xs tabular-nums text-white/90">
{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 && (
<span className="ml-1.5 text-white/45">
(
{
clockParts(new Date(now.getTime() + Math.max(0, duration - abs) * 1000), prefs)
.time
}
)
</span>
)}
</div>
{duration > 0 && (
<span className="ml-1.5 text-white/45">
(
{
clockParts(
new Date(now.getTime() + Math.max(0, duration - abs) * 1000),
prefs,
).time
}
)
</span>
)}
</div>
<div className="ml-auto flex items-center gap-2">
{/* Quick TRACKS menu (audio / subtitle) — the frequent action, on its own button. Picking
<div className="ml-auto flex items-center gap-2">
{/* 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) && (
<>
<IconButton
variant="overlay"
size="player"
round
ref={setTracksReference}
{...tracksPop.getReferenceProps()}
aria-expanded={panel === "tracks"}
label={t("plex.player.tracks")}
>
<Languages />
</IconButton>
<PopoverSurface
popover={tracksPop}
ariaLabel={t("plex.player.tracks")}
className={`${PLAYER_MENU} w-56`}
>
{/* 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. */}
<div
ref={tracksFadeRef}
onClick={(e) => 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 && (
<div role="group" aria-label={t("plex.player.audio")}>
<PanelHead>{t("plex.player.audio")}</PanelHead>
{detail.audio_streams.map((a) => (
<PopoverItem
key={a.ord}
tone="media"
role="menuitemradio"
checked={(audioOrd ?? 0) === a.ord}
label={audioName(a)}
onSelect={() => changeAudio(a.ord)}
className={`${TRACK_ROW} ${
(audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
}`}
>
<MenuCheck kind="one" on={(audioOrd ?? 0) === a.ord} />
<span className="truncate">{audioName(a)}</span>
</PopoverItem>
))}
</div>
)}
{textSubs.length > 0 && (
<div role="group" aria-label={t("plex.player.subtitles")}>
<PanelHead>{t("plex.player.subtitles")}</PanelHead>
<PopoverItem
tone="media"
role="menuitemradio"
checked={subOrd == null}
label={t("plex.player.subOff")}
onSelect={() => changeSubtitle(null)}
className={`${TRACK_ROW} ${subOrd == null ? "text-accent" : "text-white"}`}
>
<MenuCheck kind="one" on={subOrd == null} />
<span className="truncate">{t("plex.player.subOff")}</span>
</PopoverItem>
{textSubs.map((sub) => (
<PopoverItem
key={sub.ord}
tone="media"
role="menuitemradio"
checked={subOrd === sub.ord}
label={subtitleName(sub)}
onSelect={() => changeSubtitle(sub.ord)}
className={`${TRACK_ROW} ${
subOrd === sub.ord ? "text-accent" : "text-white"
}`}
>
<MenuCheck kind="one" on={subOrd === sub.ord} />
<span className="truncate">{subtitleName(sub)}</span>
</PopoverItem>
))}
</div>
)}
</div>
</PopoverSurface>
</>
)}
{/* The fixed ⋯ (C5): the rare tools. Settings opens the tuning panel from the same spot,
so the ⋯ also closes it. */}
<div className="relative">
<IconButton
variant="overlay"
size="player"
round
ref={moreTriggerRef}
{...morePop.getReferenceProps()}
aria-expanded={panel === "more" || panel === "settings"}
label={t("plex.player.more")}
>
<MoreHorizontal />
</IconButton>
<PopoverSurface
popover={morePop}
ariaLabel={t("plex.player.more")}
className={`${PLAYER_MENU} w-60 p-1.5`}
>
<div onClick={(e) => e.stopPropagation()} className="flex flex-col">
{moreItems.map((it) => (
<PopoverItem
key={it.id}
tone="media"
label={it.label}
// Closes first, so the focus is back on the ⋯ before the tool opens: a dialog
// opened from here returns the focus to where it found it.
onSelect={() => 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) && (
<>
<IconButton
variant="overlay"
size="player"
round
ref={setTracksReference}
{...tracksPop.getReferenceProps()}
aria-expanded={panel === "tracks"}
label={t("plex.player.tracks")}
>
{it.icon}
{it.label}
</PopoverItem>
))}
<Languages />
</IconButton>
<PopoverSurface
popover={tracksPop}
ariaLabel={t("plex.player.tracks")}
className={`${PLAYER_MENU} w-56`}
>
{/* 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. */}
<div
ref={tracksFadeRef}
onClick={(e) => 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 && (
<div role="group" aria-label={t("plex.player.audio")}>
<PanelHead>{t("plex.player.audio")}</PanelHead>
{detail.audio_streams.map((a) => (
<PopoverItem
key={a.ord}
tone="media"
role="menuitemradio"
checked={(audioOrd ?? 0) === a.ord}
label={audioName(a)}
onSelect={() => changeAudio(a.ord)}
className={TRACK_ROW}
>
<MenuCheck kind="one" on={(audioOrd ?? 0) === a.ord} />
<span className="truncate">{audioName(a)}</span>
</PopoverItem>
))}
</div>
)}
{textSubs.length > 0 && (
<div role="group" aria-label={t("plex.player.subtitles")}>
<PanelHead>{t("plex.player.subtitles")}</PanelHead>
<PopoverItem
tone="media"
role="menuitemradio"
checked={subOrd == null}
label={t("plex.player.subOff")}
onSelect={() => changeSubtitle(null)}
className={TRACK_ROW}
>
<MenuCheck kind="one" on={subOrd == null} />
<span className="truncate">{t("plex.player.subOff")}</span>
</PopoverItem>
{textSubs.map((sub) => (
<PopoverItem
key={sub.ord}
tone="media"
role="menuitemradio"
checked={subOrd === sub.ord}
label={subtitleName(sub)}
onSelect={() => changeSubtitle(sub.ord)}
className={TRACK_ROW}
>
<MenuCheck kind="one" on={subOrd === sub.ord} />
<span className="truncate">{subtitleName(sub)}</span>
</PopoverItem>
))}
</div>
)}
</div>
</PopoverSurface>
</>
)}
{/* Clip mode (`c`): mark a range of what is playing and cut it into a download. */}
{duration > 0 && (
<Ctrl
label={t("clip.open")}
onClick={() => setClip(openAt(abs, duration))}
buttonRef={clipButtonRef}
>
<Scissors className="w-5 h-5" />
</Ctrl>
)}
{/* The fixed ⋯ (C5): the rare tools. Settings opens the tuning panel from the same spot,
so the ⋯ also closes it. */}
<div className="relative">
<IconButton
variant="overlay"
size="player"
round
ref={moreTriggerRef}
{...morePop.getReferenceProps()}
aria-expanded={panel === "more" || panel === "settings"}
label={t("plex.player.more")}
>
<MoreHorizontal />
</IconButton>
<PopoverSurface
popover={morePop}
ariaLabel={t("plex.player.more")}
className={`${PLAYER_MENU} w-60 p-1.5`}
>
<div onClick={(e) => e.stopPropagation()} className="flex flex-col">
{moreItems.map((it) => (
<PopoverItem
key={it.id}
tone="media"
label={it.label}
// Closes first, so the focus is back on the ⋯ before the tool opens: a dialog
// opened from here returns the focus to where it found it.
onSelect={() => closeThenAct(() => closePanel("more"), it.run)}
className={`${PLAYER_ROW} flex min-h-9 items-center gap-2.5 text-white`}
>
{it.icon}
{it.label}
</PopoverItem>
))}
</div>
</PopoverSurface>
{panel === "settings" && (
<PlexPlayerSettings
menuRef={menuRef}
returnFocusTo={menuTriggerRef}
settingsTabs={settingsTabs}
activeTab={activeTab}
setSettingsTab={setSettingsTab}
prefs={prefs}
patchPrefs={patchPrefs}
changeAudioOffset={changeAudioOffset}
now={now}
/>
)}
</div>
</PopoverSurface>
{panel === "settings" && (
<PlexPlayerSettings
menuRef={menuRef}
returnFocusTo={menuTriggerRef}
settingsTabs={settingsTabs}
activeTab={activeTab}
setSettingsTab={setSettingsTab}
prefs={prefs}
patchPrefs={patchPrefs}
changeAudioOffset={changeAudioOffset}
now={now}
/>
)}
</div>
{/* 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() && (
<Ctrl label={t("plex.player.fullscreen")} onClick={toggleFs} align="end">
{fs ? <Minimize className="w-5 h-5" /> : <Maximize className="w-5 h-5" />}
</Ctrl>
)}
</div>
</div>
{canFullscreenElements() && (
<Ctrl label={t("plex.player.fullscreen")} onClick={toggleFs} align="end">
{fs ? <Minimize className="w-5 h-5" /> : <Maximize className="w-5 h-5" />}
</Ctrl>
)}
</div>
</div>
</>
)}
</div>
{/* Stats-for-nerds debug overlay (toggle with "d" or the gauge button). Non-modal corner panel —
@@ -1692,7 +1840,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
}}
>
<div
className="my-auto w-full max-w-3xl rounded-2xl border border-white/15 bg-media-chrome/95 shadow-2xl"
className="my-auto w-full max-w-3xl rounded-2xl border border-white/15 bg-media-chrome/95 media-marks shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<Suspense fallback={null}>
@@ -7,6 +7,7 @@ import { Pressable } from "./ui/Pressable";
import { ColorInput, CheckRow } from "./ui/NativeControls";
import Tabs from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel";
import { MEDIA_CHOSEN } from "./ui/mediaChrome";
type SettingsTab = "sync" | "playback" | "subtitle" | "clock";
@@ -65,7 +66,7 @@ export function PlexPlayerSettings(props: {
aria-label={t("plex.player.settings")}
onWheel={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
className="border border-white/15 bg-media-chrome/95 absolute bottom-full right-0 z-menu mb-2 w-80 rounded-xl p-2 text-sm text-white shadow-2xl"
className="border border-white/15 bg-media-chrome/95 media-marks absolute bottom-full right-0 z-menu mb-2 w-80 rounded-xl p-2 text-sm text-white shadow-2xl"
>
<Tabs
tone="media"
@@ -273,9 +274,7 @@ export function PlexPlayerSettings(props: {
aria-pressed={prefs.clockDateStyle === st}
onClick={() => patchPrefs({ clockDateStyle: st })}
className={`touch-target flex-1 rounded px-2 py-1 text-2xs ${
prefs.clockDateStyle === st
? "bg-white/15 text-accent"
: "text-white/70 hover:bg-white/10"
prefs.clockDateStyle === st ? MEDIA_CHOSEN : "text-white/70 hover:bg-white/10"
}`}
>
{clockParts(now, { ...prefs, clockDate: true, clockDateStyle: st }).date}
+1 -1
View File
@@ -72,7 +72,7 @@ export function UpNextCard({
role="region"
aria-label={t("plex.player.upNext.title")}
onClick={(e) => e.stopPropagation()}
className="absolute bottom-24 right-6 z-menu flex w-[22rem] max-w-[calc(100vw-3rem)] gap-3 rounded-2xl border border-white/15 bg-media-chrome/95 p-3.5 text-white shadow-2xl"
className="absolute bottom-24 right-6 z-menu flex w-[22rem] max-w-[calc(100vw-3rem)] gap-3 rounded-2xl border border-white/15 bg-media-chrome/95 media-marks p-3.5 text-white shadow-2xl"
>
<div className="h-[6.5rem] w-[4.6rem] flex-none overflow-hidden rounded-lg bg-white/10">
{next && (
@@ -6,7 +6,7 @@
// now says how many made it (also when `req` has already shown the refusal in its dialog), deselects
// them, holds the cut list still while it queues, and a retry goes on with the same "(i/N)" series.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
import { render, screen, fireEvent, waitFor, cleanup, act, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
@@ -101,3 +101,175 @@ describe("VideoEditor — a split export that fails part-way", () => {
expect(enqueue.mock.calls[2]![0].display_name).toBe("Talk (2/2)");
});
});
// A time typed into the selected segment's field belongs to that segment: selecting another one
// drops it, and never commits it to the new selection's cut.
describe("VideoEditor — a typed time and another segment", () => {
it("does not move the newly selected segment's cut with a time typed for the old one", () => {
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
render(
<QueryClientProvider client={new QueryClient()}>
<VideoEditor job={job} onClose={() => {}} />
</QueryClientProvider>,
);
const video = document.querySelector("video")!;
for (const at of [30, 60]) {
Object.defineProperty(video, "currentTime", {
value: at,
configurable: true,
writable: true,
});
fireEvent.timeUpdate(video);
fireEvent.click(screen.getByText("editor.splitHere"));
}
const segs = () => screen.getAllByTitle("editor.keptSeg");
fireEvent.pointerDown(segs()[1]!);
const inField = screen.getByRole("textbox", { name: "editor.in" });
act(() => inField.focus());
fireEvent.change(inField, { target: { value: "0:40" } });
// Another segment is selected before the field loses the focus (a press on it).
fireEvent.pointerDown(segs()[2]!);
fireEvent.blur(inField);
const cuts = screen
.getAllByRole("slider", { name: "editor.cutN" })
.map((c) => c.getAttribute("aria-valuenow"));
expect(cuts).toEqual(["30", "60"]);
});
});
// An unreadable time is said on a line under the segment's row: outside the field's label, so the
// field's name stays "Out" and the line is read once, as its description.
describe("VideoEditor — an unreadable time", () => {
it("is said under the row, outside the field's label", () => {
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
render(
<QueryClientProvider client={new QueryClient()}>
<VideoEditor job={job} onClose={() => {}} />
</QueryClientProvider>,
);
const video = document.querySelector("video")!;
Object.defineProperty(video, "currentTime", { value: 50, configurable: true, writable: true });
fireEvent.timeUpdate(video);
fireEvent.click(screen.getByText("editor.splitHere"));
fireEvent.pointerDown(screen.getAllByTitle("editor.keptSeg")[0]!);
const out = screen.getByRole("textbox", { name: "editor.out" });
fireEvent.change(out, { target: { value: "abc" } });
fireEvent.keyDown(out, { key: "Enter" });
const line = screen.getByRole("alert");
expect(line.textContent).toBe("common.timecodeInvalid");
expect(line.closest("label")).toBeNull();
expect(screen.getByRole("textbox", { name: "editor.out" })).toBe(out);
expect(out.getAttribute("aria-describedby")).toBe(line.id);
});
});
// 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(
<QueryClientProvider client={new QueryClient()}>
<VideoEditor job={job} onClose={() => {}} />
</QueryClientProvider>,
);
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");
});
});
// A cut's remove button and the keep toggle of the segment after it: the button is a button a click
// or a key works (it sat inside the cut's slider, pointer-only, its arrows moving the cut), and it
// sits at the cut's foot, clear of the toggle at the segment's head; the toggle sits over the cut's
// hit area that reaches into its segment, so a press on it is the toggle's.
describe("VideoEditor — a cut's remove button", () => {
const split = () => {
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
render(
<QueryClientProvider client={new QueryClient()}>
<VideoEditor job={job} onClose={() => {}} />
</QueryClientProvider>,
);
const video = document.querySelector("video")!;
Object.defineProperty(video, "currentTime", { value: 50, configurable: true, writable: true });
fireEvent.timeUpdate(video);
fireEvent.click(screen.getByText("editor.splitHere"));
};
it("removes the cut on a click (Enter on a focused button is one), and is not part of the slider", () => {
split();
const remove = screen.getByRole("button", { name: "editor.removeCut" });
expect(screen.getByRole("slider", { name: "editor.cutN" }).contains(remove)).toBe(false);
act(() => remove.focus());
fireEvent.click(remove);
expect(screen.queryByRole("slider", { name: "editor.cutN" })).toBeNull();
});
it("hands the focus on to a cut beside it, or to Split here, never to <body>", async () => {
split();
const video = document.querySelector("video")!;
Object.defineProperty(video, "currentTime", { value: 70, configurable: true, writable: true });
fireEvent.timeUpdate(video);
fireEvent.click(screen.getByText("editor.splitHere"));
const [second] = screen.getAllByRole("button", { name: "editor.removeCut" }).slice(1);
act(() => second!.focus());
fireEvent.click(second!);
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByRole("slider", { name: "editor.cutN" })),
);
const last = screen.getByRole("button", { name: "editor.removeCut" });
act(() => last.focus());
fireEvent.click(last);
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByText("editor.splitHere").closest("button")),
);
});
it("a remove refused while the clips are queued leaves no focus to take later", async () => {
let refuse: (e: unknown) => void = () => {};
vi.spyOn(api, "enqueueEdit").mockImplementation(
() =>
new Promise((_, reject) => {
refuse = reject;
}) as never,
);
split();
fireEvent.click(screen.getByText("editor.createN"));
await waitFor(() => expect(api.enqueueEdit).toHaveBeenCalled());
// The cut list holds still while the clips are queued: the remove is refused.
const remove = screen.getByRole("button", { name: "editor.removeCut" });
act(() => remove.focus());
fireEvent.click(remove);
expect(screen.getByRole("slider", { name: "editor.cutN" })).toBeTruthy();
await act(async () => refuse(new Error("refused")));
// The create failed and the editor stays; a later split keeps the focus where it was.
const video = document.querySelector("video")!;
Object.defineProperty(video, "currentTime", { value: 70, configurable: true, writable: true });
fireEvent.timeUpdate(video);
const splitButton = screen.getByText("editor.splitHere").closest("button")!;
act(() => splitButton.focus());
fireEvent.click(splitButton);
expect(screen.getAllByRole("slider", { name: "editor.cutN" })).toHaveLength(2);
expect(document.activeElement).toBe(splitButton);
});
it("sits under the track at its cut, off the cut's grab area; the keep toggle sits over it", () => {
split();
const remove = screen.getByRole("button", { name: "editor.removeCut" });
const track = screen.getByRole("group", { name: "editor.timeline" });
// A press anywhere on the cut drags it; the X that deletes it (no undo) is below the track.
expect(track.contains(remove)).toBe(false);
const cut = screen.getByRole("slider", { name: "editor.cutN" });
expect(remove.closest<HTMLElement>("[style]")?.style.left).toBe(cut.style.left);
const toggles = within(track).getAllByRole("button", { pressed: true });
for (const toggle of toggles) expect(toggle.className).toMatch(/\bz-menu\b/);
});
});
+156 -252
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -7,37 +7,22 @@ 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";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
import { TimecodeError, TimecodeField } from "./ui/TimecodeField";
import { useTimecodeField } from "./ui/useTimecodeField";
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 { trackLeft } from "./ui/trackPosition";
import { formatTimecode } 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;
@@ -59,6 +44,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const qc = useQueryClient();
const videoRef = useRef<HTMLVideoElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const splitRef = useRef<HTMLButtonElement>(null);
const sid = useRef(1);
const nid = () => sid.current++;
@@ -67,11 +53,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<Seg[]>([{ id: 0, start: 0, end: srcDur, keep: true }]);
@@ -90,6 +74,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({
@@ -111,21 +96,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;
@@ -162,6 +135,18 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
// --- cut-list mutations ---
const boundaries = useMemo(() => segments.slice(0, -1).map((s) => s.end), [segments]);
// A cut removed by its button takes the button with it: once it is gone, the focus moves on to the
// cut before it (or the one now first), else to Split here — not to <body>, out of the dialog.
const refocusAfterRemove = useRef<number | null>(null);
useEffect(() => {
const bi = refocusAfterRemove.current;
if (bi == null) return;
refocusAfterRemove.current = null;
const cut = trackRef.current?.querySelector<HTMLElement>(
`[data-handle="cut-${Math.max(0, bi - 1)}"]`,
);
(cut ?? splitRef.current)?.focus();
}, [boundaries.length]);
const splitAtPlayhead = () => {
if (creating.current) return;
const time = current;
@@ -200,81 +185,12 @@ 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<Drag | null>(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<HTMLDivElement>(null);
const cropDrag = useRef<null | { mode: "move" | "resize"; px: number; py: number; orig: Frac }>(
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 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;
@@ -395,6 +311,18 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const sel = segments.find((s) => s.id === selId) ?? segments[0];
const selIdx = segments.findIndex((s) => s.id === sel?.id);
const hours = duration >= 3600;
const inField = useTimecodeField(
sel?.start ?? 0,
hours,
(v) => selIdx > 0 && moveBoundary(selIdx - 1, v),
);
const outField = useTimecodeField(
sel?.end ?? 0,
hours,
(v) => selIdx < segments.length - 1 && moveBoundary(selIdx, v),
);
const timeErrorId = useId();
return (
<Modal
@@ -416,35 +344,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
className="w-full max-h-[42dvh] mx-auto block object-contain"
playsInline
/>
{cropOn && picRect && (
<div
ref={overlayRef}
className="absolute"
style={{
left: picRect.left,
top: picRect.top,
width: picRect.width,
height: picRect.height,
}}
>
<div
className="absolute border-2 border-accent cursor-move"
style={{
left: `${crop.x * 100}%`,
top: `${crop.y * 100}%`,
width: `${crop.w * 100}%`,
height: `${crop.h * 100}%`,
boxShadow: "0 0 0 9999px rgba(0,0,0,0.5)",
}}
onPointerDown={(e) => onCropDown(e, "move")}
>
<div
className="absolute -right-1.5 -bottom-1.5 w-4 h-4 rounded-xs bg-accent cursor-nwse-resize"
onPointerDown={(e) => onCropDown(e, "resize")}
/>
</div>
</div>
)}
{cropOn && <CropFrame aspect={va} rect={crop} ratio={null} onChange={setCrop} />}
</div>
{/* transport */}
@@ -459,6 +359,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
<Button
variant="outline"
size="sm"
ref={splitRef}
onClick={splitAtPlayhead}
icon={<SplitSquareHorizontal />}
>
@@ -468,106 +369,112 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
{/* timeline: filmstrip + segments (keep/drop tint) + markers + playhead + hover preview */}
<div className="relative">
<div
ref={trackRef}
className="relative h-14 rounded-lg overflow-hidden bg-surface border border-border cursor-pointer"
onPointerDown={(e) => {
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 && (
<div className="absolute inset-0 flex pointer-events-none">
{Array.from({ length: stripCells }).map((_, i) => (
<div
key={i}
className="h-full flex-1"
style={tileStyle(
Math.round(((i + 0.5) / stripCells) * ((strip!.count ?? 1) - 1)),
)}
/>
))}
</div>
)}
{/* segment tint: dropped = dimmed + hatched */}
{segments.map((s) => (
<div
key={s.id}
className={clsx(
"absolute inset-y-0 border-r border-black/40 last:border-r-0",
<div ref={trackRef}>
<RangeTrack
label={t("editor.timeline")}
duration={duration}
playhead={current}
onSeek={(time) => {
setCurrent(time);
seek(time);
}}
onHover={setHover}
className="h-14 rounded-lg bg-surface border border-border cursor-pointer"
background={
stripCells > 0 && (
<div className="absolute inset-0 flex pointer-events-none">
{Array.from({ length: stripCells }).map((_, i) => (
<div
key={i}
className="h-full flex-1"
style={tileStyle(
Math.round(((i + 0.5) / stripCells) * ((strip!.count ?? 1) - 1)),
)}
/>
))}
</div>
)
}
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 */}
<Pressable
aria-pressed={s.keep}
onPointerDown={(e) => {
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 ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
</Pressable>
</div>
))}
{/* draggable boundary markers */}
{boundaries.map((b, bi) => (
<div
key={bi}
className="absolute inset-y-0 -ml-1.5 w-3 flex items-center justify-center z-base"
style={{ left: pct(b) }}
>
<div
className="w-1 h-full bg-white/90 cursor-ew-resize rounded-sm"
onPointerDown={(e) => {
e.stopPropagation();
setDrag({ type: "boundary", bi });
}}
/>
<Pressable
onPointerDown={(e) => {
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")}
>
<X className="w-3 h-3" />
</Pressable>
</div>
))}
{/* playhead */}
<div
className="absolute inset-y-0 w-0.5 bg-white pointer-events-none z-menu"
style={{ left: pct(current) }}
),
style: s.keep
? undefined
: {
backgroundImage:
"repeating-linear-gradient(45deg, rgba(0,0,0,0.55) 0 6px, rgba(0,0,0,0.25) 6px 12px)",
},
title: s.keep ? t("editor.keptSeg") : t("editor.droppedSeg"),
onPointerDown: () => setSelId(s.id),
children: (
// keep/drop toggle, over the cut's hit area beside it (that reaches 12 px into the
// segment): a press on the eye is the eye's, never a drag of the cut
<Pressable
aria-pressed={s.keep}
onPointerDown={(e) => {
e.stopPropagation();
toggleKeep(s.id);
}}
className={clsx(
"absolute top-1 left-1 z-menu 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 ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
</Pressable>
),
}))}
// 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),
}))}
/>
</div>
{/* Each cut's remove button, under the track at the cut: a real button a click or Enter
works, off the cut's grab area, where a press drags it (a removal has no undo). */}
{boundaries.length > 0 && (
<div className="relative mt-1 h-6">
{boundaries.map((b, bi) => (
<div
key={`cut-${bi}`}
className="absolute top-0 -translate-x-1/2"
style={{ left: trackLeft(b, { from: 0, to: duration }) }}
>
<Pressable
onClick={() => {
// Refused while the clips are queued (the list holds still): then nothing is
// removed, and no focus is left waiting to be taken by a later change.
if (creating.current) return;
refocusAfterRemove.current = bi;
deleteBoundary(bi);
}}
// 24px, the target floor; white on its black disc in either theme.
className="flex p-1.5 rounded-full bg-black/70 text-white/80 hover:text-white"
aria-label={t("editor.removeCut", { n: bi + 1 })}
title={t("editor.removeCut", { n: bi + 1 })}
>
<X className="w-3 h-3" />
</Pressable>
</div>
))}
</div>
)}
{/* hover-scrub thumbnail */}
{strip && hoverTime != null && (
@@ -605,35 +512,32 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
<div className="flex-1" />
<label className="flex items-center gap-1 text-muted">
{t("editor.in")}
<Input
<TimecodeField
className="w-24 tabular-nums"
size="sm"
defaultValue={tc(sel.start)}
key={`s-${sel.id}-${sel.start}`}
field={inField}
errorId={timeErrorId}
disabled={selIdx === 0}
onBlur={(e) => {
const v = parseTc(e.target.value);
if (v != null && selIdx > 0) moveBoundary(selIdx - 1, v);
}}
/>
</label>
<label className="flex items-center gap-1 text-muted">
{t("editor.out")}
<Input
<TimecodeField
className="w-24 tabular-nums"
size="sm"
defaultValue={tc(sel.end)}
key={`e-${sel.id}-${sel.end}`}
field={outField}
errorId={timeErrorId}
disabled={selIdx === segments.length - 1}
onBlur={(e) => {
const v = parseTc(e.target.value);
if (v != null && selIdx < segments.length - 1) moveBoundary(selIdx, v);
}}
/>
</label>
<span className="text-xs text-muted tabular-nums w-16 text-right">
{tc(sel.end - sel.start)}
</span>
{(inField.invalid || outField.invalid) && (
<div className="basis-full">
<TimecodeError id={timeErrorId} hours={hours} />
</div>
)}
</div>
)}
+1 -1
View File
@@ -268,7 +268,7 @@ export function DownloadRow({
<div
className={`font-medium leading-snug break-words line-clamp-1 @max-[40rem]/dlrow:line-clamp-2`}
>
{job.job_kind === "edit" && (
{(job.job_kind === "edit" || job.job_kind === "clip") && (
<span className="mr-1.5 inline-flex items-center gap-1 px-1.5 py-0.5 align-middle rounded-sm text-2xs font-semibold uppercase tracking-wide bg-accent/15 text-accent">
<Scissors className="w-3 h-3" /> {t("downloads.clipBadge")}
</span>
@@ -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<HTMLButtonElement>;
// 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 (
<div className="relative flex group/ctrl">
<IconButton
ref={buttonRef}
variant="overlay"
size="player"
round
@@ -104,7 +104,7 @@ export const ROVING_HIGHLIGHT =
"data-active:bg-accent/35 data-active:text-fg data-active:ring-1 data-active:ring-inset data-active:ring-accent/60";
const HIGHLIGHT_SELECTED =
"data-active:bg-accent/35 data-active:text-fg data-active:ring-2 data-active:ring-inset data-active:ring-accent/80";
// On the always-dark media chrome (a player's menus, `bg-media-chrome` under white text) the theme's
// On the always-dark media chrome (a player's menus, `bg-media-chrome media-marks` under white text) the theme's
// `text-fg` is dark in the light theme: the highlighted row would go dark-on-dark. White instead.
const MEDIA_HIGHLIGHT =
"data-active:bg-white/15 data-active:text-white data-active:ring-1 data-active:ring-inset data-active:ring-white/40";
@@ -0,0 +1,167 @@
// @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<typeof import("react-i18next")>();
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,
start = { x: 0.2, y: 0.2, w: 0.4, h: 0.4 },
}: {
aspect?: number;
ratio?: number | null;
start?: Frac;
}) {
const [rect, setRect] = useState<Frac>(start);
return (
<div>
<output data-testid="rect">
{[rect.x, rect.y, rect.w, rect.h].map((v) => v.toFixed(3)).join(" ")}
</output>
<CropFrame aspect={aspect} rect={rect} ratio={ratio} onChange={setRect} />
</div>
);
}
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(<Harness aspect={4 / 3} />);
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(
<div onKeyDown={player}>
<Harness />
</div>,
);
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(<Harness />);
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(<Harness />);
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("follows the pointer that took it, not a second finger landing on it", () => {
render(<Harness />);
const frame = screen.getByRole("group", { name: "common.cropFrame.frame" });
fireEvent.pointerDown(frame, { button: 0, clientX: 500, clientY: 300, pointerId: 1 });
// A second finger lands 200 px away and stays: it must not become the drag's origin.
fireEvent.pointerDown(frame, { button: 0, clientX: 700, clientY: 300, pointerId: 2 });
fireEvent.pointerMove(frame, { clientX: 660, clientY: 390, pointerId: 1 });
expect(rect()).toBe("0.300 0.300 0.400 0.400");
fireEvent.pointerMove(frame, { clientX: 900, clientY: 300, pointerId: 2 });
expect(rect()).toBe("0.300 0.300 0.400 0.400");
fireEvent.pointerUp(frame, { clientX: 660, clientY: 390, pointerId: 1 });
});
it("takes the focus on a press, so the arrows go on from where the pointer left it", () => {
render(<Harness />);
const frame = screen.getByRole("group", { name: "common.cropFrame.frame" });
fireEvent.pointerDown(frame, { button: 0, clientX: 500, clientY: 300 });
fireEvent.pointerUp(frame, { clientX: 500, clientY: 300 });
expect(document.activeElement).toBe(frame);
const se = screen.getByRole("group", { name: "common.cropFrame.se" });
fireEvent.pointerDown(se, { button: 0, clientX: 960, clientY: 540 });
expect(document.activeElement).toBe(se);
fireEvent.pointerUp(se, { clientX: 960, clientY: 540 });
// An edge is the pointer's only: its press puts the focus on the frame it belongs to.
fireEvent.pointerDown(document.querySelector<HTMLElement>(".cursor-ew-resize")!, {
button: 0,
clientX: 960,
clientY: 400,
});
expect(document.activeElement).toBe(frame);
});
it("shrinks its handles on a small frame, so the frame itself stays there to grab", () => {
const corner = () => screen.getByRole("group", { name: "common.cropFrame.se" });
const edge = () => document.querySelector<HTMLElement>(".cursor-ew-resize")!;
// 640 × 360 px of a 1600 × 900 picture: the full handles.
render(<Harness />);
expect(corner().className).toContain("size-5");
expect(edge().className).toContain("w-4");
cleanup();
// A 1:4 picture in that box is 225 px wide: a tenth of it is a 22 px frame.
render(<Harness aspect={0.25} start={{ x: 0.2, y: 0.2, w: 0.1, h: 0.5 }} />);
expect(corner().className).toContain("size-3");
expect(edge().className).toContain("w-2");
});
it("lets a press outside the frame through to the player, and keeps one on the frame", () => {
const click = vi.fn();
render(
<div onClick={click}>
<Harness />
</div>,
);
expect(screen.getByTestId("crop-frame").className).toContain("pointer-events-none");
fireEvent.click(screen.getByRole("group", { name: "common.cropFrame.frame" }));
expect(click).not.toHaveBeenCalled();
});
});
+235
View File
@@ -0,0 +1,235 @@
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 <video>'s, an iframe's) and says the picture's shape. The layer itself lets pointers
// through: only the frame and its handles take them, so a press elsewhere on the picture is still
// the player's (play / pause).
type Corner = "nw" | "ne" | "sw" | "se";
interface Props {
/** The picture's shape as shown (width / height). */
aspect: number;
rect: Frac;
/** A shape lock in picture fractions (cropGeometry.fracRatio), or null for a free frame. */
ratio: number | null;
onChange: (r: Frac) => void;
className?: string;
}
const STEP = 0.01;
const BIG_STEP = 0.1;
const ARROWS: Record<string, [number, number]> = {
ArrowLeft: [-1, 0],
ArrowRight: [1, 0],
ArrowUp: [0, -1],
ArrowDown: [0, 1],
};
const CORNERS: Corner[] = ["nw", "ne", "sw", "se"];
const EDGES: CropHandle[] = ["n", "s", "e", "w"];
// Each handle's hit area, centred on its edge or corner (the frame's own border is 2 px).
const HANDLE_BOX: Record<CropHandle, string> = {
nw: "-left-2.5 -top-2.5 size-5 cursor-nwse-resize",
ne: "-right-2.5 -top-2.5 size-5 cursor-nesw-resize",
sw: "-left-2.5 -bottom-2.5 size-5 cursor-nesw-resize",
se: "-right-2.5 -bottom-2.5 size-5 cursor-nwse-resize",
n: "inset-x-3 -top-2 h-4 cursor-ns-resize",
s: "inset-x-3 -bottom-2 h-4 cursor-ns-resize",
e: "inset-y-3 -right-2 w-4 cursor-ew-resize",
w: "inset-y-3 -left-2 w-4 cursor-ew-resize",
};
// The same on a small frame: the full handles reach 10 px into it from every side, and a frame at its
// minimum on a small picture (40 × 23 px) was then nearly all handle, with no room left to grab it.
const SMALL_HANDLE_BOX: Record<CropHandle, string> = {
nw: "-left-1.5 -top-1.5 size-3 cursor-nwse-resize",
ne: "-right-1.5 -top-1.5 size-3 cursor-nesw-resize",
sw: "-left-1.5 -bottom-1.5 size-3 cursor-nesw-resize",
se: "-right-1.5 -bottom-1.5 size-3 cursor-nwse-resize",
n: "inset-x-1.5 -top-1 h-2 cursor-ns-resize",
s: "inset-x-1.5 -bottom-1 h-2 cursor-ns-resize",
e: "inset-y-1.5 -right-1 w-2 cursor-ew-resize",
w: "inset-y-1.5 -left-1 w-2 cursor-ew-resize",
};
// Below this many px on its shorter side a frame takes the small handles.
const SMALL_FRAME_PX = 64;
// A drag is its pointer's: a second finger on the frame neither restarts it from where it landed nor
// moves it (two origins at once made the frame jump between them).
type Drag = { handle: CropHandle | "move"; pointer: number; x: number; y: number; from: Frac };
// Not the app's accent ring: over a video it can be any colour, and a light theme's dark-blue accent
// all but vanished on a dark picture. Amber, like the players' playhead, reads on both — the media
// amber (`--media-mark`), the same in both themes: a light theme's warn is too dark over a picture.
const FOCUS =
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-media-mark";
export function CropFrame({ aspect, rect, ratio, onChange, className }: Props) {
const { t } = useTranslation();
const boxRef = useRef<HTMLDivElement>(null);
const [box, setBox] = useState({ w: 0, h: 0 });
const [drag, setDrag] = useState<Drag | null>(null);
useEffect(() => {
const el = boxRef.current;
if (!el) return;
const measure = () => setBox({ w: el.clientWidth, h: el.clientHeight });
measure();
if (typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
const pic = contentRect(box.w, box.h, aspect);
const small = !!pic && Math.min(rect.w * pic.width, rect.h * pic.height) < SMALL_FRAME_PX;
const handleBox = small ? SMALL_HANDLE_BOX : HANDLE_BOX;
const begin = (handle: Drag["handle"]) => (e: PointerEvent<HTMLElement>) => {
if (e.button !== 0) return;
// The press is the frame's: not the video's play / pause, not a scrub of anything under it.
e.stopPropagation();
e.preventDefault();
if (drag) return;
// preventDefault also stops the press from focusing: focused by hand, so the arrows go on from
// here (an edge is the pointer's only, so its frame takes the focus).
e.currentTarget.closest<HTMLElement>("[tabindex]")?.focus({ preventScroll: true });
try {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {
// No capture (a synthetic event): the drag still follows while the pointer stays on it.
}
setDrag({ handle, pointer: e.pointerId, x: e.clientX, y: e.clientY, from: rect });
};
const follow = (e: PointerEvent<HTMLElement>) => {
if (!drag || drag.pointer !== e.pointerId || !pic) return;
const dx = (e.clientX - drag.x) / pic.width;
const dy = (e.clientY - drag.y) / pic.height;
onChange(
drag.handle === "move"
? moveCrop(drag.from, dx, dy)
: resizeCrop(drag.from, drag.handle, dx, dy, ratio),
);
};
const end = (e: PointerEvent<HTMLElement>) => {
if (drag?.pointer === e.pointerId) setDrag(null);
};
const pointer = (handle: Drag["handle"]) => ({
onPointerDown: begin(handle),
onPointerMove: follow,
onPointerUp: end,
onPointerCancel: end,
// A click after the drag is the frame's too.
onClick: (e: MouseEvent<HTMLElement>) => e.stopPropagation(),
});
const keys = (handle: CropHandle | "move") => (e: KeyboardEvent<HTMLElement>) => {
const dir = ARROWS[e.key];
if (!dir || e.altKey || e.ctrlKey || e.metaKey) return;
// Taken here, so the player underneath does not also seek or change the volume on the arrow.
e.preventDefault();
e.stopPropagation();
const step = e.shiftKey ? BIG_STEP : STEP;
const [dx, dy] = [dir[0] * step, dir[1] * step];
onChange(handle === "move" ? moveCrop(rect, dx, dy) : resizeCrop(rect, handle, dx, dy, ratio));
};
const pct = (v: number) => `${(v * 100).toFixed(3)}%`;
return (
<div
ref={boxRef}
data-testid="crop-frame"
className={clsx("pointer-events-none absolute inset-0 overflow-hidden", className)}
>
{pic && (
<div
className="absolute"
style={{ left: pic.left, top: pic.top, width: pic.width, height: pic.height }}
>
<div
role="group"
tabIndex={0}
aria-label={t("common.cropFrame.frame")}
data-crop="frame"
className={clsx(
"pointer-events-auto absolute cursor-move touch-none border-2 border-white",
FOCUS,
)}
style={{
left: pct(rect.x),
top: pct(rect.y),
width: pct(rect.w),
height: pct(rect.h),
// The dim outside the frame, as far as the picture goes (the layer clips it).
boxShadow: "0 0 0 100vmax rgb(0 0 0 / 0.55)",
}}
onKeyDown={keys("move")}
{...pointer("move")}
>
{drag &&
[1, 2].map((i) => (
<div key={i} aria-hidden="true">
<div
className="absolute inset-y-0 w-px bg-white/40"
style={{ left: `${(i * 100) / 3}%` }}
/>
<div
className="absolute inset-x-0 h-px bg-white/40"
style={{ top: `${(i * 100) / 3}%` }}
/>
</div>
))}
{EDGES.map((h) => (
<div
key={h}
aria-hidden="true"
className={clsx("absolute touch-none", handleBox[h])}
{...pointer(h)}
/>
))}
{CORNERS.map((h) => (
<div
key={h}
role="group"
tabIndex={0}
aria-label={t(`common.cropFrame.${h}`)}
data-crop={h}
className={clsx(
"absolute flex touch-none items-center justify-center rounded-xs",
handleBox[h],
FOCUS,
)}
onKeyDown={keys(h)}
{...pointer(h)}
>
<span
className={clsx("rounded-xs bg-white shadow", small ? "size-1.5" : "size-2.5")}
/>
</div>
))}
</div>
</div>
)}
</div>
);
}
+2 -1
View File
@@ -20,7 +20,8 @@ export interface IconButtonProps
children: ReactNode;
variant?: IconButtonVariant;
size?: IconButtonSize;
/** A toggle: sets aria-pressed and tints the glyph with the accent. */
/** A toggle: sets aria-pressed and tints the glyph with the chosen colour (the accent; the media
* amber on the player chrome). */
pressed?: boolean;
/** Not accepted: a toggle is `pressed` (JSX lets any undeclared `aria-*` through, so it is `never`). */
"aria-pressed"?: never;
+1 -1
View File
@@ -13,7 +13,7 @@ export function MenuCheck({ kind, on }: { kind: "many" | "one"; on: boolean | "m
return (
<Check
aria-hidden="true"
className={`size-4 shrink-0 ${on === true ? "text-accent" : "opacity-0"}`}
className={`size-4 shrink-0 ${on === true ? "text-chosen" : "opacity-0"}`}
/>
);
return (
@@ -2,7 +2,8 @@ import clsx from "clsx";
import type { InputHTMLAttributes, ReactNode, Ref } from "react";
// A6 (docs/DESIGN.md): checkbox, slider and colour well are the BROWSER's controls, tinted
// with the accent — keyboard, screen reader and forced-colours support come free, and a drawn copy
// with the chosen colour (the accent; the media amber on the player chrome) — keyboard, screen
// reader and forced-colours support come free, and a drawn copy
// would have to rebuild all three. What the browser does not give is the 44 touch target: the labelled
// row (`CheckRow`) or the slider's own box grows to it on a coarse pointer. Like `Input`, a raw
// `<input>` for any of them anywhere else is a lint error.
@@ -22,7 +23,7 @@ export function Checkbox({
type="checkbox"
onChange={(e) => onChange?.(e.target.checked)}
className={clsx(
"size-4 shrink-0 cursor-pointer accent-accent disabled:cursor-not-allowed",
"size-4 shrink-0 cursor-pointer accent-chosen disabled:cursor-not-allowed",
className,
)}
{...rest}
@@ -70,7 +71,7 @@ export function Slider({
type="range"
onChange={(e) => onChange(Number(e.target.value))}
className={clsx(
"min-h-6 w-full cursor-pointer accent-accent pointer-coarse:min-h-(--touch-target)",
"min-h-6 w-full cursor-pointer accent-chosen pointer-coarse:min-h-(--touch-target)",
className,
)}
{...rest}
@@ -36,6 +36,7 @@ function PriorityStepButton({
}) {
return (
<button
type="button"
onPointerDown={(e) => {
// Keep receiving pointer events even if the pointer drifts off the small button mid-hold.
try {
@@ -162,6 +163,7 @@ export function PriorityStepper({
} ${cls.value}`}
>
<button
type="button"
ref={editTarget}
onClick={beginEdit}
aria-label={t("common.priority.edit")}
+7 -7
View File
@@ -35,13 +35,13 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
## What lives here
| 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 `<button>` 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 `<input>` 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 `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` 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), `RangeTrack` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop) |
| 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` |
## The size contract (E10 S1c)
@@ -0,0 +1,218 @@
// @vitest-environment jsdom
//
// The shared time track: a handle is a slider the keyboard steps (and the step stops at its range),
// a drag reports its moves as not-final and its release as final, a press on the bare track seeks,
// and the window decides what is drawn. jsdom lays nothing out, so the track's box is stubbed.
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { RangeTrack, RulerTicks, type TrackHandle } from "./RangeTrack";
afterEach(cleanup);
beforeAll(() => {
// jsdom has no PointerEvent: a MouseEvent carries the button and clientX a drag reads.
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;
});
function box(el: Element, left = 0, width = 1000) {
el.getBoundingClientRect = () =>
({
left,
width,
top: 0,
height: 40,
right: left + width,
bottom: 40,
x: left,
y: 0,
}) as DOMRect;
}
function handle(over: Partial<TrackHandle> = {}): TrackHandle {
return {
id: "in",
time: 50,
min: 10,
max: 90,
label: "In",
valueText: "0:50.0",
step: 0.1,
bigStep: 1,
onChange: vi.fn(),
...over,
};
}
describe("RangeTrack", () => {
it("draws a handle as a slider with its time as the value", () => {
render(<RangeTrack duration={100} label="Clip" handles={[handle()]} />);
const s = screen.getByRole("slider", { name: "In" });
expect(s.getAttribute("aria-valuenow")).toBe("50");
expect(s.getAttribute("aria-valuetext")).toBe("0:50.0");
expect(s.style.left).toBe("50%");
});
it("steps a focused handle with the arrows, Shift for the big step, and takes the key", () => {
const h = handle();
render(<RangeTrack duration={100} label="Clip" handles={[h]} />);
const s = screen.getByRole("slider", { name: "In" });
const right = new KeyboardEvent("keydown", {
key: "ArrowRight",
bubbles: true,
cancelable: true,
});
s.dispatchEvent(right);
expect(h.onChange).toHaveBeenLastCalledWith(50.1, true);
expect(right.defaultPrevented).toBe(true);
fireEvent.keyDown(s, { key: "ArrowLeft", shiftKey: true });
expect(h.onChange).toHaveBeenLastCalledWith(49, true);
});
it("stops a step at the handle's range", () => {
const h = handle({ time: 89.95 });
render(<RangeTrack duration={100} label="Clip" handles={[h]} />);
const s = screen.getByRole("slider", { name: "In" });
fireEvent.keyDown(s, { key: "ArrowRight", shiftKey: true });
expect(h.onChange).toHaveBeenLastCalledWith(90, true);
fireEvent.keyDown(s, { key: "Home" });
expect(h.onChange).toHaveBeenLastCalledWith(10, true);
});
it("leaves a modified arrow to the page", () => {
const h = handle();
render(<RangeTrack duration={100} label="Clip" handles={[h]} />);
fireEvent.keyDown(screen.getByRole("slider", { name: "In" }), {
key: "ArrowRight",
ctrlKey: true,
});
expect(h.onChange).not.toHaveBeenCalled();
});
it("reports a drag's moves as not final and its release as final, clamped to the range", () => {
const h = handle();
const onSeek = vi.fn();
render(<RangeTrack duration={100} label="Clip" handles={[h]} onSeek={onSeek} />);
box(screen.getByRole("group", { name: "Clip" }));
const s = screen.getByRole("slider", { name: "In" });
fireEvent.pointerDown(s, { button: 0, clientX: 500, pointerId: 1 });
fireEvent.pointerMove(s, { clientX: 700, pointerId: 1 });
expect(h.onChange).toHaveBeenLastCalledWith(70, false);
fireEvent.pointerUp(s, { clientX: 990, pointerId: 1 });
expect(h.onChange).toHaveBeenLastCalledWith(90, true);
// The handle's press is not a seek of the track under it.
expect(onSeek).not.toHaveBeenCalled();
});
it("reports a drag the browser took away (a cancel) as final where it stopped", () => {
// A caller that acts on the final move only (the Plex seek, the clip strip's window) was left
// with a not-final one for good.
const h = handle();
render(<RangeTrack duration={100} label="Clip" handles={[h]} />);
box(screen.getByRole("group", { name: "Clip" }));
const s = screen.getByRole("slider", { name: "In" });
fireEvent.pointerDown(s, { button: 0, clientX: 500, pointerId: 1 });
fireEvent.pointerMove(s, { clientX: 700, pointerId: 1 });
fireEvent.pointerCancel(s, { pointerId: 1 });
expect(h.onChange).toHaveBeenLastCalledWith(70, true);
});
it("follows the pointer that took a handle, not a second finger", () => {
const a = handle({ id: "in", label: "In", time: 30 });
const b = handle({ id: "out", label: "Out", time: 70 });
render(<RangeTrack duration={100} label="Clip" handles={[a, b]} />);
box(screen.getByRole("group", { name: "Clip" }));
const inH = screen.getByRole("slider", { name: "In" });
const outH = screen.getByRole("slider", { name: "Out" });
fireEvent.pointerDown(inH, { button: 0, clientX: 300, pointerId: 1 });
fireEvent.pointerDown(outH, { button: 0, clientX: 700, pointerId: 2 });
fireEvent.pointerMove(inH, { clientX: 400, pointerId: 1 });
expect(a.onChange).toHaveBeenLastCalledWith(40, false);
fireEvent.pointerMove(outH, { clientX: 800, pointerId: 2 });
expect(b.onChange).not.toHaveBeenCalled();
fireEvent.pointerUp(inH, { clientX: 400, pointerId: 1 });
expect(a.onChange).toHaveBeenLastCalledWith(40, true);
});
it("ends a drag whose handle leaves the window under it, so the track takes the next press", () => {
const onChange = vi.fn();
const onSeek = vi.fn();
const { rerender } = render(
<RangeTrack duration={200} label="Clip" handles={[handle({ onChange })]} onSeek={onSeek} />,
);
const track = screen.getByRole("group", { name: "Clip" });
box(track);
fireEvent.pointerDown(screen.getByRole("slider", { name: "In" }), {
button: 0,
clientX: 250,
pointerId: 7,
});
// A key moves the mark out of the window while the finger is still down: the handle unmounts.
rerender(
<RangeTrack
duration={200}
label="Clip"
handles={[handle({ onChange, time: 20 })]}
view={{ from: 100, to: 200 }}
onSeek={onSeek}
/>,
);
expect(screen.queryByRole("slider", { name: "In" })).toBeNull();
// Released where the handle no longer is: the drag ends, as final, where the mark now is.
fireEvent.pointerUp(document.body, { pointerId: 7 });
expect(onChange).toHaveBeenLastCalledWith(20, true);
fireEvent.pointerDown(track, { button: 0, clientX: 500, pointerId: 8 });
expect(onSeek).toHaveBeenCalledWith(150, false);
});
it("seeks on a press of the bare track, following the drag, final on release", () => {
const onSeek = vi.fn();
render(<RangeTrack duration={200} label="Clip" onSeek={onSeek} />);
const track = screen.getByRole("group", { name: "Clip" });
box(track);
fireEvent.pointerDown(track, { button: 0, clientX: 250, pointerId: 1 });
expect(onSeek).toHaveBeenLastCalledWith(50, false);
fireEvent.pointerMove(track, { clientX: 500, pointerId: 1 });
expect(onSeek).toHaveBeenLastCalledWith(100, false);
fireEvent.pointerUp(track, { clientX: 500, pointerId: 1 });
expect(onSeek).toHaveBeenLastCalledWith(100, true);
});
it("maps time through its window, and draws nothing that falls outside it", () => {
const onSeek = vi.fn();
render(
<RangeTrack
duration={3600}
view={{ from: 600, to: 700 }}
label="Zoom"
playhead={50}
onSeek={onSeek}
handles={[
handle({ id: "in", label: "In", time: 650, min: 0, max: 3600 }),
handle({ id: "out", label: "Out", time: 800, min: 0, max: 3600 }),
]}
/>,
);
const track = screen.getByRole("group", { name: "Zoom" });
box(track);
expect(screen.getByRole("slider", { name: "In" }).style.left).toBe("50%");
expect(screen.queryByRole("slider", { name: "Out" })).toBeNull();
expect(screen.queryByTestId("range-playhead")).toBeNull();
fireEvent.pointerDown(track, { button: 0, clientX: 100, pointerId: 1 });
expect(onSeek).toHaveBeenLastCalledWith(610, false);
});
});
describe("RulerTicks", () => {
it("picks a step that keeps the window to a handful of ticks", () => {
const { container } = render(<RulerTicks view={{ from: 0, to: 60 }} format={(t) => `${t}s`} />);
const labels = [...container.querySelectorAll("span")].map((s) => s.textContent);
expect(labels).toEqual(["0s", "10s", "20s", "30s", "40s", "50s", "60s"]);
});
});
+319
View File
@@ -0,0 +1,319 @@
import {
useEffect,
useEffectEvent,
useRef,
useState,
type CSSProperties,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
} from "react";
import clsx from "clsx";
import { trackFrac, trackPct } from "./trackPosition";
// A horizontal time track: the shared body of every timeline that marks points in a piece of media
// (the download editor's cut list, the players' clip panel). It draws what it is given — regions
// (tinted spans), handles (draggable, keyboard-steppable marks), a playhead and a background slot
// (a filmstrip, a ruler) — over a WINDOW of the media's time, so the same track serves an overview of
// the whole item and a zoomed strip around a selection. What a mark means stays with the caller.
//
// A handle is a real control, not just a drag target: a `slider` with its time as the value, so Tab
// reaches it and the arrows step it (Shift for the big step), and a screen reader hears where it is.
// Its drawn bar is thin and its hit area 1.5rem wide, at least twice the bar — the long-timeline
// editors that drew the hit area as thin as the bar were the ones people missed.
//
// Every move reports `final`: false while a pointer drags, true on release or a key — and when the
// browser takes the pointer away mid-drag (a cancel: a system gesture on a tablet), true where it
// stopped. A player whose seek is expensive (a transcode restarts) acts on the final one only; a cheap
// one follows the drag. A drag is its pointer's: a second finger neither takes it over nor starts
// another, so two fingers on the track never fight over one mark.
export interface RangeWindow {
from: number;
to: number;
}
export interface TrackHandle {
id: string;
time: number;
/** The range the handle may move in (seconds); a move past it stops at the edge. */
min: number;
max: number;
/** Accessible name ("In", "Cut 2"). */
label: string;
/** The time as read out (aria-valuetext), in the caller's own format. */
valueText: string;
/** Seconds per arrow key; `bigStep` with Shift or Page Up / Down. */
step: number;
bigStep: number;
onChange: (time: number, final: boolean) => void;
/** The drawn bar's look (its colour); the hit area around it is the track's. */
barClassName?: string;
}
interface TrackRegion {
key: string | number;
start: number;
end: number;
className?: string;
style?: CSSProperties;
title?: string;
onPointerDown?: (e: PointerEvent<HTMLDivElement>) => void;
children?: ReactNode;
}
interface Props {
/** The media's length; with no `view` the track shows all of it. */
duration: number;
view?: RangeWindow;
playhead?: number | null;
/** A press or drag on the track itself (not on a handle): the time under the pointer. */
onSeek?: (time: number, final: boolean) => void;
/** The pointer over the track: its x within the track and the time there; null once it leaves. */
onHover?: (h: { x: number; time: number } | null) => void;
handles?: readonly TrackHandle[];
regions?: readonly TrackRegion[];
background?: ReactNode;
/** The track's box: its height, surface and edge. */
className?: string;
/** Accessible name of the whole track. */
label: string;
playheadClassName?: string;
}
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
/** The time under `clientX` on the track that holds `el`. */
function timeAtPointer(el: Element, clientX: number, win: RangeWindow): number {
const track = el.closest("[data-range-track]") ?? el;
const r = track.getBoundingClientRect();
const frac = r.width > 0 ? clamp((clientX - r.left) / r.width, 0, 1) : 0;
return win.from + frac * (win.to - win.from);
}
export function RangeTrack({
duration,
view,
playhead,
onSeek,
onHover,
handles = [],
regions = [],
background,
className,
label,
playheadClassName = "bg-white",
}: Props) {
const win: RangeWindow = view ?? { from: 0, to: duration };
const frac = (t: number) => trackFrac(t, win);
const pct = trackPct;
// The handle a pointer is dragging, and the press on the bare track (a scrub), so a move is only
// acted on between its own down and up.
const [dragging, setDragging] = useState<{
id: string;
pointer: number;
el: HTMLElement;
} | null>(null);
const [scrubbing, setScrubbing] = useState<number | null>(null);
// Where the drag last put its handle, for a cancel to report as final.
const lastRef = useRef(0);
const busy = dragging != null || scrubbing != null;
// A handle can go while its pointer is still down: its mark moved out of the window under it (a
// key marked the other end and carried this one along). Its own up / cancel then never come — the
// capture went with the node — and the drag would hold the track `busy` for good (a finger never
// comes back with the same pointer). So the pointer's release anywhere ends it, reported final
// where the mark now is: the caller's final-only work (a seek, a frozen view let go) still runs.
const endLostDrag = useEffectEvent((id: string) => {
setDragging(null);
const h = handles.find((x) => x.id === id);
if (h) h.onChange(clamp(h.time, h.min, h.max), true);
});
useEffect(() => {
if (!dragging) return;
const { id, pointer, el } = dragging;
const end = (e: globalThis.PointerEvent) => {
if (e.pointerId === pointer && !el.isConnected) endLostDrag(id);
};
document.addEventListener("pointerup", end);
document.addEventListener("pointercancel", end);
return () => {
document.removeEventListener("pointerup", end);
document.removeEventListener("pointercancel", end);
};
}, [dragging]);
const capture = (e: PointerEvent<HTMLElement>) => {
try {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {
// No capture (a synthetic event): the drag still follows while the pointer stays on the target.
}
};
return (
<div
data-range-track=""
role="group"
aria-label={label}
className={clsx("relative touch-none select-none overflow-hidden", className)}
onPointerDown={(e) => {
if (!onSeek || e.button !== 0 || busy) return;
capture(e);
setScrubbing(e.pointerId);
onSeek(timeAtPointer(e.currentTarget, e.clientX, win), false);
}}
onPointerMove={(e) => {
const time = timeAtPointer(e.currentTarget, e.clientX, win);
onHover?.({ x: e.clientX - e.currentTarget.getBoundingClientRect().left, time });
if (scrubbing === e.pointerId) onSeek?.(time, false);
}}
onPointerUp={(e) => {
if (scrubbing !== e.pointerId) return;
setScrubbing(null);
onSeek?.(timeAtPointer(e.currentTarget, e.clientX, win), true);
}}
// A cancelled scrub seeks no further: where it was taken away is not a place anyone chose.
onPointerCancel={(e) => {
if (scrubbing === e.pointerId) setScrubbing(null);
}}
onPointerLeave={() => onHover?.(null)}
>
{background}
{regions.map((r) => {
const a = clamp(frac(r.start), 0, 1);
const b = clamp(frac(r.end), 0, 1);
if (b <= a && r.end > r.start) return null; // wholly outside the window
return (
<div
key={r.key}
className={clsx("absolute inset-y-0", r.className)}
style={{ left: pct(a), width: pct(b - a), ...r.style }}
title={r.title}
onPointerDown={r.onPointerDown}
>
{r.children}
</div>
);
})}
{playhead != null && playhead >= win.from && playhead <= win.to && (
<div
data-testid="range-playhead"
className={clsx(
"pointer-events-none absolute inset-y-0 w-0.5 -translate-x-1/2",
playheadClassName,
)}
style={{ left: pct(frac(playhead)) }}
/>
)}
{handles.map((h) => {
const f = frac(h.time);
if (f < 0 || f > 1) return null;
const move = (time: number, final: boolean) => {
const t = clamp(time, h.min, h.max);
lastRef.current = t;
h.onChange(t, final);
};
const mine = (e: PointerEvent) => dragging?.id === h.id && dragging.pointer === e.pointerId;
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const big = e.shiftKey ? h.bigStep : h.step;
const to: Record<string, number> = {
ArrowLeft: h.time - big,
ArrowDown: h.time - big,
ArrowRight: h.time + big,
ArrowUp: h.time + big,
PageDown: h.time - h.bigStep,
PageUp: h.time + h.bigStep,
Home: h.min,
End: h.max,
};
const next = to[e.key];
if (next === undefined || e.altKey || e.ctrlKey || e.metaKey) return;
// Taken here, so the player underneath does not also seek on the same arrow (`dispatch`
// skips a handled key).
e.preventDefault();
e.stopPropagation();
move(next, true);
};
return (
<div
key={h.id}
role="slider"
tabIndex={0}
aria-label={h.label}
aria-valuemin={Math.round(h.min * 10) / 10}
aria-valuemax={Math.round(h.max * 10) / 10}
aria-valuenow={Math.round(h.time * 10) / 10}
aria-valuetext={h.valueText}
data-handle={h.id}
// The ring sits inside the hit area: the track clips its overflow. Its colour is the base
// focus ring's, which a scope over another ground re-points (media-scope: the media amber).
className="absolute inset-y-0 z-base flex w-6 -translate-x-1/2 cursor-ew-resize touch-none items-center justify-center rounded-sm focus-visible:-outline-offset-2"
style={{ left: pct(f) }}
onKeyDown={onKeyDown}
onPointerDown={(e) => {
if (e.button !== 0) return;
e.stopPropagation();
if (busy) return;
capture(e);
lastRef.current = h.time;
setDragging({ id: h.id, pointer: e.pointerId, el: e.currentTarget });
}}
onPointerMove={(e) => {
if (mine(e)) move(timeAtPointer(e.currentTarget, e.clientX, win), false);
}}
onPointerUp={(e) => {
if (!mine(e)) return;
e.stopPropagation();
setDragging(null);
move(timeAtPointer(e.currentTarget, e.clientX, win), true);
}}
onPointerCancel={(e) => {
if (!mine(e)) return;
setDragging(null);
move(lastRef.current, true);
}}
>
<div className={clsx("h-full w-1 rounded-sm", h.barClassName ?? "bg-white/90")} />
</div>
);
})}
</div>
);
}
const NICE_STEPS = [0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200];
/** Time ticks for a track's background: a nice step that gives the window at most `max` of them,
* each labelled by `format`. */
export function RulerTicks({
view,
format,
max = 8,
className,
}: {
view: RangeWindow;
format: (t: number) => string;
max?: number;
className?: string;
}) {
const span = Math.max(view.to - view.from, 1e-6);
const step = NICE_STEPS.find((s) => span / s <= max) ?? NICE_STEPS[NICE_STEPS.length - 1]!;
const ticks: number[] = [];
for (let t = Math.ceil(view.from / step) * step; t <= view.to; t += step) ticks.push(t);
return (
<div aria-hidden="true" className={clsx("pointer-events-none absolute inset-0", className)}>
{ticks.map((t) => (
<div
key={t}
className="absolute bottom-0 h-2 border-l border-current"
style={{ left: `${(((t - view.from) / span) * 100).toFixed(3)}%` }}
>
<span className="absolute bottom-2 left-1 text-3xs tabular-nums whitespace-nowrap">
{format(t)}
</span>
</div>
))}
</div>
);
}
@@ -62,6 +62,8 @@ export default function SegmentedToggle<T extends string>({
{options.map((o) => (
<button
key={o.value}
// A choice, never a submit: inside a form (the clip panel) a bare button would send it.
type="button"
onClick={() => onChange(o.value)}
title={o.title}
aria-pressed={value === o.value}
@@ -61,4 +61,16 @@ describe("Tabs", () => {
fireEvent.click(tab("Beta"));
expect(screen.getByRole("tabpanel", { name: "Beta" })).toBe(panel);
});
it("on the media chrome marks the chosen tab in white with the chosen mark, not the accent's text", () => {
// A light theme's accent is dark on the always-dark chrome (Slate 2.84:1 as text).
render(<Tabs tabs={TABS} active="a" onChange={() => {}} label="Pick" tone="media" />);
const on = tab("Alpha").className;
expect(on).not.toMatch(/\btext-accent\b/);
expect(on).toMatch(/\btext-white\b/);
// The scope's own `--chosen`, read where it is used: `--color-chosen` is resolved once on :root
// and would stay the accent under `media-marks`.
expect(on).toMatch(/var\(--chosen,/);
expect(on).not.toMatch(/--color-chosen/);
});
});
+2 -1
View File
@@ -2,6 +2,7 @@ import { Fragment, useId, useRef, type KeyboardEvent } from "react";
import { panelId, tabId } from "./tabPanel";
import { type LucideIcon } from "lucide-react";
import CountBadge from "./CountBadge";
import { MEDIA_CHOSEN } from "./mediaChrome";
// Reusable pill tab-rail for in-page sub-navigation (Configuration, Users & roles, the Settings
// sections, the Stats overview/system split…). Horizontal by default; `orientation="vertical"` gives
@@ -125,7 +126,7 @@ export default function Tabs({
className={
tone === "media"
? `touch-target flex-1 items-center justify-center whitespace-nowrap rounded px-2 py-1 text-2xs ${
on ? "bg-white/15 text-accent" : "text-white/70 hover:bg-white/10"
on ? MEDIA_CHOSEN : "text-white/70 hover:bg-white/10"
}`
: `pill touch-target items-center rounded-lg px-3 text-sm ${
vertical
@@ -0,0 +1,72 @@
// @vitest-environment jsdom
//
// The time field: invalid only once a time was offered, said in words on the caller's line, and a
// draft that never outlives the value it was typed over.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { TimecodeError, TimecodeField } from "./TimecodeField";
import { useTimecodeField } from "./useTimecodeField";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
});
afterEach(cleanup);
function Harness({ value, onCommit }: { value: number; onCommit: (s: number) => void }) {
const field = useTimecodeField(value, false, onCommit);
return (
<>
<TimecodeField aria-label="In" field={field} errorId="err" />
{field.invalid && <TimecodeError id="err" hours={false} />}
</>
);
}
const field = () => screen.getByRole("textbox") as HTMLInputElement;
describe("TimecodeField", () => {
it("is not invalid while a time is still being typed, only once it is offered", () => {
render(<Harness value={10} onCommit={() => {}} />);
fireEvent.change(field(), { target: { value: "" } });
fireEvent.change(field(), { target: { value: "1:" } });
expect(field().getAttribute("aria-invalid")).toBeNull();
fireEvent.blur(field());
expect(field().getAttribute("aria-invalid")).toBe("true");
// Once one was offered, the field re-checks as it is typed: the mark goes the moment it reads.
fireEvent.change(field(), { target: { value: "1:0" } });
expect(field().getAttribute("aria-invalid")).toBeNull();
});
it("says in words, read out and linked to the field, how a time is typed", () => {
render(<Harness value={10} onCommit={() => {}} />);
fireEvent.change(field(), { target: { value: "abc" } });
fireEvent.keyDown(field(), { key: "Enter" });
const id = field().getAttribute("aria-describedby");
expect(id).toBeTruthy();
expect(document.getElementById(id!)?.textContent).toBe("common.timecodeInvalid");
expect(screen.getByRole("alert").id).toBe(id);
fireEvent.change(field(), { target: { value: "0:12" } });
fireEvent.keyDown(field(), { key: "Enter" });
expect(field().getAttribute("aria-describedby")).toBeNull();
});
it("drops a draft when the value moves under it, and never commits it to the new value", () => {
const onCommit = vi.fn();
const { rerender } = render(<Harness value={10} onCommit={onCommit} />);
fireEvent.change(field(), { target: { value: "0:05" } });
// Another segment selected under the same field (or a nudge): the field edits a new value now.
rerender(<Harness value={40} onCommit={onCommit} />);
expect(field().value).toBe("0:40.0");
fireEvent.blur(field());
expect(onCommit).not.toHaveBeenCalled();
// An unreadable one goes too, and its mark with it.
fireEvent.change(field(), { target: { value: "x" } });
fireEvent.blur(field());
expect(field().getAttribute("aria-invalid")).toBe("true");
rerender(<Harness value={41} onCommit={onCommit} />);
expect(field().value).toBe("0:41.0");
expect(field().getAttribute("aria-invalid")).toBeNull();
});
});
@@ -0,0 +1,67 @@
import { type ComponentProps } from "react";
import { useTranslation } from "react-i18next";
import { Input } from "./Input";
import type { TimecodeDraft } from "./useTimecodeField";
// A field a cut's time is typed into (lib/timecode): the editor's segment ends, the clip panel's In /
// Out. A typed draft is the field's until Enter or leaving the field takes it; then the field shows
// the value as it landed — the caller may clamp it (a time past the end) — and a time it cannot read
// stays for fixing rather than vanishing or seeming taken: marked invalid, with a line saying how to
// type one. Nothing is marked while a first time is being typed; once one was offered (Enter,
// leaving the field) and could not be read, the field re-checks as it is typed, so the mark goes the
// moment the time reads. One element throughout, so Enter leaves the focus in the field (a field
// re-created per value dropped it on <body>).
//
// The draft lives in `useTimecodeField`, held by the caller, because the line is the caller's to
// place: in the flow under the fields' row (`TimecodeError`), never laid over the controls below it,
// and outside a field's <label>, where it would become part of the field's name.
type Props = Omit<
ComponentProps<typeof Input>,
"value" | "defaultValue" | "onChange" | "invalid"
> & {
field: TimecodeDraft;
/** The id of the `TimecodeError` that speaks for this field while it is invalid. */
errorId: string;
};
export function TimecodeField({
field,
errorId,
onBlur,
onKeyDown,
"aria-describedby": describedBy,
...rest
}: Props) {
const described = [describedBy, field.invalid ? errorId : null].filter(Boolean).join(" ");
return (
<Input
{...rest}
value={field.text}
invalid={field.invalid}
aria-describedby={described || undefined}
onChange={(e) => field.type(e.target.value)}
onBlur={(e) => {
field.offer();
onBlur?.(e);
}}
onKeyDown={(e) => {
onKeyDown?.(e);
if (e.key !== "Enter" || e.defaultPrevented) return;
e.preventDefault();
field.offer();
}}
/>
);
}
/** How a time is typed, said once one could not be read: rendered by the caller only while one of
* its fields is invalid, in the flow under them, and read out as it appears. */
export function TimecodeError({ id, hours }: { id: string; hours: boolean }) {
const { t } = useTranslation();
return (
<p id={id} role="alert" className="text-xs text-danger">
{t(hours ? "common.timecodeInvalidHours" : "common.timecodeInvalid")}
</p>
);
}
+2 -2
View File
@@ -121,7 +121,7 @@ const ICON_VARIANTS: Record<IconButtonVariant, string> = {
// An icon toggle that is on shows it in the glyph (watched, saved, a selected track), not as a solid
// fill: a solid accent square in a row of quiet glyphs outshouts the row's primary action.
const ICON_PRESSED = "aria-pressed:text-accent";
const ICON_PRESSED = "aria-pressed:text-chosen";
const ICON_SIZES: Record<IconButtonSize, string> = {
sm: "size-7 [&>svg]:size-4",
@@ -172,7 +172,7 @@ export function iconButtonClass({
pressed?: boolean;
}) {
return clsx(
"inline-grid shrink-0 place-items-center transition touch-target aria-expanded:text-accent disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed",
"inline-grid shrink-0 place-items-center transition touch-target aria-expanded:text-chosen disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed",
ICON_VARIANTS[variant],
round || variant === "media" ? "rounded-full" : "rounded-lg",
pressed !== undefined && ICON_PRESSED,
+3 -2
View File
@@ -25,13 +25,14 @@ const SIZES: Record<FieldSize, string> = {
};
// The edge is --edge-strong (the fg mixed 55% into the bg, ≥ 3:1 on every scheme — WCAG 1.4.11); focus
// turns it into a 2px accent edge (the border plus a 1px ring), the outlined-field convention. Invalid
// turns it into a 2px accent edge (the border plus a 1px ring; `focus`, so a scope over another ground
// can name its own, see media-scope), the outlined-field convention. Invalid
// is the danger edge, focused or not. A wrapper frame keys both states to its own INPUT, not to any
// descendant: the header search's Go button is disabled while the query is empty, and a bare
// `has-disabled:` faded the whole box. On a coarse pointer the box grows to the 44 touch target.
const BASE =
"min-w-0 border edge-strong text-fg placeholder:text-muted transition-colors " +
"focus-within:border-accent focus-within:ring-1 focus-within:ring-accent focus-within:outline-hidden " +
"focus-within:border-focus focus-within:ring-1 focus-within:ring-focus focus-within:outline-hidden " +
"aria-invalid:border-danger aria-invalid:ring-danger " +
"has-[input[aria-invalid=true]]:border-danger has-[input[aria-invalid=true]]:ring-danger " +
"disabled:cursor-not-allowed disabled:opacity-50 " +
@@ -0,0 +1,7 @@
// The player's always-dark chrome, which looks the same in every theme because the picture under it
// does (docs/DESIGN.md, "Marks on the player chrome").
/** A choice chosen on the chrome (a settings tab, a date style): white on a veil, underlined in the
* chosen mark — `media-marks` points it at the media amber, as a light theme's accent is dark there. */
export const MEDIA_CHOSEN =
"bg-white/15 text-white shadow-[inset_0_-2px_0_var(--chosen,var(--accent))]";
@@ -0,0 +1,14 @@
import type { RangeWindow } from "./RangeTrack";
// Where a time sits along a `RangeTrack` over a window: the track draws its regions, playhead and
// handles by these, and a control laid beside the track (a cut's remove button under it) lines up
// with the handle drawn at the same time.
export const trackFrac = (t: number, win: RangeWindow) =>
(t - win.from) / Math.max(win.to - win.from, 1e-6);
/** A fraction of the track as a CSS length, held to the track. */
export const trackPct = (f: number) => `${(Math.max(0, Math.min(1, f)) * 100).toFixed(3)}%`;
/** A time's place along the track, as its CSS `left`. */
export const trackLeft = (t: number, win: RangeWindow) => trackPct(trackFrac(t, win));
@@ -0,0 +1,49 @@
import { useState } from "react";
import { formatTimecode, parseTimecode } from "../../lib/timecode";
// The draft of a time field (`TimecodeField`), held by the caller so it can place the line saying an
// unreadable time (`TimecodeError`) in its own flow.
export interface TimecodeDraft {
/** The text the field shows. */
text: string;
/** A time was offered and cannot be read. */
invalid: boolean;
type: (text: string) => void;
/** Enter or leaving the field: take the draft if it reads, else mark it. */
offer: () => void;
}
/** The draft of one time field. `value` in seconds; `hours` as `formatTimecode` takes it. */
export function useTimecodeField(
value: number,
hours: boolean,
/** A readable time was typed (not called when it equals `value`). */
onCommit: (seconds: number) => void,
): TimecodeDraft {
const [draft, setDraft] = useState<{ text: string; offered: boolean } | null>(null);
// A draft belongs to the value it was typed over. When the value moves without it (a nudge, "to
// the playhead", another segment selected under the same field), the draft is dropped: kept, it
// would show over the new value, and leaving the field would commit it to whatever the field
// edits now — another cut.
const [typedOver, setTypedOver] = useState(value);
if (typedOver !== value) {
setTypedOver(value);
setDraft(null);
}
return {
text: draft?.text ?? formatTimecode(value, hours),
invalid: draft != null && draft.offered && parseTimecode(draft.text) == null,
type: (text) => setDraft({ text, offered: draft?.offered ?? false }),
offer: () => {
if (draft == null) return;
const v = parseTimecode(draft.text);
if (v == null) {
if (!draft.offered) setDraft({ text: draft.text, offered: true });
return;
}
setDraft(null);
if (Math.abs(v - value) > 1e-3) onCommit(v);
},
};
}
@@ -59,6 +59,7 @@ const CANCEL_COPY: Record<DownloadJobKind, typeof CANCEL_DOWNLOAD> = {
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<DownloadJobKind, { title: string; body: string }> = {
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({
@@ -1,4 +1,15 @@
[
{
"version": "0.105.0",
"date": "2026-10-10",
"new": [
"Clips: mark a range of what you're watching — a YouTube video or a Plex item — set its ends to the tenth, crop it to a frame drawn on the picture, and cut it straight into your downloads."
],
"fixed": [
"Downloads: a download, a clip or an editor clip that doesn't fit in the storage you have left says so, and is never stored over your cap; YouTube downloads reach their format's height again.",
"Player: focus rings, the chosen track or tab, sliders and the progress bar's head are amber on the dark player in every theme, so they show in a light theme too."
]
},
{
"version": "0.104.0",
"date": "2026-10-10",
@@ -1,4 +1,15 @@
[
{
"version": "0.105.0",
"date": "2026-10-10",
"new": [
"Klipek: jelölj ki egy szakaszt abból, amit nézel — YouTube-videóból vagy Plex-elemből —, állítsd be a végeit tizedmásodpercre, vágd ki a képre rajzolt keretre, és mentsd egyenesen a letöltéseid közé."
],
"fixed": [
"Letöltések: ami nem fér bele a megmaradt tárhelyedbe — letöltés, klip vagy szerkesztett vágás —, arról ezt mondja, és sosem kerül a kereteden túl a tárhelyre; a YouTube-letöltések ismét elérik a formátumuk felbontását.",
"Lejátszó: a fókuszgyűrű, a kiválasztott sáv vagy fül, a csúszkák és a folyamatjelző feje minden témában borostyán a sötét lejátszón, így világos témában is látszanak."
]
},
{
"version": "0.104.0",
"date": "2026-10-10",
@@ -170,6 +170,13 @@ const youtubeReasons = [
backend("youtube/client.py").match(/^YouTubeRefusal = Literal\[([^\]]*)\]/m)?.[1] ?? ""
).matchAll(/"(\w+)"/g),
].map((m) => m[1]!);
// A clip the server will not cut → `clip.errors.<reason>`: every `ClipError("<reason>"…)` in the backend
// (a reason may carry its numbers after it: `ClipError("too_long", limit_s=…)`).
const clipReasons = [
...new Set(
appSources().flatMap((src) => [...src.matchAll(/ClipError\("(\w+)"[,)]/g)].map((m) => m[1]!)),
),
];
// The closed English vocabularies the backend WRITES into data, named through `vocab.<group>`
// (lib/vocab) by their letters-and-digits key. A word the backend adds shows in English until named —
@@ -233,6 +240,7 @@ describe("backend ids have their names in both languages", () => {
expect(goneWhats).toEqual(expect.arrayContaining(["playlist", "download", "job"]));
expect(opaqueWhats).toEqual([]);
expect(youtubeReasons).toEqual(["quota", "reconnect", "unavailable", "gone"]);
expect(clipReasons).toEqual(expect.arrayContaining(["too_long", "past_end", "bad_crop"]));
expect(crewJobs).toEqual(
expect.arrayContaining(["Director", "Creator", "Director of Photography"]),
);
@@ -336,6 +344,13 @@ describe("backend ids have their names in both languages", () => {
).toEqual([]);
});
it(`${lang}: every reason a clip is refused, and none it cannot be`, () => {
expect(missing(clipReasons.map((r) => `clip.errors.${r}`))).toEqual([]);
expect([...idsUnder(keys, "clip.errors")].filter((r) => !clipReasons.includes(r))).toEqual(
[],
);
});
it(`${lang}: every crew job, auto-tag and built-in profile the backend writes, and no other`, () => {
for (const [group, words] of Object.entries(VOCAB)) {
const wanted = words.map((w) => `vocab.${group}.${vocabKey(w)}`);
+36
View File
@@ -0,0 +1,36 @@
{
"title": "Clip",
"open": "Make a clip",
"close": "Close the clip panel",
"overview": "Whole video",
"strip": "Clip range",
"in": "Start",
"out": "End",
"inHere": "Start at the current position",
"outHere": "End at the current position",
"earlier": "{{label}}: a tenth of a second earlier",
"later": "{{label}}: a tenth of a second later",
"length": "Length {{length}}",
"loop": "Play selection",
"name": "Clip name",
"namePlaceholder": "Clip name (optional)",
"cut": "Cut clip",
"queued": "The clip is being made — it will be in your downloads",
"view": "Downloads",
"failed": "Couldn’t make the clip",
"crop": "Crop",
"shape": "Frame shape",
"shapeFree": "Free",
"whole": "Whole picture",
"errors": {
"bad_source": "This video can’t be clipped.",
"bad_range": "The clip’s start or end isn’t valid.",
"too_short": "A clip is at least a second long.",
"too_long": "A clip is at most {{minutes}} minutes long.",
"bad_audio": "That audio track isn’t available.",
"bad_crop": "The crop frame isn’t valid. Make it bigger, or go back to the whole picture.",
"not_found": "This item is no longer in the library.",
"media_missing": "The item’s file can’t be reached from here.",
"past_end": "The clip runs past the end of the video."
}
}
+9
View File
@@ -2,6 +2,8 @@
"privacyPolicy": "Privacy policy",
"termsOfService": "Terms of service",
"sourceCode": "Source code",
"timecodeInvalid": "Not a time — type it like 1:05.5",
"timecodeInvalidHours": "Not a time — type it like 1:02:05.5",
"close": "Close",
"cancel": "Cancel",
"confirm": "Confirm",
@@ -69,5 +71,12 @@
"done": "Copied to the clipboard",
"linkDone": "Link copied to the clipboard",
"failed": "Couldn't copy to the clipboard"
},
"cropFrame": {
"frame": "Crop frame — the arrow keys move it, with Shift further",
"nw": "Top-left corner of the crop frame — the arrow keys resize it",
"ne": "Top-right corner of the crop frame — the arrow keys resize it",
"sw": "Bottom-left corner of the crop frame — the arrow keys resize it",
"se": "Bottom-right corner of the crop frame — the arrow keys resize it"
}
}
+7 -2
View File
@@ -243,10 +243,15 @@
"detailsToggle": "Technical details",
"upload": {
"quota_exceeded": {
"short": "Not enough storage for the converted file",
"body": "The phone-ready version of this upload would not fit in your storage quota. Your original is kept – delete something from your library, then try again."
}
},
"edit": {
"quota_exceeded": {
"short": "Not enough storage for the clip",
"body": "This clip doesn't fit in the storage you have left. Delete something from your library, or cut a shorter clip, then try again."
},
"unknown": {
"short": "Couldn't make the clip",
"body": "Something went wrong while making this clip. The technical details below may help."
@@ -302,8 +307,8 @@
"body": "Something went wrong with this download. The technical details below may help."
},
"quota_exceeded": {
"short": "Storage full",
"body": "This download was stopped because your storage quota is already used up. Delete something from your library, then try again."
"short": "Not enough storage",
"body": "This download doesn't fit in the storage you have left. Delete something from your library, then try again."
}
},
"catalog": {
+4 -2
View File
@@ -37,7 +37,9 @@
"dropped": "Dropped",
"keptSeg": "Kept segment",
"droppedSeg": "Dropped segment",
"removeCut": "Remove cut",
"removeCut": "Remove cut {{n}}",
"keptSummary": "{{n}} kept · {{dur}}",
"createJoin": "Create joined clip"
"createJoin": "Create joined clip",
"timeline": "Timeline",
"cutN": "Cut {{n}}"
}
+3 -1
View File
@@ -38,5 +38,7 @@
"cap": {
"space": "Space",
"menu": "Menu"
}
},
"clip": "Clip mode",
"clipMark": "Mark the clip’s start / end here"
}
+1 -1
View File
@@ -45,7 +45,7 @@
"undoAction": "Unsuspend",
"title": "Suspend account?",
"undoTitle": "Unsuspend account?",
"confirm": "Suspend {{email}}? They won't be able to sign in (by password or Google) until you unsuspend them, and any active session ends.",
"confirm": "Suspend {{email}}? They won't be able to sign in (by password or Google) until you unsuspend them, and any active session ends. Their queued downloads wait until then.",
"undoConfirm": "Unsuspend {{email}}? They'll be able to sign in again.",
"done": "{{email}} suspended",
"undone": "{{email}} unsuspended",
+36
View File
@@ -0,0 +1,36 @@
{
"title": "Klip",
"open": "Klip készítése",
"close": "Klippanel bezárása",
"overview": "A teljes videó",
"strip": "A klip tartománya",
"in": "Eleje",
"out": "Vége",
"inHere": "Eleje a mostani pozíciónál",
"outHere": "Vége a mostani pozíciónál",
"earlier": "{{label}}: egy tized másodperccel korábban",
"later": "{{label}}: egy tized másodperccel később",
"length": "Hossz {{length}}",
"loop": "Kijelölés lejátszása",
"name": "A klip neve",
"namePlaceholder": "A klip neve (nem kötelező)",
"cut": "Klip vágása",
"queued": "Készül a klip — a letöltéseid közé kerül",
"view": "Letöltések",
"failed": "Nem sikerült elkészíteni a klipet",
"crop": "Kivágás",
"shape": "Keret aránya",
"shapeFree": "Szabad",
"whole": "Teljes kép",
"errors": {
"bad_source": "Ebből a videóból nem lehet klipet vágni.",
"bad_range": "A klip eleje vagy vége érvénytelen.",
"too_short": "A klip legalább egy másodperc.",
"too_long": "A klip legfeljebb {{minutes}} perc lehet.",
"bad_audio": "Ez a hangsáv nem érhető el.",
"bad_crop": "A kivágókeret érvénytelen. Nagyítsd meg, vagy térj vissza a teljes képre.",
"not_found": "Ez az elem már nincs a könyvtárban.",
"media_missing": "Az elem fájlja innen nem érhető el.",
"past_end": "A klip túlnyúlik a videó végén."
}
}
+9
View File
@@ -2,6 +2,8 @@
"privacyPolicy": "Adatvédelmi irányelvek",
"termsOfService": "Felhasználási feltételek",
"sourceCode": "Forráskód",
"timecodeInvalid": "Ez nem időpont — így írd: 1:05,5",
"timecodeInvalidHours": "Ez nem időpont — így írd: 1:02:05,5",
"close": "Bezárás",
"cancel": "Mégse",
"confirm": "Megerősítés",
@@ -69,5 +71,12 @@
"done": "Vágólapra másolva",
"linkDone": "Link a vágólapra másolva",
"failed": "Nem sikerült a vágólapra másolni"
},
"cropFrame": {
"frame": "Kivágókeret — a nyilakkal mozgatható, Shifttel nagyobbat lép",
"nw": "A kivágókeret bal felső sarka — a nyilakkal méretezhető",
"ne": "A kivágókeret jobb felső sarka — a nyilakkal méretezhető",
"sw": "A kivágókeret bal alsó sarka — a nyilakkal méretezhető",
"se": "A kivágókeret jobb alsó sarka — a nyilakkal méretezhető"
}
}
+7 -2
View File
@@ -243,10 +243,15 @@
"detailsToggle": "Technikai részletek",
"upload": {
"quota_exceeded": {
"short": "Nincs elég hely az átalakított fájlnak",
"body": "A feltöltés telefonra alakított változata nem fér bele a tárhelykeretedbe. Az eredeti megmaradt – törölj valamit a könyvtáradból, majd próbáld újra."
}
},
"edit": {
"quota_exceeded": {
"short": "Nincs elég hely a klipnek",
"body": "Ez a klip nem fér bele a megmaradt tárhelyedbe. Törölj valamit a könyvtáradból, vagy vágj rövidebb klipet, majd próbáld újra."
},
"unknown": {
"short": "Nem sikerült elkészíteni a klipet",
"body": "Hiba történt a klip készítése közben. Az alábbi technikai részletek segíthetnek."
@@ -302,8 +307,8 @@
"body": "Valami hiba történt ennél a letöltésnél. Az alábbi technikai részletek segíthetnek."
},
"quota_exceeded": {
"short": "Betelt a tárhely",
"body": "Ez a letöltés leállt, mert a tárhelykereted már elfogyott. Törölj valamit a könyvtáradból, aztán próbáld újra."
"short": "Nincs elég tárhely",
"body": "Ez a letöltés nem fér bele a megmaradt tárhelyedbe. Törölj valamit a könyvtáradból, aztán próbáld újra."
}
},
"catalog": {
+4 -2
View File
@@ -37,7 +37,9 @@
"dropped": "Eldobva",
"keptSeg": "Megtartott szegmens",
"droppedSeg": "Eldobott szegmens",
"removeCut": "Vágás törlése",
"removeCut": "{{n}}. vágás törlése",
"keptSummary": "{{n}} megtartva · {{dur}}",
"createJoin": "Összefűzött klip"
"createJoin": "Összefűzött klip",
"timeline": "Idővonal",
"cutN": "{{n}}. vágás"
}
+3 -1
View File
@@ -38,5 +38,7 @@
"cap": {
"space": "Szóköz",
"menu": "Menü"
}
},
"clip": "Klip mód",
"clipMark": "A klip eleje / vége itt"
}
+1 -1
View File
@@ -45,7 +45,7 @@
"undoAction": "Feloldás",
"title": "Felfüggeszted a fiókot?",
"undoTitle": "Feloldod a felfüggesztést?",
"confirm": "Felfüggeszted: {{email}}? Nem fog tudni belépni (sem jelszóval, sem Google-lel), amíg fel nem oldod, és az aktív munkamenete megszűnik.",
"confirm": "Felfüggeszted: {{email}}? Nem fog tudni belépni (sem jelszóval, sem Google-lel), amíg fel nem oldod, és az aktív munkamenete megszűnik. A sorban álló letöltései addig várnak.",
"undoConfirm": "Feloldod a felfüggesztést: {{email}}? Újra be tud majd lépni.",
"done": "{{email}} felfüggesztve",
"undone": "{{email}} felfüggesztése feloldva",
+47 -4
View File
@@ -31,6 +31,11 @@
--color-fg: var(--fg);
--color-muted: var(--muted);
--color-accent: var(--accent);
/* A focus ring's colour: the accent, unless a scope over another ground names its own. */
--color-focus: var(--focus, var(--accent));
/* What marks a choice standing on its ground (a chosen row's tick, a chosen tab's underline): the
accent, unless a scope over another ground names its own. */
--color-chosen: var(--chosen, var(--accent));
--color-accent-fg: var(--accent-fg);
--color-info: var(--info);
--color-info-fg: var(--info-fg);
@@ -45,6 +50,7 @@
--color-live: var(--live);
--color-live-fg: var(--live-fg);
--color-media-chrome: var(--media-chrome);
--color-media-mark: var(--media-mark);
--font-sans: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
@@ -142,6 +148,35 @@
border-color: var(--edge-strong);
}
/* The marks on the always-dark player chrome (a player, its menus and panels) — the focus ring and
what is chosen — are the media amber, as a light theme's accent is dark there (Slate 2.84:1).
Every class string that paints bg-media-chrome names it or media-scope (theme.contract.test.ts). */
@utility media-marks {
--focus: var(--media-mark);
--chosen: var(--media-mark);
}
/* A form on the always-dark player chrome (the clip panel's fields): the neutral tokens the field
primitives read are re-pointed to white-on-chrome for this subtree, so a light theme's dark text
never lands on the dark bar. The accent stays the theme's for a choice FILLED with it (its own
text on it holds); a mark standing on the chrome itself is not left to it: a focus ring and a
chosen mark are the media amber and an error the media red, as a light theme's accent and danger
are dark (under 3:1 on the chrome). */
@utility media-scope {
--fg: #ffffff;
--muted: rgb(255 255 255 / 0.62);
--bg: var(--media-chrome);
--border: rgb(255 255 255 / 0.16);
--edge-strong: rgb(255 255 255 / 0.4);
/* A card on the chrome (a segmented control's track): a veil of it, never the theme's card — a
light theme's white under this scope's white text left the unchosen options unreadable. */
--card: rgb(255 255 255 / 0.08);
--danger: var(--media-danger);
--focus: var(--media-mark);
--chosen: var(--media-mark);
color: var(--fg);
}
/* The reading measure for prose (a synopsis, a biography, a channel's description): ~65 characters
a line, inside the 45–75 a reader follows back to the next line's start. A full-width page ran
one to 110 (owner: "shorter lines are easier to follow"). Only where the container can
@@ -280,9 +315,11 @@
a:focus-visible,
[role="button"]:focus-visible,
[role="tab"]:focus-visible,
[role="slider"]:focus-visible,
summary:focus-visible {
/* Solid accent: the earlier 60% mix all but vanished on the dark glass. */
outline: 2px solid var(--accent);
/* Solid accent: the earlier 60% mix all but vanished on the dark glass. A scope over another
ground (media-scope) may name its own ring colour in --focus. */
outline: 2px solid var(--focus, var(--accent));
outline-offset: 2px;
}
@@ -1138,6 +1175,11 @@ html[data-theme="light"] {
--live-fg: #ffffff;
--rating-media: #fcd34d; /* a score on a poster's black scrim */
--media-chrome: #171717; /* the always-dark player's menus and bars, under white text */
/* A place marked on that chrome or over a video (a playhead, a focused end's ring, the crop
frame's ring, an intro / credits marker): amber in both themes, as the chrome is dark in both. */
--media-mark: #fbbf24;
/* An error said on that chrome (a time field the clip panel cannot read): its edge and its line. */
--media-danger: #f87171;
}
/* Toast auto-dismiss countdown bar (shrinks left-to-right to zero) */
@@ -1191,9 +1233,10 @@ html[data-theme="light"] {
}
/* …and it only fills the viewport while no ancestor has a filter: a `backdrop-filter` makes that
ancestor the fixed stage's containing block. The modal's card is a blurred `.glass` panel,
so while the stage is maximised the card drops its blur — the stage covers the card anyway. `html `
so while the stage is maximised the card drops its blur — the stage covers the card anyway. Any
depth: the stage sits in a wrapper with the clip panel. `html `
outranks `html[data-theme="dark"] .glass`, which sets the blur again (lib/playerStage.contract.test.ts). */
html .glass:has(> .player-stage--max) {
html .glass:has(.player-stage--max) {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
+34
View File
@@ -245,6 +245,40 @@ describe("req — a structured refusal reads in the user's language", () => {
expect(err.detail).toBeUndefined();
});
it.each([
["too_long", "clip.errors.too_long"],
["bad_crop", "clip.errors.bad_crop"],
])("says why a clip was not cut (%s)", async (reason, localized) => {
// It read as the generic "couldn't carry out that action" — a 10-minute cap no retry gets past.
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ detail: { code: "clip", reason } }), {
status: 422,
headers: { "content-type": "application/json" },
}),
);
await expect(
req("/api/downloads/clip", { method: "POST" }, { quiet: true }),
).rejects.toMatchObject({ detail: localized });
});
it("states the clip cap the server refused with, not a copy of it", async () => {
// The cap is the server's (downloads/clip.py MAX_CLIP_S): a message with "10 minutes" baked in
// goes stale the day it moves.
const i18n = (await import("../../i18n")).default;
const t = vi.spyOn(i18n, "t");
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ detail: { code: "clip", reason: "too_long", limit_s: 300 } }), {
status: 422,
headers: { "content-type": "application/json" },
}),
);
await req("/api/downloads/clip", { method: "POST" }, { quiet: true }).catch(() => undefined);
expect(t).toHaveBeenCalledWith(
"clip.errors.too_long",
expect.objectContaining({ minutes: "5" }),
);
});
it.each(["quota", "reconnect", "unavailable"])(
"says what to do about an action YouTube refused (%s)",
async (reason) => {
+8 -1
View File
@@ -81,7 +81,7 @@ function localizeDetail(d: unknown): string {
// "check your input" message rather than leaking them into a translated sentence.
if (Array.isArray(d)) return t("errors.validation");
const o = d as
| { code?: string; reason?: string; limit?: number; min?: number }
| { code?: string; reason?: string; limit?: number; limit_s?: number; min?: number }
| null
| undefined;
if (o?.code === "quota") {
@@ -94,6 +94,13 @@ function localizeDetail(d: unknown): string {
}
if (o?.code === "edit")
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
// A clip the server will not cut (downloads/clip): the range, the frame, the source. An unknown
// reason reads as the plain failure rather than a key; a cap comes with the refusal (`limit_s`).
if (o?.code === "clip")
return t(`clip.errors.${o.reason}`, {
defaultValue: t("clip.failed"),
minutes: o.limit_s == null ? undefined : formatDecimal(o.limit_s / 60, i18n.language, 1, 0),
});
if (o?.code === "cookies")
return t(`downloads.errors.cookies.${o.reason}`, t("downloads.errors.cookies.generic"));
// Password sign-in refusals. The card renders the account-shaped ones as a full panel
+22 -5
View File
@@ -44,10 +44,22 @@ 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";
// An ad-hoc clip (backend downloads/clip): a marked range of a YouTube video or a Plex item.
export interface ClipRequest {
source: "youtube" | "plex";
ref: string;
start_s: number;
end_s: number;
audio_track?: number;
/** A crop frame: fractions of the box it was drawn in, and that box's shape (downloads/clip). */
crop?: { x: number; y: number; w: number; h: number; aspect: number };
display_name?: string;
}
// 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).
@@ -91,7 +103,8 @@ export interface DownloadJob {
extra_links: string[]; // extra reference URLs (beyond source_url)
job_kind?: DownloadJobKind; // see DownloadJobKind; absent reads as "download"
source_job_id?: number | null; // parent download an edit was cut from
edit_spec?: EditSpec | null;
// An edit's recipe, or a clip's spec (its request without the name) — by job_kind.
edit_spec?: EditSpec | Omit<ClipRequest, "display_name"> | null;
can_download: boolean;
expired: boolean;
asset: DownloadAsset | null;
@@ -226,6 +239,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,35 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// The limits a clip is marked to, held in two languages: the clip panel's handles, fields and keys
// keep a range the server takes, and a crop frame no smaller than it cuts. Neither side can see the
// other's copy, so the gate reads both (as `api/downloads.contract.test.ts` does for the name bound).
// A cap moved on one side alone makes the panel offer what the server refuses — or clamp below
// what it would take — with every other test green.
//
// Read as TEXT: the modules are plain constants, but reading the source is what makes a renamed or
// moved constant fail here instead of comparing two undefineds.
const src = (u: string) => readFileSync(new URL(u, import.meta.url), "utf-8");
const backend = src("../../../backend/app/downloads/clip.py");
function pyConst(name: string): number {
const m = backend.match(new RegExp(`^${name}\\s*=\\s*([\\d.]+)`, "m"));
expect(m, `backend/app/downloads/clip.py no longer defines ${name}`).toBeTruthy();
return Number(m![1]);
}
function tsConst(file: string, name: string): number {
const m = src(file).match(new RegExp(`^(?:export )?const ${name} = ([\\d.]+);`, "m"));
expect(m, `${file} no longer defines ${name}`).toBeTruthy();
return Number(m![1]);
}
describe("clip limits", () => {
it.each([
["./clipRange.ts", "MIN_CLIP_S"],
["./clipRange.ts", "MAX_CLIP_S"],
["./cropGeometry.ts", "MIN_CROP"],
])("%s %s is the server's", (file, name) => {
expect(tsConst(file, name)).toBe(pyConst(name));
});
});
+65
View File
@@ -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 });
});
});
+66
View File
@@ -0,0 +1,66 @@
// 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;
// clipLimits.contract.test.ts holds the two copies equal).
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) };
}
/** The `[` key: In at the playhead `t`, opening a range there when none is marked yet. */
export function markInAt(r: ClipRange | null, t: number, duration: number): ClipRange {
return markIn(r ?? openAt(t, duration), t, duration);
}
/** The `]` key: Out at the playhead `t`; with no range yet, one that ENDS there. */
export function markOutAt(r: ClipRange | null, t: number, duration: number): ClipRange {
return markOut(r ?? openAt(Math.max(0, t - DEFAULT_CLIP_S), duration), t, duration);
}
+73 -1
View File
@@ -1,5 +1,14 @@
import { describe, expect, it } from "vitest";
import { contentRect, cropToSourcePx } from "./cropGeometry";
import {
contentRect,
cropToSourcePx,
fitRatio,
fracRatio,
MIN_CROP,
moveCrop,
resizeCrop,
type Frac,
} from "./cropGeometry";
describe("contentRect (display-px letterbox mapping)", () => {
it("returns the full box when box aspect matches the video aspect", () => {
@@ -86,3 +95,66 @@ describe("cropToSourcePx (fraction-of-content → even source px)", () => {
expect(cropToSourcePx({ x: 0, y: 0, w: 1, h: 1 }, 1920, 0)).toBeNull();
});
});
const near = (r: Frac, want: Frac) => {
for (const k of ["x", "y", "w", "h"] as const) expect(r[k], k).toBeCloseTo(want[k], 6);
};
// The pixel shape of a frame on a 16:9 picture.
const shape = (r: Frac) => (r.w * 16) / 9 / r.h;
const inside = (r: Frac) => {
expect(r.x).toBeGreaterThanOrEqual(-1e-9);
expect(r.y).toBeGreaterThanOrEqual(-1e-9);
expect(r.x + r.w).toBeLessThanOrEqual(1 + 1e-9);
expect(r.y + r.h).toBeLessThanOrEqual(1 + 1e-9);
};
describe("the crop frame a user draws", () => {
const r: Frac = { x: 0.2, y: 0.2, w: 0.4, h: 0.4 };
it("moves whole, stopping at the picture's edge", () => {
near(moveCrop(r, 0.1, -0.05), { x: 0.3, y: 0.15, w: 0.4, h: 0.4 });
near(moveCrop(r, 0.9, -0.9), { x: 0.6, y: 0, w: 0.4, h: 0.4 });
});
it("resizes free from an edge or a corner, the opposite side staying put", () => {
near(resizeCrop(r, "e", 0.1, 0.3, null), { x: 0.2, y: 0.2, w: 0.5, h: 0.4 });
near(resizeCrop(r, "nw", -0.1, -0.05, null), { x: 0.1, y: 0.15, w: 0.5, h: 0.45 });
// past the picture, and past the opposite edge: stopped at the edge and at the floor
near(resizeCrop(r, "se", 0.9, 0.9, null), { x: 0.2, y: 0.2, w: 0.8, h: 0.8 });
near(resizeCrop(r, "w", 0.9, 0, null), { x: 0.6 - MIN_CROP, y: 0.2, w: MIN_CROP, h: 0.4 });
});
it("keeps a locked shape from a corner, following the side the pointer changed more", () => {
const ratio = fracRatio(9 / 16, 16 / 9);
const start = fitRatio(r, ratio);
const grown = resizeCrop(start, "se", 0, -0.2, ratio);
expect(shape(grown)).toBeCloseTo(9 / 16, 6);
inside(grown);
// a mostly sideways drag inward shrinks it (a "larger of the two" rule never would)
const narrowed = resizeCrop({ x: 0.3, y: 0.1, w: 0.2, h: 0.6 }, "ne", -0.08, 0.01, 0.75);
expect(narrowed.w).toBeCloseTo(0.12, 6);
expect(narrowed.h).toBeCloseTo(0.16, 6);
expect(narrowed.y + narrowed.h).toBeCloseTo(0.7, 6); // the anchored corner (sw) stays
});
it("keeps a locked shape from an edge, growing the other side about the middle and in bounds", () => {
const ratio = fracRatio(1, 16 / 9);
const sq = { x: 0.4, y: 0.4, w: 0.2 * ratio, h: 0.2 };
const out = resizeCrop(sq, "e", 0.5, 0, ratio);
expect(shape(out)).toBeCloseTo(1, 6);
expect(out.y + out.h / 2).toBeCloseTo(0.5, 6);
inside(out);
const tall = resizeCrop(sq, "s", 0, 0.9, ratio);
expect(shape(tall)).toBeCloseTo(1, 6);
inside(tall);
});
it("fits a chosen shape as large as the picture allows, centred on the frame", () => {
const tall = fitRatio({ x: 0.7, y: 0.1, w: 0.2, h: 0.2 }, fracRatio(9 / 16, 16 / 9));
expect(tall.h).toBeCloseTo(1, 6);
expect(shape(tall)).toBeCloseTo(9 / 16, 6);
expect(tall.x + tall.w / 2).toBeCloseTo(0.8, 6);
const wide = fitRatio({ x: 0, y: 0, w: 1, h: 1 }, fracRatio(21 / 9, 16 / 9));
near(wide, { x: 0, y: (1 - (16 / 9) * (9 / 21)) / 2, w: 1, h: (16 / 9) * (9 / 21) });
});
});
+85
View File
@@ -46,3 +46,88 @@ export function cropToSourcePx(crop: Frac, srcW: number, srcH: number): CropPx |
const y = Math.min(evenOff(crop.y * srcH), srcH - h);
return { x, y, w, h };
}
// --- the frame a user draws: moved, resized, fitted to a shape ---------------------------------------
//
// All in fractions of the picture (0..1 each way), so a frame means the same thing whatever size the
// picture is shown at. A shape lock is the frame's width / height IN THOSE FRACTIONS: a 9:16 output
// on a 16:9 picture is (9/16) / (16/9) — `fracRatio` turns one into the other.
/** The smallest a frame gets on either side: the server's floor (downloads/clip), held equal by
* clipLimits.contract.test.ts. */
export const MIN_CROP = 0.1;
export const WHOLE: Frac = { x: 0, y: 0, w: 1, h: 1 };
export type CropHandle = "n" | "s" | "e" | "w" | "nw" | "ne" | "sw" | "se";
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
/** An output shape (width / height in pixels) as a ratio of picture fractions. */
export function fracRatio(shape: number, pictureAspect: number): number {
return shape / pictureAspect;
}
/** The frame moved by (dx, dy), kept whole inside the picture. */
export function moveCrop(r: Frac, dx: number, dy: number): Frac {
return { ...r, x: clamp(r.x + dx, 0, 1 - r.w), y: clamp(r.y + dy, 0, 1 - r.h) };
}
/** The frame with one edge or corner moved by (dx, dy); the opposite one stays put. Under a lock
* (`ratio`, see fracRatio) a corner follows whichever side the pointer changed more, and an edge
* grows the other side about the frame's middle. Never under MIN_CROP, never out of the picture. */
export function resizeCrop(
r: Frac,
handle: CropHandle,
dx: number,
dy: number,
ratio: number | null,
): Frac {
const sx = handle.includes("e") ? 1 : handle.includes("w") ? -1 : 0;
const sy = handle.includes("s") ? 1 : handle.includes("n") ? -1 : 0;
const right = r.x + r.w;
const bottom = r.y + r.h;
// The free move first: each moved edge, stopped at the picture and at the floor.
const l = sx < 0 ? clamp(r.x + dx, 0, right - MIN_CROP) : r.x;
const rt = sx > 0 ? clamp(right + dx, r.x + MIN_CROP, 1) : right;
const t = sy < 0 ? clamp(r.y + dy, 0, bottom - MIN_CROP) : r.y;
const b = sy > 0 ? clamp(bottom + dy, r.y + MIN_CROP, 1) : bottom;
if (ratio == null) return { x: l, y: t, w: rt - l, h: b - t };
// Locked: one width, the height from it, then the room the anchor leaves on both axes.
const floor = Math.max(MIN_CROP, MIN_CROP * ratio);
if (sx !== 0 && sy !== 0) {
const ax = sx > 0 ? r.x : right;
const ay = sy > 0 ? r.y : bottom;
const byW = rt - l;
const byH = (b - t) * ratio;
const want = Math.abs(byW - r.w) >= Math.abs(byH - r.h * ratio) ? byW : byH;
const room = Math.min(sx > 0 ? 1 - ax : ax, (sy > 0 ? 1 - ay : ay) * ratio);
const w = Math.min(Math.max(want, floor), room);
const h = w / ratio;
return { x: sx > 0 ? ax : ax - w, y: sy > 0 ? ay : ay - h, w, h };
}
if (sx !== 0) {
const ax = sx > 0 ? r.x : right;
const cy = r.y + r.h / 2;
const room = Math.min(sx > 0 ? 1 - ax : ax, 2 * Math.min(cy, 1 - cy) * ratio);
const w = Math.min(Math.max(rt - l, floor), room);
const h = w / ratio;
return { x: sx > 0 ? ax : ax - w, y: cy - h / 2, w, h };
}
const ay = sy > 0 ? r.y : bottom;
const cx = r.x + r.w / 2;
const room = Math.min(sy > 0 ? 1 - ay : ay, (2 * Math.min(cx, 1 - cx)) / ratio);
const h = Math.min(Math.max(b - t, floor / ratio), room);
const w = h * ratio;
return { x: cx - w / 2, y: sy > 0 ? ay : ay - h, w, h };
}
/** The largest frame of `ratio` (see fracRatio) the picture holds, centred where `r` is centred as
* far as the picture lets it be. */
export function fitRatio(r: Frac, ratio: number): Frac {
const w = ratio >= 1 ? 1 : ratio;
const h = w / ratio;
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
return { x: clamp(cx - w / 2, 0, 1 - w), y: clamp(cy - h / 2, 0, 1 - h), w, h };
}
+1
View File
@@ -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", () => {
+1 -1
View File
@@ -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",
};
}
+48
View File
@@ -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"]) {
@@ -80,6 +81,44 @@ describe("failTitleKey", () => {
expect(i18n.t(failCopyKeys(download, "body"))).toBe(
i18n.t("downloads.errors.fail.cat.quota_exceeded.body"),
);
// Its headline too, worded for the converted file rather than the download it is not.
const short = i18n.t(failCopyKeys(upload, "short"));
expect(short).not.toMatch(/^downloads\./);
expect(short).not.toBe(i18n.t("downloads.errors.fail.cat.quota_exceeded.short"));
}
});
it("a download refused for storage says it does not fit, true with space left or none", async () => {
// Refused at a full cap before it starts, or by yt-dlp for a file larger than what is left: one
// sentence true of both, so never "full" or "used up".
const download = {
error_code: "quota_exceeded",
error_detail: null,
job_kind: "download",
} as const;
for (const lng of ["en", "hu"]) {
await i18n.changeLanguage(lng);
for (const part of ["short", "body"] as const) {
const text = i18n.t(failCopyKeys(download, part));
expect(text).not.toMatch(/^downloads\./);
expect(text).not.toMatch(/full|used up|betelt|elfogyott/i);
}
}
});
it("a clip refused for storage says so of a clip, and not that the quota is used up", async () => {
// Refused before it is cut (nothing left) or after (the cut is bigger than what is left): one
// sentence true of both, worded for a clip and naming the clip-side way out.
for (const job_kind of ["clip", "edit"] as const) {
const clip = { error_code: "quota_exceeded", error_detail: null, job_kind } as const;
for (const lng of ["en", "hu"]) {
await i18n.changeLanguage(lng);
for (const part of ["short", "body"] as const) {
const own = i18n.t(failCopyKeys(clip, part));
expect(own).not.toMatch(/^downloads\./);
expect(own).not.toBe(i18n.t(`downloads.errors.fail.cat.quota_exceeded.${part}`));
}
}
}
});
@@ -106,6 +145,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"]) {
+3 -1
View File
@@ -13,7 +13,8 @@ export function failCopyKeys(
job: Pick<DownloadJob, "error_code" | "error_detail" | "job_kind">,
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<DownloadJobKind, string> = {
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. */
+26
View File
@@ -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);
+17
View File
@@ -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;
@@ -39,7 +39,7 @@ function mountPlayer(setPlayerError: (code: number | null) => void) {
setPlayerError,
setLiveData: () => {},
reapplyStored: () => {},
focusModal: () => {},
reclaimFocus: () => {},
advanceOnEnd: () => {},
sessionPosRef: { current: new Map() },
qc: new QueryClient(),
@@ -81,7 +81,7 @@ function mountPlayer(qc: QueryClient, sessionPos: Map<string, number>) {
setPlayerError: () => {},
setLiveData: () => {},
reapplyStored: () => {},
focusModal: () => {},
reclaimFocus: () => {},
advanceOnEnd: () => {},
sessionPosRef: { current: sessionPos },
qc,
+15 -15
View File
@@ -81,11 +81,11 @@ function loadYouTubeApi(): Promise<YTNamespace> {
export function useYtVolume({
playerRef,
dialogRef,
focusModal,
reclaimFocus,
}: {
playerRef: RefObject<YTPlayer | null>;
dialogRef: RefObject<HTMLDivElement | null>;
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<YTPlayer | null>;
stageRef: RefObject<HTMLDivElement | null>;
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<HTMLDivElement | null>;
playerRef: RefObject<YTPlayer | null>;
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

Some files were not shown because too many files have changed in this diff Show More