Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
891507dc75 | ||
|
|
e6894ac0fc | ||
|
|
663a7e8012 | ||
|
|
93192dadd8 | ||
|
|
5b48018e92 | ||
|
|
8312c6d592 | ||
|
|
e1d325b942 | ||
|
|
977ba244fa | ||
|
|
89d18b46a6 | ||
|
|
c27cfdcb9c | ||
|
|
a867145bcb | ||
|
|
4d4eea2c54 | ||
|
|
c489b60c8b | ||
|
|
b4bf71ea64 | ||
|
|
72df11f2c0 | ||
|
|
0a16bc40ae | ||
|
|
520b0afb90 | ||
|
|
e301632765 | ||
|
|
50157cc2fd | ||
|
|
f221c1962a | ||
|
|
d466ae915b | ||
|
|
14598ec320 | ||
|
|
b16cf7668c | ||
|
|
3438aeb695 |
@@ -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).
|
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
|
## 0.104.0 — 2026-10-10
|
||||||
|
|
||||||
### New
|
### New
|
||||||
|
|||||||
@@ -437,14 +437,6 @@
|
|||||||
"lineCount": 1
|
"lineCount": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 12,
|
|
||||||
"endColumn": 13,
|
|
||||||
"lineCount": 10
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"code": "reportAttributeAccessIssue",
|
"code": "reportAttributeAccessIssue",
|
||||||
"range": {
|
"range": {
|
||||||
@@ -1439,14 +1431,6 @@
|
|||||||
"lineCount": 1
|
"lineCount": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 38,
|
|
||||||
"endColumn": 43,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"code": "reportArgumentType",
|
"code": "reportArgumentType",
|
||||||
"range": {
|
"range": {
|
||||||
|
|||||||
@@ -181,15 +181,18 @@ class Settings(BaseSettings):
|
|||||||
# for YouTube Proof-of-Origin tokens (bypasses bot-detection + unlocks high-quality
|
# 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").
|
# 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"
|
download_pot_base_url: str = "http://bgutil-pot:4416"
|
||||||
# yt-dlp player clients (comma-separated). PRIMARY = android_vr: high-quality DASH, no PO
|
# yt-dlp player clients (comma-separated). PRIMARY = tv_simply: measured 2026-10-10 as the one
|
||||||
# token / JS runtime needed, reliable for normal use. On a bot/DRM/format failure (YouTube's
|
# client that is offered 1080p+ AND finishes the fetch, its stream token minted by the bgutil
|
||||||
# per-client responses are flaky, and android_vr can get bot-flagged under heavy load) the
|
# sidecar. android_vr (the primary until then) was still offered 2160p, but every fetch died
|
||||||
# worker retries with the FALLBACK set — the POT-capable web clients (web needs the token +
|
# with HTTP 403 — it now needs a stream PO token the sidecar cannot mint — and the fallback
|
||||||
# Deno) plus android for audio. Both admin-tunable when YouTube shifts what works.
|
# 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
|
# 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.
|
# of these two sets — yt-dlp drops the rest, so android_vr / android / ios never run there.
|
||||||
# See adapters.YouTubeAdapter._COOKIE_CAPABLE.
|
# 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"
|
download_player_clients_fallback: str = "web_safari,web,android"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -106,7 +106,8 @@ class YouTubeAdapter(SourceAdapter):
|
|||||||
# `player_client=android_vr` yt-dlp logs `Skipping client "android_vr" since it does not support
|
# `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
|
# 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
|
# 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
|
# 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[...]
|
# 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
|
# ["SUPPORTS_COOKIES"], pinned by test_adapters against the installed yt-dlp, so a version bump
|
||||||
|
|||||||
@@ -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),
|
||||||
|
]
|
||||||
@@ -30,6 +30,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import logs
|
from app import logs
|
||||||
|
from app.downloads import clip as clipmod
|
||||||
from app.downloads import formats
|
from app.downloads import formats
|
||||||
from app.downloads.adapters import _host_is
|
from app.downloads.adapters import _host_is
|
||||||
from app.models import DownloadCredential, MediaAsset
|
from app.models import DownloadCredential, MediaAsset
|
||||||
@@ -259,6 +260,28 @@ def usable_cookie(
|
|||||||
return None
|
return None
|
||||||
if not is_authed_asset(asset, spec, user_id, url):
|
if not is_authed_asset(asset, spec, user_id, url):
|
||||||
return None # shared asset — never attach a cookie to a non-private identity
|
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)
|
raw = decrypt_optional(cred.cookies_enc)
|
||||||
if not raw:
|
if not raw:
|
||||||
log.warning(
|
log.warning(
|
||||||
@@ -284,6 +307,14 @@ def resolve_cookiefile(
|
|||||||
return _write_cookiefile(raw, staging) if raw else None
|
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:
|
def _write_cookiefile(raw: str, staging: Path) -> Path:
|
||||||
"""Write the cookie blob to a private 0600 file under `staging/.auth/` and return its path.
|
"""Write the cookie blob to a private 0600 file under `staging/.auth/` and return its path.
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
# 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
|
# QUOTA_FULL_MESSAGE and the three *_TOO_BIG_MESSAGEs are raised by the worker and must classify
|
||||||
# below matches a fragment of it, and test_download_errors pins the pair, because a message and its
|
# back to `quota_exceeded`; their rules below match a fragment of each, and test_download_errors pins
|
||||||
# rule drifting apart is exactly how a category silently becomes unreachable.
|
# 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
|
# 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
|
# 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
|
# constants, imported above; each has a rule below that gives its category back, and a catalog entry
|
||||||
# that gives the site's sentence.
|
# that gives the site's sentence.
|
||||||
QUOTA_FULL_MESSAGE = "Your download storage is full — free up space, then try again."
|
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."
|
UPLOAD_MISSING_MESSAGE = "The uploaded file is missing."
|
||||||
NETWORK_REDACTED_MESSAGE = "The server could not reach that address."
|
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
|
# 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.
|
# first — it is our wording, so it can never be a false match on an upstream message.
|
||||||
("download storage is full", "quota_exceeded"),
|
("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.
|
# Our own redaction of a transport failure (see NETWORK_REDACTED_MESSAGE) — our wording too.
|
||||||
(NETWORK_REDACTED_MESSAGE.lower(), "network"),
|
(NETWORK_REDACTED_MESSAGE.lower(), "network"),
|
||||||
# Our own verdicts on a post link (adapters.py) — our wording too: its page holds no single video,
|
# Our own verdicts on a post link (adapters.py) — our wording too: its page holds no single video,
|
||||||
|
|||||||
@@ -105,6 +105,28 @@ def log_safe(text: object) -> str:
|
|||||||
return _PATH_RE.sub("…", text)
|
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:
|
class _YdlLog:
|
||||||
"""yt-dlp's logger protocol, pointed at ours.
|
"""yt-dlp's logger protocol, pointed at ours.
|
||||||
|
|
||||||
@@ -124,6 +146,8 @@ class _YdlLog:
|
|||||||
is the one line whose level should say so."""
|
is the one line whose level should say so."""
|
||||||
|
|
||||||
def _emit(self, level: int, msg: str) -> None:
|
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
|
# 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
|
# `[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
|
# 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`
|
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.
|
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
|
`max_bytes` (SEC-13) is the caller's remaining storage budget; yt-dlp's HTTP downloader refuses
|
||||||
than it rather than filling the disk and only then being caught by the quota accounting."""
|
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)
|
s = normalize(spec)
|
||||||
h = s["max_height"]
|
h = s["max_height"]
|
||||||
opts: dict = {
|
opts: dict = {
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ from datetime import date, datetime, timedelta, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
|
from yt_dlp.utils import download_range_func
|
||||||
|
|
||||||
from app import logs, sysconfig
|
from app import logs, sysconfig
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
|
from app.downloads import clip as clipmod
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import errors as dlerrors
|
from app.downloads import errors as dlerrors
|
||||||
from app.downloads import adapters, credentials, formats, mediakind, playable, probe, quota
|
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.stated import StatedFailure
|
||||||
from app.downloads.status import JobStatus
|
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.notifications import create_notification
|
||||||
from app.titles import normalize_title
|
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."""
|
"""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):
|
class _OwnerRefused(Exception):
|
||||||
"""A refusal that belongs to THIS job's owner, not to the source.
|
"""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:
|
def cancelled() -> bool:
|
||||||
if not state["cancelled"] and allow():
|
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 state["cancelled"]
|
||||||
|
|
||||||
return cancelled
|
return cancelled
|
||||||
@@ -333,7 +346,7 @@ def _media_info(info: dict) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _extract_with_fallback(
|
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
|
"""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
|
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.
|
download as itself.
|
||||||
|
|
||||||
`cookiefile` is the acting user's per-host cookies.txt, already gated by
|
`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)
|
client_sets = adapter.client_sets(authed=cookiefile is not None)
|
||||||
outtmpl = str(staging / "%(id)s.%(ext)s")
|
outtmpl = str(staging / "%(id)s.%(ext)s")
|
||||||
# Resolving may read a page first — network time the stall watchdog must not see as a stall.
|
# 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,
|
max_bytes=max_bytes,
|
||||||
)
|
)
|
||||||
opts["postprocessor_hooks"] = [_make_pp_hook(job_id)]
|
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 /
|
_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.
|
# 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
|
# (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
|
# 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
|
# 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
|
# 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
|
# to yt-dlp as `max_filesize` makes it refuse an oversized file (its HTTP downloader
|
||||||
# front instead.
|
# checks the announced size) instead.
|
||||||
remaining_bytes = quota.remaining_bytes(cdb, user_id)
|
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
|
# …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
|
# 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.
|
# free space, which is what the `quota_exceeded` category tells them.
|
||||||
if remaining_bytes == 0:
|
if remaining_bytes == 0:
|
||||||
raise _OwnerRefused(dlerrors.QUOTA_FULL_MESSAGE)
|
raise _OwnerRefused(dlerrors.QUOTA_FULL_MESSAGE)
|
||||||
info = _media_info(
|
with formats.watching_size_cap() as over_cap:
|
||||||
_extract_with_fallback(
|
fetched = _extract_with_fallback(
|
||||||
job_id,
|
job_id,
|
||||||
asset_id,
|
asset_id,
|
||||||
url,
|
url,
|
||||||
@@ -523,7 +546,11 @@ def _download(
|
|||||||
cookiefile=str(cookiefile) if cookiefile else None,
|
cookiefile=str(cookiefile) if cookiefile else None,
|
||||||
max_bytes=remaining_bytes,
|
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
|
# 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.
|
# identifies post-extraction) so the .nfo uniqueid type + uploader_url agree on one source.
|
||||||
adapter = adapters.resolve_adapter(source_kind, source_ref, info)
|
adapter = adapters.resolve_adapter(source_kind, source_ref, info)
|
||||||
@@ -536,6 +563,13 @@ def _download(
|
|||||||
CONVERTING,
|
CONVERTING,
|
||||||
int(info["duration"]) if info.get("duration") else None,
|
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()
|
ext = produced.suffix.lstrip(".").lower()
|
||||||
# Codecs/dims/duration come from a PROBE of the produced file — the ground truth — NOT yt-dlp's
|
# 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
|
# `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(
|
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:
|
) -> None:
|
||||||
"""Fail a local ffmpeg job — an edit, or an upload's conversion (`what="upload"`) — and say so
|
"""Fail a local ffmpeg job — an edit, a clip, or an upload's conversion (`what="upload"`) — and
|
||||||
ONCE: every way one fails comes through here, so this is where its line and its event code
|
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.
|
(`<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`).
|
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."""
|
Logged after the write, as on the download error path."""
|
||||||
@@ -755,9 +796,12 @@ def _fail_edit(
|
|||||||
job_id,
|
job_id,
|
||||||
status=JobStatus.ERROR,
|
status=JobStatus.ERROR,
|
||||||
error=msg,
|
error=msg,
|
||||||
error_code=dlerrors.classify_conversion(msg)
|
error_code=diagnosis.code
|
||||||
|
if diagnosis
|
||||||
|
else dlerrors.classify_conversion(msg)
|
||||||
if what == "upload"
|
if what == "upload"
|
||||||
else dlerrors.classify(msg),
|
else dlerrors.classify(msg),
|
||||||
|
error_detail=diagnosis.detail if diagnosis else None,
|
||||||
phase=None,
|
phase=None,
|
||||||
)
|
)
|
||||||
db.commit()
|
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.
|
# 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)
|
dest_staging = _ensure_phone_safe(job_id, dest_staging, staging, "editing", total_s)
|
||||||
out_ext = dest_staging.suffix.lstrip(".").lower() or out_ext
|
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)
|
rel = storage.edit_rel_path(user_id, asset_id, out_ext)
|
||||||
storage.place_file(dest_staging, settings.download_root, rel)
|
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)
|
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:
|
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
|
"""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,
|
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
|
return
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
if not quota.fits_after_conversion(db, owner_id, old_size, new_size):
|
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:
|
def _unplace(placed: Path | None) -> None:
|
||||||
|
|||||||
@@ -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
|
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
|
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
|
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
|
from dataclasses import dataclass
|
||||||
@@ -24,7 +25,7 @@ from sqlalchemy import and_, func, or_, select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
from app.downloads.status import JobStatus
|
from app.downloads.status import JobKind, JobStatus
|
||||||
from app.models import DownloadJob, DownloadQuota, MediaAsset
|
from app.models import DownloadJob, DownloadQuota, MediaAsset
|
||||||
|
|
||||||
# Jobs a user is considered to be "holding" (occupy a job slot / footprint). Terminal failures
|
# 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
|
# "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
|
# 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
|
# 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(row.max_bytes, max(1, row.max_concurrent), row.max_jobs, row.unlimited)
|
||||||
return QuotaLimits(
|
return QuotaLimits(
|
||||||
max_bytes=sysconfig.get_int(db, "download_default_max_bytes"),
|
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,
|
DownloadJob.user_id == user_id,
|
||||||
or_(
|
or_(
|
||||||
DownloadJob.status.in_(_HOLDING),
|
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),
|
DownloadJob.asset_id.is_not(None),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from app.config import settings
|
|||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import formats
|
from app.downloads import formats
|
||||||
|
from app.downloads.status import JobKind
|
||||||
from app.models import DownloadJob
|
from app.models import DownloadJob
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
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`)."""
|
(`cause=child_exit`)."""
|
||||||
# Imported here (not at module top) so the child pays the yt-dlp import cost only when it has
|
# 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.
|
# 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):
|
with formats.hiding(source_ref):
|
||||||
try:
|
try:
|
||||||
if job_kind == "edit":
|
if job_kind == JobKind.EDIT:
|
||||||
_process_edit(job_id, asset_id)
|
_process_edit(job_id, asset_id)
|
||||||
elif job_kind == "upload":
|
elif job_kind == JobKind.UPLOAD:
|
||||||
_convert_upload(job_id, asset_id)
|
_convert_upload(job_id, asset_id)
|
||||||
|
elif job_kind == JobKind.CLIP:
|
||||||
|
_process_clip(job_id, asset_id)
|
||||||
else:
|
else:
|
||||||
_download(job_id, asset_id, source_kind, source_ref, spec, user_id)
|
_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
|
except Exception as exc: # noqa: BLE001 — whatever it is, it is reported here or raw
|
||||||
|
|||||||
@@ -16,11 +16,22 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.downloads import adapters
|
from app.downloads import adapters
|
||||||
|
from app.downloads import clip as clipmod
|
||||||
from app.downloads import credentials
|
from app.downloads import credentials
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import formats, mediakind, quota, storage
|
from app.downloads import formats, mediakind, quota, storage
|
||||||
from app.downloads.status import HOLDING, JobStatus
|
from app.downloads.status import HOLDING, JobKind, JobStatus
|
||||||
from app.models import DISPLAY_TEXT_MAX, Channel, DownloadJob, MediaAsset, Video
|
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):
|
class EditError(Exception):
|
||||||
@@ -40,10 +51,13 @@ def source_url(source_kind: str, source_ref: str) -> str:
|
|||||||
def reference_url(job, asset) -> str | None:
|
def reference_url(job, asset) -> str | None:
|
||||||
"""The clean "downloaded from" URL for a job — shared by the private Downloads page and the public
|
"""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
|
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
|
own earlier download, not a web page) and for a Plex clip (the library is not a public page); a
|
||||||
from source_kind+source_ref (covers queued/older rows before the worker fills it)."""
|
YouTube clip links its video at the clip's start; else the canonical page URL yt-dlp recorded; else
|
||||||
if job is None or job.job_kind == "edit":
|
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
|
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:
|
if asset is not None and asset.source_webpage_url:
|
||||||
return asset.source_webpage_url
|
return asset.source_webpage_url
|
||||||
if job.source_kind in ("youtube", "url"):
|
if job.source_kind in ("youtube", "url"):
|
||||||
@@ -180,7 +194,7 @@ def enqueue(
|
|||||||
profile_snapshot=spec,
|
profile_snapshot=spec,
|
||||||
display_name=display_name
|
display_name=display_name
|
||||||
or (asset.title[:DISPLAY_TEXT_MAX] if asset.status == "ready" and asset.title else None),
|
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),
|
queue_pos=next_queue_pos(db),
|
||||||
)
|
)
|
||||||
db.add(job)
|
db.add(job)
|
||||||
@@ -188,7 +202,7 @@ def enqueue(
|
|||||||
|
|
||||||
# Cache hit: the file already exists — no worker round-trip needed.
|
# Cache hit: the file already exists — no worker round-trip needed.
|
||||||
if asset.status == "ready":
|
if asset.status == "ready":
|
||||||
job.status = "done"
|
job.status = JobStatus.DONE
|
||||||
job.progress = 100
|
job.progress = 100
|
||||||
asset.last_access_at = datetime.now(timezone.utc)
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
@@ -197,7 +211,7 @@ def enqueue(
|
|||||||
return job
|
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
|
# 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
|
# `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.
|
# 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]
|
ref = f"{user_id}:{src_asset.id}"[:512]
|
||||||
asset = get_or_create_asset(db, "edit", ref, sig)
|
asset = get_or_create_asset(db, "edit", ref, sig)
|
||||||
if not asset.title:
|
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.uploader = src_asset.uploader
|
||||||
asset.thumbnail_url = src_asset.thumbnail_url
|
asset.thumbnail_url = src_asset.thumbnail_url
|
||||||
asset.upload_date = src_asset.upload_date
|
asset.upload_date = src_asset.upload_date
|
||||||
@@ -251,7 +265,7 @@ def enqueue_edit(
|
|||||||
job = DownloadJob(
|
job = DownloadJob(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
asset_id=asset.id,
|
asset_id=asset.id,
|
||||||
job_kind="edit",
|
job_kind=JobKind.EDIT,
|
||||||
source_kind="edit",
|
source_kind="edit",
|
||||||
source_ref=ref,
|
source_ref=ref,
|
||||||
source_job_id=source_job.id,
|
source_job_id=source_job.id,
|
||||||
@@ -259,14 +273,101 @@ def enqueue_edit(
|
|||||||
edit_spec=spec,
|
edit_spec=spec,
|
||||||
profile_snapshot={},
|
profile_snapshot={},
|
||||||
display_name=display_name or asset.title[:DISPLAY_TEXT_MAX],
|
display_name=display_name or asset.title[:DISPLAY_TEXT_MAX],
|
||||||
status="queued",
|
status=JobStatus.QUEUED,
|
||||||
queue_pos=next_queue_pos(db),
|
queue_pos=next_queue_pos(db),
|
||||||
)
|
)
|
||||||
db.add(job)
|
db.add(job)
|
||||||
incr_ref_count(db, asset.id)
|
incr_ref_count(db, asset.id)
|
||||||
|
|
||||||
if asset.status == "ready": # identical clip already produced for this user → instant
|
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
|
job.progress = 100
|
||||||
asset.last_access_at = datetime.now(timezone.utc)
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,23 @@ HOLDING = frozenset(
|
|||||||
{JobStatus.QUEUED, JobStatus.RUNNING, JobStatus.PAUSED, JobStatus.DONE, JobStatus.ERROR}
|
{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
|
# 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.
|
# 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})
|
STOPPED_BY_ROUTE = frozenset({JobStatus.PAUSED, JobStatus.CANCELED})
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import mediakind, playable, probe, quota, service, storage
|
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
|
from app.models import DownloadJob, MediaAsset, display_text
|
||||||
|
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ def ingest_upload(
|
|||||||
job = DownloadJob(
|
job = DownloadJob(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
asset_id=asset.id,
|
asset_id=asset.id,
|
||||||
job_kind="upload",
|
job_kind=JobKind.UPLOAD,
|
||||||
source_kind="upload",
|
source_kind="upload",
|
||||||
source_ref=ref,
|
source_ref=ref,
|
||||||
profile_snapshot={},
|
profile_snapshot={},
|
||||||
|
|||||||
@@ -123,7 +123,8 @@ class User(Base, TimestampMixin, CanonicalEmailMixin):
|
|||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
||||||
# Admin-imposed access block, distinct from is_active (approval lifecycle). A suspended
|
# 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
|
# 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")
|
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
|
# 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;
|
# 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_kind: Mapped[str] = mapped_column(String(16)) # "youtube" | "url"
|
||||||
source_ref: Mapped[str] = mapped_column(String(512))
|
source_ref: Mapped[str] = mapped_column(String(512))
|
||||||
# "download" (default), "edit" (a per-user trim/crop derivative of another job) or "upload" (a
|
# "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`).
|
# 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")
|
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).
|
# 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(
|
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(
|
source_asset_id: Mapped[int | None] = mapped_column(
|
||||||
ForeignKey("media_assets.id", ondelete="SET NULL")
|
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)
|
edit_spec: Mapped[dict | None] = mapped_column(JSON)
|
||||||
profile_id: Mapped[int | None] = mapped_column(
|
profile_id: Mapped[int | None] = mapped_column(
|
||||||
ForeignKey("download_profiles.id", ondelete="SET NULL")
|
ForeignKey("download_profiles.id", ondelete="SET NULL")
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ from app.config import settings
|
|||||||
from app.downloads.edit import encode_thread_cap
|
from app.downloads.edit import encode_thread_cap
|
||||||
from app.models import PlexItem
|
from app.models import PlexItem
|
||||||
from app.plex import paths
|
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__)
|
log = logs.get_logger(__name__)
|
||||||
_HLS_ROOT = (
|
_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
|
# 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
|
# (_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.
|
# 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).
|
# The three source classes that drive the transcode-pipeline choice (see _probe_video_kind).
|
||||||
|
|||||||
@@ -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
|
# outside it (AV1, MPEG-4 ASP/XviD…) is software-only even on the iGPU (it can't decode it to feed its
|
||||||
# own encoder).
|
# own encoder).
|
||||||
HW_DECODABLE_CODECS = frozenset({"h264", "hevc", "h265", "vp9", "vc1", "mpeg2video"})
|
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
|
# Color-transfer values marking an HDR source (PQ / HLG). THE single source of this set: plex.stream
|
||||||
# scale=nv12 path on Gen 9.5 (see plex.stream) → software transcode, so it counts as NOT HW-capable.
|
# (the tonemap choice) and downloads.clip (a clip's tonemap) import it here, so a transfer added for
|
||||||
_HDR_TRANSFERS = frozenset({"smpte2084", "arib-std-b67"})
|
# 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(
|
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.
|
# whole library would drown the sync's own lines.
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
depth = None
|
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 depth, hdr
|
||||||
return None, False
|
return None, False
|
||||||
|
|
||||||
|
|||||||
@@ -484,7 +484,8 @@ def set_user_suspended(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Suspend or un-suspend an account. A suspended user can't sign in by any method and any
|
"""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
|
un-suspend the user is emailed they can return (suspend is announced reactively, on a blocked
|
||||||
sign-in)."""
|
sign-in)."""
|
||||||
suspended = body.suspended
|
suspended = body.suspended
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ state, serves finished files (range-aware, with the user's custom display name),
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from typing import Literal
|
||||||
from collections.abc import AsyncIterator, Sequence
|
from collections.abc import AsyncIterator, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
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 import APIRouter, Depends, HTTPException, Request
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import delete, func, select
|
from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -27,13 +28,14 @@ from app import audit, sysconfig
|
|||||||
from app.auth import admin_user, current_user
|
from app.auth import admin_user, current_user
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import get_db, release_read_connection
|
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 credentials as dlcreds
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import links as linksmod
|
from app.downloads import links as linksmod
|
||||||
from app.downloads import quota, service, storage
|
from app.downloads import quota, service, storage
|
||||||
from app.downloads import upload as uploadmod
|
from app.downloads import upload as uploadmod
|
||||||
from app.downloads import urlguard
|
from app.downloads import urlguard
|
||||||
from app.downloads.status import JobStatus
|
from app.downloads.status import NOT_REFETCHABLE, JobStatus
|
||||||
from app.models import (
|
from app.models import (
|
||||||
DownloadJob,
|
DownloadJob,
|
||||||
DownloadLink,
|
DownloadLink,
|
||||||
@@ -576,6 +578,49 @@ def enqueue_edit(
|
|||||||
return _serialize_job(db, job)
|
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:
|
def _quota_detail(e: quota.QuotaExceeded) -> dict:
|
||||||
"""Structured error the trilingual client localizes (reason key + numbers), instead of a
|
"""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()."""
|
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
|
never deletes a file another job/user still shares: the old asset is only removed if THIS job
|
||||||
was its last holder."""
|
was its last holder."""
|
||||||
job = _own_job_for_update(db, user, job_id)
|
job = _own_job_for_update(db, user, job_id)
|
||||||
if job.job_kind in ("edit", "upload"):
|
if job.job_kind in NOT_REFETCHABLE:
|
||||||
# Neither has a re-fetchable source: an editor clip is derived locally, an upload IS the
|
# None 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.
|
# source, an ad-hoc clip is a range of a source the clip itself does not re-fetch.
|
||||||
raise refused(409, "not_redownloadable")
|
raise refused(409, "not_redownloadable")
|
||||||
if job.status in (JobStatus.QUEUED, JobStatus.RUNNING, JobStatus.PAUSED):
|
if job.status in (JobStatus.QUEUED, JobStatus.RUNNING, JobStatus.PAUSED):
|
||||||
raise refused(409, "still_downloading")
|
raise refused(409, "still_downloading")
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from app.db import get_db
|
|||||||
from app.downloads import links as linksmod
|
from app.downloads import links as linksmod
|
||||||
from app.downloads import storage
|
from app.downloads import storage
|
||||||
from app.downloads.status import JobStatus
|
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.ratelimit import RateLimiter, client_key
|
||||||
from app.security import verify_password
|
from app.security import verify_password
|
||||||
from app.refusal import refused
|
from app.refusal import refused
|
||||||
@@ -44,6 +44,17 @@ def _link_or_404(db: Session, token: str) -> DownloadLink:
|
|||||||
return link
|
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]:
|
def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAsset]:
|
||||||
job = db.get(DownloadJob, link.job_id)
|
job = db.get(DownloadJob, link.job_id)
|
||||||
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
|
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
|
job is not None
|
||||||
and asset is not None
|
and asset is not None
|
||||||
and asset.status in ("pending", "downloading")
|
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:
|
# 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".
|
# its own reason, so the page can say "try again shortly" rather than "this link is gone".
|
||||||
|
|||||||
+39
-11
@@ -29,6 +29,7 @@ from datetime import datetime, timezone
|
|||||||
from functools import partial
|
from functools import partial
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import logs
|
from app import logs
|
||||||
from app import logsink
|
from app import logsink
|
||||||
@@ -44,7 +45,7 @@ from app.downloads.jobdb import (
|
|||||||
_reset_asset_pending,
|
_reset_asset_pending,
|
||||||
_staging_dir,
|
_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
|
from app.models import DISPLAY_TEXT_MAX, DownloadJob, MediaAsset
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
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);
|
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
|
`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
|
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:
|
with SessionLocal() as db:
|
||||||
default_mc = sysconfig.get_int(db, "download_default_max_concurrent")
|
default_mc = sysconfig.get_int(db, "download_default_max_concurrent")
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
@@ -170,6 +176,10 @@ def _claim_job() -> int | None:
|
|||||||
"""
|
"""
|
||||||
SELECT id, user_id FROM download_jobs
|
SELECT id, user_id FROM download_jobs
|
||||||
WHERE status='queued'
|
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 (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM media_assets a
|
SELECT 1 FROM media_assets a
|
||||||
WHERE a.id = download_jobs.asset_id AND a.status = 'downloading'
|
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:
|
if status is None or status in STOPPED_BY_ROUTE:
|
||||||
reason = "cancelled" # a route stopped it (or the row vanished)
|
reason = "cancelled" # a route stopped it (or the row vanished)
|
||||||
break
|
break
|
||||||
if status == "running":
|
if status == JobStatus.RUNNING:
|
||||||
if stale is not None and stale > stall_s:
|
if stale is not None and stale > stall_s:
|
||||||
reason = "stalled"
|
reason = "stalled"
|
||||||
break
|
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
|
# 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").
|
# done/error) just gets its lingering child killed, its DB status left intact ("abandon").
|
||||||
if time.monotonic() - started > wall_s:
|
if time.monotonic() - started > wall_s:
|
||||||
reason = "timeout" if status == "running" else "abandon"
|
reason = "timeout" if status == JobStatus.RUNNING else "abandon"
|
||||||
break
|
break
|
||||||
# else: a terminal status under the cap (done/error, or a re-queued contended asset) means
|
# 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
|
# 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)
|
_reset_asset_pending(asset_id)
|
||||||
if proc.returncode != _CHILD_OK:
|
if proc.returncode != _CHILD_OK:
|
||||||
status, _ = _job_liveness(job_id)
|
status, _ = _job_liveness(job_id)
|
||||||
if status == "running":
|
if status == JobStatus.RUNNING:
|
||||||
_cleanup_staging(asset_id)
|
_cleanup_staging(asset_id)
|
||||||
_fail_running(
|
_fail_running(
|
||||||
job_id,
|
job_id,
|
||||||
@@ -560,15 +570,12 @@ def _process_job(job_id: int) -> None:
|
|||||||
asset_id = asset.id if asset else None
|
asset_id = asset.id if asset else None
|
||||||
asset_status = asset.status if asset else None
|
asset_status = asset.status if asset else None
|
||||||
asset_error = asset.error 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
|
# 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
|
# "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
|
# 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.
|
# 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)
|
url, cookie = _failed_link_and_cookie(db, job, asset)
|
||||||
cookie = credentials.usable_cookie(
|
|
||||||
db, job.user_id, url, asset, formats.normalize(job.profile_snapshot)
|
|
||||||
)
|
|
||||||
diagnosis = dlerrors.diagnose(asset_error, url=url, cookie_attached=cookie is not None)
|
diagnosis = dlerrors.diagnose(asset_error, url=url, cookie_attached=cookie is not None)
|
||||||
del cookie # only whether there is one matters here
|
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)
|
_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:
|
def _finish_from_cache(job_id: int, asset_id: int) -> None:
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
job = db.get(DownloadJob, job_id)
|
job = db.get(DownloadJob, job_id)
|
||||||
asset = db.get(MediaAsset, asset_id)
|
asset = db.get(MediaAsset, asset_id)
|
||||||
if job and asset:
|
if job and asset:
|
||||||
fields = {"status": JobStatus.DONE, "progress": 100, "phase": None}
|
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).
|
# 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]
|
fields["display_name"] = asset.title[:DISPLAY_TEXT_MAX]
|
||||||
asset.last_access_at = datetime.now(timezone.utc)
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
# Source of truth is requirements.txt (the floor ranges); regenerate after editing it with:
|
# 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
|
# 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)
|
# 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
|
# scans it, blocking. A security fix, or a yt-dlp release YouTube's changes force, may be ONE pin
|
||||||
# regeneration, which would move every other package too — then `pip install -r requirements.lock && pip check` must
|
# bumped in place instead of a full regeneration, which would move every other package too — then
|
||||||
# pass. In place so far: urllib3 2.7.0 -> 2.8.0 (PYSEC-2026-4175/4176/4177).
|
# `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
|
APScheduler==3.11.3
|
||||||
Authlib==1.7.2
|
Authlib==1.7.2
|
||||||
Mako==1.4.1
|
Mako==1.4.1
|
||||||
@@ -62,4 +63,4 @@ uvloop==0.22.1
|
|||||||
watchfiles==1.2.0
|
watchfiles==1.2.0
|
||||||
websockets==17.0.1
|
websockets==17.0.1
|
||||||
yt-dlp-ejs==0.8.0
|
yt-dlp-ejs==0.8.0
|
||||||
yt-dlp==2026.7.4
|
yt-dlp==2026.8.19
|
||||||
|
|||||||
@@ -16,12 +16,14 @@ argon2-cffi>=23.1,<26
|
|||||||
# Download center: yt-dlp does the extraction/download; ffmpeg (OS-level in the Dockerfile)
|
# 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
|
# 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
|
# 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.
|
# keep the floor loose and bump it when extraction breaks. 2026.8.19 marks the clients that need a
|
||||||
yt-dlp[default]>=2025.5.22
|
# 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
|
# 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
|
# 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
|
# (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 reports every target "unavailable" outside it. Move it when bumping
|
# 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`
|
# 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.
|
# fails the gate if the locked pair can no longer impersonate.
|
||||||
curl_cffi>=0.10,<0.16
|
curl_cffi>=0.10,<0.16
|
||||||
|
|||||||
@@ -175,6 +175,21 @@ def test_the_cookie_capable_clients_match_the_installed_ytdlp():
|
|||||||
assert set(YouTubeAdapter._AUTHED_DEFAULT) <= live
|
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():
|
def test_youtube_ydl_opts_extra_carries_player_client_and_pot():
|
||||||
ea = YouTubeAdapter().ydl_opts_extra(["android_vr"])["extractor_args"]
|
ea = YouTubeAdapter().ydl_opts_extra(["android_vr"])["extractor_args"]
|
||||||
assert ea["youtube"]["player_client"] == ["android_vr"]
|
assert ea["youtube"]["player_client"] == ["android_vr"]
|
||||||
|
|||||||
@@ -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)")
|
||||||
@@ -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
|
# 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".
|
# 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.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():
|
def test_the_redacted_network_text_classifies_back_to_network():
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ its OWN session, so every test COMMITS its setup before calling it.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from app.config import settings
|
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.models import DownloadJob, DownloadQuota, MediaAsset, User
|
||||||
from app.worker import _claim_job
|
from app.worker import _claim_job
|
||||||
|
|
||||||
@@ -155,6 +156,25 @@ def test_unlimited_user_is_never_excluded(db):
|
|||||||
assert _claim_job() == q.id
|
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 --------------------------------------------------------------------------
|
# --- #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).status == "pending"
|
||||||
assert db.get(MediaAsset, shared.id).error is None
|
assert db.get(MediaAsset, shared.id).error is None
|
||||||
assert db.get(DownloadJob, theirs.id).status == "queued"
|
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)
|
||||||
|
|||||||
@@ -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"))
|
||||||
@@ -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.
|
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."""
|
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 edit as editmod
|
||||||
from app.downloads import formats
|
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)
|
current = formats.format_sig(audio)
|
||||||
monkeypatch.setattr(formats, "_AUDIO_SIG_VERSION", 4)
|
monkeypatch.setattr(formats, "_AUDIO_SIG_VERSION", 4)
|
||||||
assert formats.format_sig(audio) != current
|
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()
|
||||||
|
|||||||
@@ -73,10 +73,17 @@ def test_every_path_to_ready_runs_the_rule():
|
|||||||
found.append(where)
|
found.append(where)
|
||||||
if not runs_rule:
|
if not runs_rule:
|
||||||
skipped.append(where)
|
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.
|
# 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}
|
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}"
|
assert skipped == [], f"marks an asset ready without the phone-safe rule: {skipped}"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
def test_a_long_display_name_is_truncated_on_EVERY_route_that_takes_one(
|
||||||
db, as_user, make_user, _root, monkeypatch, upload_headers
|
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
|
`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
|
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_name"] == bound
|
||||||
assert patched.json()["display_uploader"] == 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]]:
|
def _display_write_sites() -> dict[tuple[str, str], set[str]]:
|
||||||
"""Every place under `backend/app/` that puts a value into a display field, as
|
"""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.
|
(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
|
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
|
`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] =
|
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
|
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
|
t.id if isinstance(t, ast.Name) else t.attr
|
||||||
for t in node.targets
|
for t in node.targets
|
||||||
if isinstance(t, (ast.Name, ast.Attribute))
|
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
|
value = node.value
|
||||||
elif isinstance(node, ast.keyword):
|
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:
|
# Bounded by `display_text` unless noted:
|
||||||
# service.redownload CLEARS the field so the fresh metadata wins — `None` is the
|
# 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.
|
# 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
|
# service.enqueue / enqueue_edit / `display_name` is already bounded by its caller; the fallback
|
||||||
# slices the YouTube title to the same width.
|
# enqueue_clip slices the source's title to the same width.
|
||||||
# downloads.upload_download passes through `spool_upload`'s value, bounded there.
|
# 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
|
# 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
|
# 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)"
|
"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_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)"},
|
("downloads/upload.py", "ingest_upload"): {"display_text(display_name)"},
|
||||||
("routes/auth.py", "callback"): {"display_text(userinfo.get('name'))"},
|
("routes/auth.py", "callback"): {"display_text(userinfo.get('name'))"},
|
||||||
("routes/auth.py", "_complete_link"): {"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_download"): {"display_text(body.display_name)"},
|
||||||
("routes/downloads.py", "enqueue_edit"): {"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"): {
|
("routes/downloads.py", "update_download_meta"): {
|
||||||
"display_text(body.display_name)",
|
"display_text(body.display_name)",
|
||||||
"display_text(body.display_uploader)",
|
"display_text(body.display_uploader)",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import pytest
|
|||||||
|
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.downloads import errors as dlerrors
|
||||||
from app.downloads import gc as gcmod
|
from app.downloads import gc as gcmod
|
||||||
from app.downloads import pipeline, quota, storage
|
from app.downloads import pipeline, quota, storage
|
||||||
from app.downloads import probe as probemod
|
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()
|
db.expire_all()
|
||||||
job = db.get(DownloadJob, job.id)
|
job = db.get(DownloadJob, job.id)
|
||||||
assert (job.status, job.error_code) == ("error", "quota_exceeded")
|
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 original.exists()
|
||||||
assert [p.name for p in _uploads_dir(_root, u.id).iterdir()] == [original.name]
|
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
|
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):
|
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`).
|
# The same disk the owner's footprint and the GC's cap count (`quota.ON_DISK`).
|
||||||
_probe(monkeypatch)
|
_probe(monkeypatch)
|
||||||
|
|||||||
+45
-1
@@ -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,
|
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
|
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
|
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
|
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
|
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.
|
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
|
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
|
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]
|
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)
|
## 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 | 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-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-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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 ------------------------------------------------------------------------
|
// --- the fake IFrame API ------------------------------------------------------------------------
|
||||||
const clock = { current: 0, duration: 3000 };
|
const clock = { current: 0, duration: 3000 };
|
||||||
const started: { videoId: string; start: number | undefined }[] = [];
|
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 = {
|
const base = {
|
||||||
getCurrentTime: () => clock.current,
|
getCurrentTime: () => clock.current,
|
||||||
getDuration: () => clock.duration,
|
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),
|
get: (t, k) => (k in t ? t[k as keyof typeof t] : k === "then" ? undefined : () => undefined),
|
||||||
});
|
});
|
||||||
window.YT = {
|
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 });
|
started.push({ videoId: opts.videoId, start: opts.playerVars.start });
|
||||||
|
playerEvents = opts.events ?? {};
|
||||||
return fakePlayer;
|
return fakePlayer;
|
||||||
} as never,
|
} 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", () => {
|
it("keeps Tab inside the player", () => {
|
||||||
open();
|
open();
|
||||||
const dialog = screen.getByTestId("player-modal");
|
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.next" })).toHaveLength(1);
|
||||||
expect(screen.getAllByRole("button", { name: "player.previous" })).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/);
|
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 (
|
vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation(function (
|
||||||
this: HTMLElement,
|
this: HTMLElement,
|
||||||
) {
|
) {
|
||||||
return this.querySelector(":scope > .player-stage") ? 630 : 0;
|
return this.querySelector(":scope > * > .player-stage") ? 630 : 0;
|
||||||
});
|
});
|
||||||
const A = vid("A", 0);
|
const A = vid("A", 0);
|
||||||
const B = vid("B", 0);
|
const B = vid("B", 0);
|
||||||
renderModal(
|
renderModal(
|
||||||
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
<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))");
|
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={() => {}} />,
|
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||||
);
|
);
|
||||||
const stage = document.querySelector<HTMLElement>(".player-stage")!;
|
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");
|
const info = screen.getByTestId("player-info");
|
||||||
// The card is a row: the video, then the panel holding the title and the channel.
|
// The card is a row: the video, then the panel holding the title and the channel.
|
||||||
expect(card.className).toMatch(/(^|\s)flex(\s|$)/);
|
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={() => {}} />,
|
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||||
);
|
);
|
||||||
const row = () =>
|
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" }));
|
fireEvent.click(screen.getByRole("button", { name: "player.collapsePanel" }));
|
||||||
expect(screen.queryByTestId("player-info")).toBeNull();
|
expect(screen.queryByTestId("player-info")).toBeNull();
|
||||||
const strip = screen.getByTestId("player-strip");
|
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)");
|
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 () => {
|
it("gives a panel with something to scroll the wheel for that alone, the volume elsewhere", async () => {
|
||||||
wideWindow(true);
|
wideWindow(true);
|
||||||
vi.spyOn(api, "videoDetail").mockResolvedValue({
|
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={() => {}} />,
|
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||||
);
|
);
|
||||||
const stage = document.querySelector<HTMLElement>(".player-stage")!;
|
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, "scrollHeight", { value: 900, configurable: true });
|
||||||
Object.defineProperty(card, "clientHeight", { value: 500, configurable: true });
|
Object.defineProperty(card, "clientHeight", { value: 500, configurable: true });
|
||||||
const spin = (el: Element) => {
|
const spin = (el: Element) => {
|
||||||
@@ -778,3 +879,156 @@ describe("PlayerModal — the docked bar's fullscreen", () => {
|
|||||||
expect(maximised(false)).toBe(1);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
Minimize2,
|
Minimize2,
|
||||||
PanelRightClose,
|
PanelRightClose,
|
||||||
PanelRightOpen,
|
PanelRightOpen,
|
||||||
|
Scissors,
|
||||||
Settings,
|
Settings,
|
||||||
SkipBack,
|
SkipBack,
|
||||||
SkipForward,
|
SkipForward,
|
||||||
@@ -50,6 +51,8 @@ import {
|
|||||||
} from "../lib/layerStack";
|
} from "../lib/layerStack";
|
||||||
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
|
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
|
||||||
import DownloadButton from "./DownloadButton";
|
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 { api, type Video, type VideoStatus } from "../lib/api";
|
||||||
import type { YTPlayer } from "../lib/youtube";
|
import type { YTPlayer } from "../lib/youtube";
|
||||||
import {
|
import {
|
||||||
@@ -242,6 +245,22 @@ export default function PlayerModal({
|
|||||||
// useYtNativeControls, useYtBigView) where a fresh identity each render would spread into their
|
// useYtNativeControls, useYtBigView) where a fresh identity each render would spread into their
|
||||||
// dep lists too.
|
// dep lists too.
|
||||||
const focusModal = useCallback(() => cardRef.current?.focus(), []);
|
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
|
// 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
|
// 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
|
// 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
|
// 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).
|
// fresh player, which starts with the panel open (owner).
|
||||||
const [panelShut, setPanelShut] = useState(false);
|
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
|
// 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.
|
// goes to its counterpart, not to <body>, where the player's keys would no longer reach it.
|
||||||
const panelToggleRef = useRef<HTMLButtonElement>(null);
|
const panelToggleRef = useRef<HTMLButtonElement>(null);
|
||||||
@@ -262,6 +285,7 @@ export default function PlayerModal({
|
|||||||
const togglePanel = (shut: boolean) => {
|
const togglePanel = (shut: boolean) => {
|
||||||
refocusToggle.current = true;
|
refocusToggle.current = true;
|
||||||
setPanelShut(shut);
|
setPanelShut(shut);
|
||||||
|
setShutForClip(false);
|
||||||
};
|
};
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!refocusToggle.current) return;
|
if (!refocusToggle.current) return;
|
||||||
@@ -300,12 +324,12 @@ export default function PlayerModal({
|
|||||||
const { volumeUi, reapplyStored, nudgeVolume } = useYtVolume({
|
const { volumeUi, reapplyStored, nudgeVolume } = useYtVolume({
|
||||||
playerRef,
|
playerRef,
|
||||||
dialogRef,
|
dialogRef,
|
||||||
focusModal,
|
reclaimFocus,
|
||||||
});
|
});
|
||||||
const { nativeControls, setNativeControls } = useNativeControlsYield({
|
const { nativeControls, setNativeControls } = useNativeControlsYield({
|
||||||
playerRef,
|
playerRef,
|
||||||
stageRef,
|
stageRef,
|
||||||
focusModal,
|
reclaimFocus,
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
isFullscreen,
|
isFullscreen,
|
||||||
@@ -315,13 +339,146 @@ export default function PlayerModal({
|
|||||||
toggleTrueFullscreen,
|
toggleTrueFullscreen,
|
||||||
exitBigView,
|
exitBigView,
|
||||||
setMaximized,
|
setMaximized,
|
||||||
} = useYtBigView({ stageRef, playerRef, focusModal });
|
} = useYtBigView({ stageRef, playerRef, reclaimFocus });
|
||||||
|
|
||||||
// The player can navigate to other videos (YouTube links in a description). The
|
// The player can navigate to other videos (YouTube links in a description). The
|
||||||
// currently-playing id may differ from the active item.
|
// currently-playing id may differ from the active item.
|
||||||
const [currentVideoId, setCurrentVideoId] = useState(active.id);
|
const [currentVideoId, setCurrentVideoId] = useState(active.id);
|
||||||
const currentIdRef = useRef(active.id);
|
const currentIdRef = useRef(active.id);
|
||||||
const navigated = currentVideoId !== 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).
|
// 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);
|
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.
|
// 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
|
// 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.
|
// list repeats). Uses the live queue + settings via refs so it's correct from the bound handler.
|
||||||
const advanceOnEnd = () => {
|
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;
|
const { autoMode: a, loopMode: l } = modeRef.current;
|
||||||
if (l === "one") return replayCurrent();
|
if (l === "one") return replayCurrent();
|
||||||
const { queue: q, index: i } = navRef.current;
|
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`
|
// 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.
|
// 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
|
// 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
|
// 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
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
dispatch(e, "ytPlayer", {
|
dispatch(e, "ytPlayer", {
|
||||||
|
clip: () => {
|
||||||
|
if (clip) closeClip();
|
||||||
|
else openClip();
|
||||||
|
},
|
||||||
|
markIn: () => markClip(markInAt),
|
||||||
|
markOut: () => markClip(markOutAt),
|
||||||
close: (ev) => {
|
close: (ev) => {
|
||||||
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an
|
// 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
|
// 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.
|
// stack synchronously, so `anyLayerAbove` alone would miss it and close the player too.
|
||||||
if (escapeHandled(ev)) return;
|
if (escapeHandled(ev)) return;
|
||||||
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) 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.
|
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
|
||||||
if (fullscreenElement()) return;
|
if (fullscreenElement()) return;
|
||||||
// Same for our own maximise: Esc steps back out of it before it closes the player.
|
// 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,
|
setPlayerError,
|
||||||
setLiveData,
|
setLiveData,
|
||||||
reapplyStored,
|
reapplyStored,
|
||||||
focusModal,
|
reclaimFocus,
|
||||||
advanceOnEnd,
|
advanceOnEnd,
|
||||||
sessionPosRef,
|
sessionPosRef,
|
||||||
qc,
|
qc,
|
||||||
@@ -668,6 +842,29 @@ export default function PlayerModal({
|
|||||||
onLoadVideo: loadVideo,
|
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.
|
// 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 fitWidth = `(100dvh - 3rem - ${below}px) * 16 / 9`;
|
||||||
const panelWidth = panelShut ? PANEL_STRIP : SIDE_PANEL;
|
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).
|
// 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,
|
// 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
|
// 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
|
// 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
|
// (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={{
|
style={{
|
||||||
maxWidth: wide
|
maxWidth: wide
|
||||||
? `calc(${fitWidth} + ${panelWidth})`
|
? `calc(${clipOpen ? `max(${MIN_VIDEO}, ${fitWidth})` : fitWidth} + ${panelWidth})`
|
||||||
: `min(56rem, max(${MIN_VIDEO}, ${fitWidth}))`,
|
: `min(56rem, max(${MIN_VIDEO}, ${fitWidth}))`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={cardRef}
|
ref={cardRef}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
// Stacked, the card is what scrolls (its details under the video): the wheel's, while it can.
|
// Stacked (and wide in clip mode), the card is what scrolls (what sits under the video): the
|
||||||
data-wheel-scrolls={wide ? undefined : ""}
|
// 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 ${
|
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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div
|
{/* The video and, in clip mode, the clip panel under it. Always this wrapper, so opening the
|
||||||
ref={stageRef}
|
panel never moves the stage to another parent (that would re-create the YouTube iframe). */}
|
||||||
// The wheel over the video is its volume, even inside a stacked card that scrolls.
|
<div className={wide ? "flex-1 min-w-0 self-start" : undefined}>
|
||||||
data-wheel-volume=""
|
<div
|
||||||
className={`player-stage relative aspect-video w-full bg-black overflow-hidden ${
|
ref={stageRef}
|
||||||
wide ? "flex-1 min-w-0 self-start rounded-l-2xl" : "rounded-t-2xl"
|
// The wheel over the video is its volume, even inside a stacked card that scrolls.
|
||||||
} ${maximized ? "player-stage--max" : ""}`}
|
data-wheel-volume=""
|
||||||
>
|
className={`player-stage media-marks relative aspect-video w-full bg-black overflow-hidden ${
|
||||||
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed
|
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. */}
|
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`:
|
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),
|
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. */}
|
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
|
<div
|
||||||
ref={mountRef}
|
className={
|
||||||
className={`w-full h-full ${playerError != null ? "invisible" : ""}`}
|
maximized || isFullscreen
|
||||||
/>
|
? `absolute inset-x-0 top-0 ${clipDocked ? "" : "bottom-14"}`
|
||||||
{/* Interaction layer over the CENTRE of the video: catches click (play/pause) + wheel
|
: "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
|
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.
|
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,
|
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
|
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.
|
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. */}
|
Hidden on error so the "Open on YouTube" CTA is clickable. */}
|
||||||
{playerError == null &&
|
{playerError == null &&
|
||||||
YT_CLICK_LAYER.map((box, i) => (
|
YT_CLICK_LAYER.map((box, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
data-testid="yt-click-layer"
|
data-testid="yt-click-layer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
focusModal();
|
focusModal();
|
||||||
togglePlay();
|
togglePlay();
|
||||||
}}
|
}}
|
||||||
title={t("player.shortcutsHint")}
|
title={t("player.shortcutsHint")}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
style={box}
|
style={box}
|
||||||
className={`absolute z-base cursor-pointer ${
|
className={`absolute z-base cursor-pointer ${
|
||||||
nativeControls ? "pointer-events-none" : ""
|
nativeControls ? "pointer-events-none" : ""
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
{/* The clip's crop frame, over the picture (this box is the iframe's). Pointers pass
|
||||||
{/* Our own control bar for the big views (maximise / our fullscreen), where the card's
|
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
|
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
|
<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
|
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;
|
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:
|
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. */}
|
add-to-playlist's popover and download's Modal portal INTO the fullscreen element. */}
|
||||||
{isFullscreen && <Toaster overlay order={overlayOrder} />}
|
{isFullscreen && <Toaster overlay order={overlayOrder} />}
|
||||||
{(maximized || isFullscreen) && (
|
{clipDocked && (
|
||||||
<div
|
<div ref={clipDockRef} className="absolute inset-x-0 bottom-0 z-menu" />
|
||||||
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"
|
{(maximized || isFullscreen) && !clipDocked && (
|
||||||
>
|
<div
|
||||||
{hasQueue && (
|
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"
|
||||||
<PlayerTransportControls
|
>
|
||||||
variant="docked"
|
{hasQueue && (
|
||||||
index={index}
|
<>
|
||||||
queueLength={queue!.length}
|
<PlayerTransportControls
|
||||||
autoMode={autoMode}
|
variant="docked"
|
||||||
loopMode={loopMode}
|
index={index}
|
||||||
onPrev={goPrev}
|
queueLength={queue!.length}
|
||||||
onNext={goNext}
|
autoMode={autoMode}
|
||||||
onCycleAuto={cycleAuto}
|
loopMode={loopMode}
|
||||||
onCycleLoop={cycleLoop}
|
onPrev={goPrev}
|
||||||
/>
|
onNext={goNext}
|
||||||
<span className="h-4 w-px bg-white/20" />
|
onCycleAuto={cycleAuto}
|
||||||
</>
|
onCycleLoop={cycleLoop}
|
||||||
)}
|
/>
|
||||||
{/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both
|
<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
|
portal into the fullscreen element rather than <body> (a body-portaled node is
|
||||||
painted underneath the element holding the lock, i.e. invisible). */}
|
painted underneath the element holding the lock, i.e. invisible). */}
|
||||||
{!navigated && <AddToPlaylist videoId={active.id} variant="overlay" />}
|
{!navigated && <AddToPlaylist videoId={active.id} variant="overlay" />}
|
||||||
{!navigated && (
|
{!navigated && (
|
||||||
<DownloadButton videoId={active.id} title={active.title} variant="overlay" />
|
<DownloadButton videoId={active.id} title={active.title} variant="overlay" />
|
||||||
)}
|
)}
|
||||||
{!navigated && (
|
{/* 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
|
<Button
|
||||||
variant="overlay"
|
variant="overlay"
|
||||||
size="sm"
|
size="sm"
|
||||||
pressed={watched}
|
onClick={(e) => {
|
||||||
onClick={() => setWatched(!watched)}
|
e.stopPropagation();
|
||||||
label={watched ? t("player.watchedUnmark") : t("player.markWatched")}
|
exitBigView();
|
||||||
icon={watched ? <CheckCheck /> : <Check />}
|
setNativeControls(false);
|
||||||
|
focusModal();
|
||||||
|
}}
|
||||||
|
label={t("player.exitMaximize")}
|
||||||
|
icon={<Minimize2 />}
|
||||||
>
|
>
|
||||||
<span className="hidden md:inline">
|
<span className="hidden sm:inline">{t("player.exitMaximize")}</span>
|
||||||
{watched ? t("player.watched") : t("player.markWatched")}
|
|
||||||
</span>
|
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
<span className="w-7 text-right tabular-nums">{volumeUi}</span>
|
)}
|
||||||
</div>
|
{/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */}
|
||||||
)}
|
{playerError == null && nativeControls && (
|
||||||
{playerError != null && (
|
<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">
|
||||||
<div className="absolute inset-0 grid place-items-center bg-bg p-6 text-center">
|
<Settings className="h-3.5 w-3.5" />
|
||||||
<div className="max-w-sm">
|
{t("player.nativeControls")}
|
||||||
<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>
|
)}
|
||||||
)}
|
{/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */}
|
||||||
</div>
|
{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
|
{/* 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. */}
|
description never makes the card taller than the video. */}
|
||||||
<div
|
<div
|
||||||
@@ -1184,6 +1415,19 @@ export default function PlayerModal({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="md"
|
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 && (
|
{onRemoveFromList && (
|
||||||
<IconButton
|
<IconButton
|
||||||
variant="outline-danger"
|
variant="outline-danger"
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
import PlexPlayer from "./PlexPlayer";
|
import PlexPlayer from "./PlexPlayer";
|
||||||
import { api, type PlexItemDetail } from "../lib/api";
|
import { api, type PlexItemDetail } from "../lib/api";
|
||||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
||||||
|
import { pushLayer, removeLayer } from "../lib/layerStack";
|
||||||
|
|
||||||
type Media = HTMLMediaElement & { _t?: number; _paused?: boolean };
|
type Media = HTMLMediaElement & { _t?: number; _paused?: boolean };
|
||||||
|
|
||||||
@@ -128,6 +129,7 @@ function mount(opts: {
|
|||||||
queue?: string[];
|
queue?: string[];
|
||||||
first?: string;
|
first?: string;
|
||||||
fromStart?: boolean;
|
fromStart?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
}) {
|
}) {
|
||||||
vi.spyOn(api, "plexItem").mockImplementation((id: string) => Promise.resolve(opts.details[id]!));
|
vi.spyOn(api, "plexItem").mockImplementation((id: string) => Promise.resolve(opts.details[id]!));
|
||||||
const session = vi
|
const session = vi
|
||||||
@@ -139,7 +141,12 @@ function mount(opts: {
|
|||||||
const first = opts.first ?? opts.queue?.[0] ?? Object.keys(opts.details)[0]!;
|
const first = opts.first ?? opts.queue?.[0] ?? Object.keys(opts.details)[0]!;
|
||||||
render(
|
render(
|
||||||
<QueryClientProvider client={qc}>
|
<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>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
return { session };
|
return { session };
|
||||||
@@ -927,3 +934,167 @@ describe("the fullscreen button", () => {
|
|||||||
delete (HTMLElement.prototype as { requestFullscreen?: unknown }).requestFullscreen;
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
|
Scissors,
|
||||||
Settings,
|
Settings,
|
||||||
SkipBack,
|
SkipBack,
|
||||||
SkipForward,
|
SkipForward,
|
||||||
@@ -48,6 +49,8 @@ import {
|
|||||||
type PlexPlayerPrefs,
|
type PlexPlayerPrefs,
|
||||||
} from "../lib/plexPlayerPrefs";
|
} from "../lib/plexPlayerPrefs";
|
||||||
import { Ctrl, PanelHead } from "./plexPlayerControls";
|
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 { PlexPlayerSettings } from "./PlexPlayerSettings";
|
||||||
import { PlexStatsOverlay } from "./PlexStatsOverlay";
|
import { PlexStatsOverlay } from "./PlexStatsOverlay";
|
||||||
import { createFragMeter } from "../lib/plexStats";
|
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).
|
// 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.
|
// The player's menus sit on the always-dark media chrome, under white text, in every theme.
|
||||||
const PLAYER_MENU =
|
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";
|
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
|
// A track choice: white like the rest, the chosen one marked by the tick in the leading slot (DESIGN
|
||||||
// the highlight's white text cannot wash out the way it did the accent-coloured label.
|
// "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 =
|
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 TAP_SKIP_S = 10;
|
||||||
const DOUBLE_TAP_MS = 300;
|
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">(
|
const [settingsTab, setSettingsTab] = useState<"sync" | "playback" | "subtitle" | "clock">(
|
||||||
"sync",
|
"sync",
|
||||||
);
|
);
|
||||||
// keep the control bar up while a menu is open
|
// Clip mode: the range being marked, or null. Its panel takes the control band's place, and a
|
||||||
const menuOpenRef = useLatest(panel === "more" || panel === "tracks" || panel === "settings");
|
// 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 menuRef = useRef<HTMLDivElement>(null);
|
||||||
const menuTriggerRef = useRef<HTMLButtonElement | null>(null);
|
const menuTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
// Auto-hiding control bar (reveal on mouse move / any key; hide after 3s while playing).
|
// 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
|
// 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.
|
// begins at the resume point), and a seek outside the stream restarts it.
|
||||||
const [videoEl, setVideoEl] = useState<HTMLVideoElement | null>(null);
|
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(
|
useMediaSession(
|
||||||
videoEl,
|
videoEl,
|
||||||
detail && {
|
detail && {
|
||||||
@@ -767,6 +834,9 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
seekTo,
|
seekTo,
|
||||||
videoRef,
|
videoRef,
|
||||||
reportWatchedError,
|
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.
|
// 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
|
// 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.
|
// (absRef 0) — its stray 'ended' must not mark anything watched or cascade another advance.
|
||||||
if (absRef.current <= 0) return;
|
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");
|
const watchedWrite = api.plexSetState(id, "watched");
|
||||||
watchedWrite.catch(reportWatchedError);
|
watchedWrite.catch(reportWatchedError);
|
||||||
// Whatever follows (the up-next card, the next item, closing) leaves a FINISHED item — unless
|
// 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);
|
video.addEventListener("ended", onEnded);
|
||||||
return () => video.removeEventListener("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');
|
// 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.
|
// 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
|
// 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
|
// 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
|
// panel, then the stats readout, then the clip being marked (its crop first) — and only then leaves
|
||||||
// the layers the viewer OPENED one per press, the one opened last first (each holds its own history
|
// the player. The mouse/browser Back peels the layers the viewer OPENED one per press, the one
|
||||||
// entry, see BackClose below); the ones that open by themselves have none, so Back leaves past
|
// opened last first (each holds its own history entry, see BackClose below); the ones that open by
|
||||||
// them. The shortcuts sheet is not in it: a Modal above us, with its own Escape layer and
|
// themselves have none, so Back leaves past them. The shortcuts sheet is not in it: a Modal above
|
||||||
// Back-to-close. Leaving closes whatever is still up first (`useHistorySubview`'s back).
|
// 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.
|
// - 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.
|
// - 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(
|
const back = useCallback(
|
||||||
(from: "arrow" | "key" | "escape") => {
|
(from: "arrow" | "key" | "escape") => {
|
||||||
if (from !== "arrow" && skipProgressRef.current != null) cancelAutoSkipRef.current();
|
if (from !== "arrow" && skipProgressRef.current != null) cancelAutoSkipRef.current();
|
||||||
else if (panelRef.current !== "none") setPanel("none");
|
else if (panelRef.current !== "none") setPanel("none");
|
||||||
else if (statsOpenRef.current) setStatsOpen(false);
|
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).
|
// The mouse/browser Back closes the layer whose history entry it popped (see BackClose below).
|
||||||
const closeStats = useCallback(() => setStatsOpen(false), []);
|
const closeStats = useCallback(() => setStatsOpen(false), []);
|
||||||
@@ -1006,6 +1083,21 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
},
|
},
|
||||||
info: () => togglePanel("info"),
|
info: () => togglePanel("info"),
|
||||||
stats: () => setStatsOpen((v) => !v),
|
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) => {
|
back: (ev) => {
|
||||||
// HTPC-style "stop & go back" to wherever the player was opened from — mirrors the mouse
|
// HTPC-style "stop & go back" to wherever the player was opened from — mirrors the mouse
|
||||||
// Back button (shared cascade).
|
// 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
|
// ⌫ 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).
|
// 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
|
// 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;
|
const ownPanelOpen = panelRef.current !== "none" || statsOpenRef.current;
|
||||||
if (!ownPanelOpen && layerIdRef.current != null && anyLayerAbove(layerIdRef.current))
|
if (!ownPanelOpen && layerIdRef.current != null && anyLayerAbove(layerIdRef.current))
|
||||||
return;
|
return;
|
||||||
@@ -1063,6 +1157,11 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
fineSeekStepRef,
|
fineSeekStepRef,
|
||||||
panelRef,
|
panelRef,
|
||||||
statsOpenRef,
|
statsOpenRef,
|
||||||
|
clipRef,
|
||||||
|
closeClip,
|
||||||
|
stepOutOfClip,
|
||||||
|
absRef,
|
||||||
|
durationRef,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Seekbar hover: a timestamp tooltip that follows the cursor. Store the position as a FRACTION
|
// 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(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
ref={wrapRef}
|
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) => {
|
onPointerDown={(e) => {
|
||||||
pointerRef.current = e.pointerType;
|
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.
|
// (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.
|
// `touch-manipulation`: a double-tap is the skip, never the browser's zoom.
|
||||||
className="w-full h-full object-contain touch-manipulation"
|
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}
|
onClick={onPictureClick}
|
||||||
playsInline
|
playsInline
|
||||||
>
|
>
|
||||||
@@ -1163,6 +1267,14 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</video>
|
</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
|
<PlexSubtitles
|
||||||
video={videoEl}
|
video={videoEl}
|
||||||
pos={prefs.subPos}
|
pos={prefs.subPos}
|
||||||
@@ -1317,12 +1429,12 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
<>
|
<>
|
||||||
{/* draining fill across the whole button… */}
|
{/* draining fill across the whole button… */}
|
||||||
<span
|
<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}%` }}
|
style={{ width: `${skipProgress * 100}%` }}
|
||||||
/>
|
/>
|
||||||
{/* …plus a crisp bottom bar so the countdown is unmistakable */}
|
{/* …plus a crisp bottom bar so the countdown is unmistakable */}
|
||||||
<span
|
<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}%` }}
|
style={{ width: `${skipProgress * 100}%` }}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -1345,309 +1457,345 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
: "opacity-0 pointer-events-none"
|
: "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
|
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`
|
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. */}
|
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
|
<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"
|
className="group relative flex h-11 cursor-pointer touch-none items-center"
|
||||||
style={{ left: `${hover.frac * 100}%` }}
|
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>
|
||||||
)}
|
|
||||||
<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
|
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
|
(closing is stopping), no Restart (Back is, and the card's), no ±10 s (← / → and the
|
||||||
touch double-tap are). */}
|
touch double-tap are). */}
|
||||||
<div className="flex items-center gap-3 text-white">
|
<div className="flex items-center gap-3 text-white">
|
||||||
<Ctrl label={t("plex.player.playPause")} onClick={togglePlay} align="start">
|
<Ctrl label={t("plex.player.playPause")} onClick={togglePlay} align="start">
|
||||||
{playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />}
|
{playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />}
|
||||||
</Ctrl>
|
</Ctrl>
|
||||||
<Ctrl
|
<Ctrl
|
||||||
label={backToPrev ? t("plex.player.prev") : t("plex.player.restart")}
|
label={backToPrev ? t("plex.player.prev") : t("plex.player.restart")}
|
||||||
onClick={backOrPrev}
|
onClick={backOrPrev}
|
||||||
>
|
>
|
||||||
<SkipBack className="w-5 h-5" />
|
<SkipBack className="w-5 h-5" />
|
||||||
</Ctrl>
|
</Ctrl>
|
||||||
{showNext && (
|
{showNext && (
|
||||||
<Ctrl
|
<Ctrl
|
||||||
label={t("plex.player.next")}
|
label={t("plex.player.next")}
|
||||||
onClick={() => nextId && go(nextId)}
|
onClick={() => nextId && go(nextId)}
|
||||||
disabled={!nextId}
|
disabled={!nextId}
|
||||||
>
|
>
|
||||||
<SkipForward className="w-5 h-5" />
|
<SkipForward className="w-5 h-5" />
|
||||||
</Ctrl>
|
</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" />
|
|
||||||
)}
|
)}
|
||||||
</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
|
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
|
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. */}
|
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. */}
|
row has to fit a portrait screen. */}
|
||||||
<div
|
<div
|
||||||
className="relative w-24 [@media(hover:none)]:hidden"
|
className="relative w-24 [@media(hover:none)]:hidden"
|
||||||
onPointerEnter={() => setVolHover(true)}
|
onPointerEnter={() => setVolHover(true)}
|
||||||
onPointerLeave={() => setVolHover(false)}
|
onPointerLeave={() => setVolHover(false)}
|
||||||
>
|
>
|
||||||
<Slider
|
<Slider
|
||||||
min={0}
|
min={0}
|
||||||
max={1}
|
max={1}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
value={muted ? 0 : volume}
|
value={muted ? 0 : volume}
|
||||||
aria-label={t("plex.player.volume")}
|
aria-label={t("plex.player.volume")}
|
||||||
onChange={(v) => setVol(v, v === 0)}
|
onChange={(v) => setVol(v, v === 0)}
|
||||||
onPointerUp={(e) => e.currentTarget.blur()}
|
onPointerUp={(e) => e.currentTarget.blur()}
|
||||||
/>
|
/>
|
||||||
{volHover && (
|
{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">
|
<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)}
|
{Math.round((muted ? 0 : volume) * 100)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-w-0 truncate text-xs tabular-nums text-white/90">
|
<div className="min-w-0 truncate text-xs tabular-nums text-white/90">
|
||||||
{fmt(abs)} / {fmt(duration)}
|
{fmt(abs)} / {fmt(duration)}
|
||||||
{/* Projected finish time (wall clock now + time remaining), in the clock's own format —
|
{/* 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. */}
|
pushes later while paused, since the remaining time then rides the advancing clock. */}
|
||||||
{duration > 0 && (
|
{duration > 0 && (
|
||||||
<span className="ml-1.5 text-white/45">
|
<span className="ml-1.5 text-white/45">
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
clockParts(new Date(now.getTime() + Math.max(0, duration - abs) * 1000), prefs)
|
clockParts(
|
||||||
.time
|
new Date(now.getTime() + Math.max(0, duration - abs) * 1000),
|
||||||
}
|
prefs,
|
||||||
)
|
).time
|
||||||
</span>
|
}
|
||||||
)}
|
)
|
||||||
</div>
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
{/* Quick TRACKS menu (audio / subtitle) — the frequent action, on its own button. Picking
|
{/* Quick TRACKS menu (audio / subtitle) — the frequent action, on its own button. Picking
|
||||||
a track keeps it open: the change is seen where it was made, and the other group is
|
a track keeps it open: the change is seen where it was made, and the other group is
|
||||||
one arrow away. */}
|
one arrow away. */}
|
||||||
{detail && (detail.audio_streams.length > 1 || textSubs.length > 0) && (
|
{detail && (detail.audio_streams.length > 1 || textSubs.length > 0) && (
|
||||||
<>
|
<>
|
||||||
<IconButton
|
<IconButton
|
||||||
variant="overlay"
|
variant="overlay"
|
||||||
size="player"
|
size="player"
|
||||||
round
|
round
|
||||||
ref={setTracksReference}
|
ref={setTracksReference}
|
||||||
{...tracksPop.getReferenceProps()}
|
{...tracksPop.getReferenceProps()}
|
||||||
aria-expanded={panel === "tracks"}
|
aria-expanded={panel === "tracks"}
|
||||||
label={t("plex.player.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`}
|
|
||||||
>
|
>
|
||||||
{it.icon}
|
<Languages />
|
||||||
{it.label}
|
</IconButton>
|
||||||
</PopoverItem>
|
<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>
|
</div>
|
||||||
</PopoverSurface>
|
{/* Absent where the browser cannot fullscreen an element (Brave on an iPad): the button
|
||||||
{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
|
|
||||||
did nothing there, and the windowed player already fills the window. */}
|
did nothing there, and the windowed player already fills the window. */}
|
||||||
{canFullscreenElements() && (
|
{canFullscreenElements() && (
|
||||||
<Ctrl label={t("plex.player.fullscreen")} onClick={toggleFs} align="end">
|
<Ctrl label={t("plex.player.fullscreen")} onClick={toggleFs} align="end">
|
||||||
{fs ? <Minimize className="w-5 h-5" /> : <Maximize className="w-5 h-5" />}
|
{fs ? <Minimize className="w-5 h-5" /> : <Maximize className="w-5 h-5" />}
|
||||||
</Ctrl>
|
</Ctrl>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats-for-nerds debug overlay (toggle with "d" or the gauge button). Non-modal corner panel —
|
{/* 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
|
<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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Pressable } from "./ui/Pressable";
|
|||||||
import { ColorInput, CheckRow } from "./ui/NativeControls";
|
import { ColorInput, CheckRow } from "./ui/NativeControls";
|
||||||
import Tabs from "./ui/Tabs";
|
import Tabs from "./ui/Tabs";
|
||||||
import { useTabPanel } from "./ui/tabPanel";
|
import { useTabPanel } from "./ui/tabPanel";
|
||||||
|
import { MEDIA_CHOSEN } from "./ui/mediaChrome";
|
||||||
|
|
||||||
type SettingsTab = "sync" | "playback" | "subtitle" | "clock";
|
type SettingsTab = "sync" | "playback" | "subtitle" | "clock";
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ export function PlexPlayerSettings(props: {
|
|||||||
aria-label={t("plex.player.settings")}
|
aria-label={t("plex.player.settings")}
|
||||||
onWheel={(e) => e.stopPropagation()}
|
onWheel={(e) => e.stopPropagation()}
|
||||||
onClick={(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
|
<Tabs
|
||||||
tone="media"
|
tone="media"
|
||||||
@@ -273,9 +274,7 @@ export function PlexPlayerSettings(props: {
|
|||||||
aria-pressed={prefs.clockDateStyle === st}
|
aria-pressed={prefs.clockDateStyle === st}
|
||||||
onClick={() => patchPrefs({ clockDateStyle: st })}
|
onClick={() => patchPrefs({ clockDateStyle: st })}
|
||||||
className={`touch-target flex-1 rounded px-2 py-1 text-2xs ${
|
className={`touch-target flex-1 rounded px-2 py-1 text-2xs ${
|
||||||
prefs.clockDateStyle === st
|
prefs.clockDateStyle === st ? MEDIA_CHOSEN : "text-white/70 hover:bg-white/10"
|
||||||
? "bg-white/15 text-accent"
|
|
||||||
: "text-white/70 hover:bg-white/10"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{clockParts(now, { ...prefs, clockDate: true, clockDateStyle: st }).date}
|
{clockParts(now, { ...prefs, clockDate: true, clockDateStyle: st }).date}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function UpNextCard({
|
|||||||
role="region"
|
role="region"
|
||||||
aria-label={t("plex.player.upNext.title")}
|
aria-label={t("plex.player.upNext.title")}
|
||||||
onClick={(e) => e.stopPropagation()}
|
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">
|
<div className="h-[6.5rem] w-[4.6rem] flex-none overflow-hidden rounded-lg bg-white/10">
|
||||||
{next && (
|
{next && (
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// now says how many made it (also when `req` has already shown the refusal in its dialog), deselects
|
// 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.
|
// 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 { 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";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => ({
|
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)");
|
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 { qk } from "../lib/queryKeys";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -7,37 +7,22 @@ import clsx from "clsx";
|
|||||||
import Modal, { ModalFooter } from "./ui/Modal";
|
import Modal, { ModalFooter } from "./ui/Modal";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { api, type DownloadJob, type EditSpec } from "../lib/api";
|
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 { Button } from "./ui/Button";
|
||||||
import { failureReason, notifyActionError } from "../lib/actionErrors";
|
import { failureReason, notifyActionError } from "../lib/actionErrors";
|
||||||
import { markReported } from "../lib/api/core";
|
import { markReported } from "../lib/api/core";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "./ui/IconButton";
|
||||||
import { Pressable } from "./ui/Pressable";
|
import { Pressable } from "./ui/Pressable";
|
||||||
import { Input } from "./ui/Input";
|
import { Input } from "./ui/Input";
|
||||||
|
import { TimecodeError, TimecodeField } from "./ui/TimecodeField";
|
||||||
|
import { useTimecodeField } from "./ui/useTimecodeField";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "./ui/surfaces";
|
||||||
|
import { RangeTrack } from "./ui/RangeTrack";
|
||||||
// mm:ss.d timecode <-> seconds.
|
import { trackLeft } from "./ui/trackPosition";
|
||||||
function tc(s: number): string {
|
import { formatTimecode } from "../lib/timecode";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Seg = { id: number; start: number; end: number; keep: boolean };
|
type Seg = { id: number; start: number; end: number; keep: boolean };
|
||||||
type Drag = { type: "seek" } | { type: "boundary"; bi: number };
|
|
||||||
|
|
||||||
const MIN_SEG = 0.1;
|
const MIN_SEG = 0.1;
|
||||||
|
|
||||||
@@ -59,6 +44,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const trackRef = useRef<HTMLDivElement>(null);
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
|
const splitRef = useRef<HTMLButtonElement>(null);
|
||||||
const sid = useRef(1);
|
const sid = useRef(1);
|
||||||
const nid = () => sid.current++;
|
const nid = () => sid.current++;
|
||||||
|
|
||||||
@@ -67,11 +53,9 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
const [current, setCurrent] = useState(0);
|
const [current, setCurrent] = useState(0);
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const [trackW, setTrackW] = useState(0);
|
const [trackW, setTrackW] = useState(0);
|
||||||
// Intrinsic (source) video dims and the video element's rendered box, both needed to map the
|
// Intrinsic (source) video dims: the crop frame's picture shape, and the crop's source pixels.
|
||||||
// crop marquee onto the letterboxed picture. `vdim` starts from asset metadata so the overlay
|
// From the asset's metadata, so the frame sizes before the media decodes.
|
||||||
// can size before the media decodes; `box` is measured off the element (max-h can clamp it).
|
|
||||||
const [vdim, setVdim] = useState({ w: job.asset?.width ?? 0, h: job.asset?.height ?? 0 });
|
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.
|
// 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 }]);
|
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 [name, setName] = useState("");
|
||||||
|
|
||||||
const fileUrl = useMemo(() => api.downloadFileUrl(job.id), [job.id]);
|
const fileUrl = useMemo(() => api.downloadFileUrl(job.id), [job.id]);
|
||||||
|
const tc = (sec: number) => formatTimecode(sec, duration >= 3600);
|
||||||
const aspect =
|
const aspect =
|
||||||
(job.asset?.width && job.asset?.height ? job.asset.width / job.asset.height : 0) || 16 / 9;
|
(job.asset?.width && job.asset?.height ? job.asset.width / job.asset.height : 0) || 16 / 9;
|
||||||
const sb = useQuery({
|
const sb = useQuery({
|
||||||
@@ -111,21 +96,9 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
return () => ro.disconnect();
|
return () => ro.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Measure the video element's rendered box (max-h can shrink it below its intrinsic size, which
|
// The picture's shape, which the crop frame is drawn on (it finds the picture inside the video's
|
||||||
// then pillarboxes the picture). The crop overlay is anchored to the picture, not this box.
|
// box itself: max-h can pillarbox it).
|
||||||
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.
|
|
||||||
const va = vdim.w && vdim.h ? vdim.w / vdim.h : aspect;
|
const va = vdim.w && vdim.h ? vdim.w / vdim.h : aspect;
|
||||||
const picRect = contentRect(box.w, box.h, va);
|
|
||||||
|
|
||||||
const onLoaded = () => {
|
const onLoaded = () => {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
@@ -162,6 +135,18 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
|
|
||||||
// --- cut-list mutations ---
|
// --- cut-list mutations ---
|
||||||
const boundaries = useMemo(() => segments.slice(0, -1).map((s) => s.end), [segments]);
|
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 = () => {
|
const splitAtPlayhead = () => {
|
||||||
if (creating.current) return;
|
if (creating.current) return;
|
||||||
const time = current;
|
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)),
|
creating.current ? segs : segs.map((s) => (s.id === id ? { ...s, keep: !s.keep } : s)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- timeline drag (seek / boundary) + hover preview ---
|
// --- timeline hover preview ---
|
||||||
const [drag, setDrag] = useState<Drag | null>(null);
|
// The hover point, measured in the track's pointer handler (track-relative x and its time), so
|
||||||
// The hover point, measured in the pointer handler (track-relative x and its time), so render never
|
// render never reads the track's geometry (a DOM read in render made the React Compiler skip it).
|
||||||
// reads the track's geometry (a DOM read in render made the React Compiler skip the editor).
|
|
||||||
const [hover, setHover] = useState<{ x: number; time: number } | null>(null);
|
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) ---
|
// --- 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 tileStyle = (idx: number): React.CSSProperties => {
|
||||||
const cols = strip?.cols ?? 12;
|
const cols = strip?.cols ?? 12;
|
||||||
const rows = strip?.rows ?? 1;
|
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 sel = segments.find((s) => s.id === selId) ?? segments[0];
|
||||||
const selIdx = segments.findIndex((s) => s.id === sel?.id);
|
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 (
|
return (
|
||||||
<Modal
|
<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"
|
className="w-full max-h-[42dvh] mx-auto block object-contain"
|
||||||
playsInline
|
playsInline
|
||||||
/>
|
/>
|
||||||
{cropOn && picRect && (
|
{cropOn && <CropFrame aspect={va} rect={crop} ratio={null} onChange={setCrop} />}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* transport */}
|
{/* transport */}
|
||||||
@@ -459,6 +359,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
ref={splitRef}
|
||||||
onClick={splitAtPlayhead}
|
onClick={splitAtPlayhead}
|
||||||
icon={<SplitSquareHorizontal />}
|
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 */}
|
{/* timeline: filmstrip + segments (keep/drop tint) + markers + playhead + hover preview */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div
|
<div ref={trackRef}>
|
||||||
ref={trackRef}
|
<RangeTrack
|
||||||
className="relative h-14 rounded-lg overflow-hidden bg-surface border border-border cursor-pointer"
|
label={t("editor.timeline")}
|
||||||
onPointerDown={(e) => {
|
duration={duration}
|
||||||
setDrag({ type: "seek" });
|
playhead={current}
|
||||||
seek(timeAt(e.clientX));
|
onSeek={(time) => {
|
||||||
}}
|
setCurrent(time);
|
||||||
onPointerMove={(e) =>
|
seek(time);
|
||||||
setHover({
|
}}
|
||||||
x: e.clientX - e.currentTarget.getBoundingClientRect().left,
|
onHover={setHover}
|
||||||
time: timeAt(e.clientX),
|
className="h-14 rounded-lg bg-surface border border-border cursor-pointer"
|
||||||
})
|
background={
|
||||||
}
|
stripCells > 0 && (
|
||||||
onPointerLeave={() => setHover(null)}
|
<div className="absolute inset-0 flex pointer-events-none">
|
||||||
>
|
{Array.from({ length: stripCells }).map((_, i) => (
|
||||||
{/* aspect-correct filmstrip */}
|
<div
|
||||||
{stripCells > 0 && (
|
key={i}
|
||||||
<div className="absolute inset-0 flex pointer-events-none">
|
className="h-full flex-1"
|
||||||
{Array.from({ length: stripCells }).map((_, i) => (
|
style={tileStyle(
|
||||||
<div
|
Math.round(((i + 0.5) / stripCells) * ((strip!.count ?? 1) - 1)),
|
||||||
key={i}
|
)}
|
||||||
className="h-full flex-1"
|
/>
|
||||||
style={tileStyle(
|
))}
|
||||||
Math.round(((i + 0.5) / stripCells) * ((strip!.count ?? 1) - 1)),
|
</div>
|
||||||
)}
|
)
|
||||||
/>
|
}
|
||||||
))}
|
regions={segments.map((s) => ({
|
||||||
</div>
|
key: s.id,
|
||||||
)}
|
start: s.start,
|
||||||
{/* segment tint: dropped = dimmed + hatched */}
|
end: s.end,
|
||||||
{segments.map((s) => (
|
// segment tint: dropped = dimmed + hatched
|
||||||
<div
|
className: clsx(
|
||||||
key={s.id}
|
s.end < duration && "border-r border-black/40",
|
||||||
className={clsx(
|
|
||||||
"absolute inset-y-0 border-r border-black/40 last:border-r-0",
|
|
||||||
s.keep ? "bg-accent/10" : "bg-black/60",
|
s.keep ? "bg-accent/10" : "bg-black/60",
|
||||||
s.id === selId && "ring-2 ring-inset ring-white/70",
|
s.id === selId && "ring-2 ring-inset ring-white/70",
|
||||||
)}
|
),
|
||||||
style={{
|
style: s.keep
|
||||||
left: pct(s.start),
|
? undefined
|
||||||
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)",
|
||||||
: {
|
},
|
||||||
backgroundImage:
|
title: s.keep ? t("editor.keptSeg") : t("editor.droppedSeg"),
|
||||||
"repeating-linear-gradient(45deg, rgba(0,0,0,0.55) 0 6px, rgba(0,0,0,0.25) 6px 12px)",
|
onPointerDown: () => setSelId(s.id),
|
||||||
}),
|
children: (
|
||||||
}}
|
// keep/drop toggle, over the cut's hit area beside it (that reaches 12 px into the
|
||||||
onPointerDown={() => setSelId(s.id)}
|
// segment): a press on the eye is the eye's, never a drag of the cut
|
||||||
title={s.keep ? t("editor.keptSeg") : t("editor.droppedSeg")}
|
<Pressable
|
||||||
>
|
aria-pressed={s.keep}
|
||||||
{/* keep/drop toggle */}
|
onPointerDown={(e) => {
|
||||||
<Pressable
|
e.stopPropagation();
|
||||||
aria-pressed={s.keep}
|
toggleKeep(s.id);
|
||||||
onPointerDown={(e) => {
|
}}
|
||||||
e.stopPropagation();
|
className={clsx(
|
||||||
toggleKeep(s.id);
|
"absolute top-1 left-1 z-menu p-0.5 rounded-sm bg-black/50 backdrop-blur-xs",
|
||||||
}}
|
s.keep ? "text-accent" : "text-muted",
|
||||||
className={clsx(
|
)}
|
||||||
"absolute top-1 left-1 p-0.5 rounded-sm bg-black/50 backdrop-blur-xs",
|
title={s.keep ? t("editor.drop") : t("editor.keep")}
|
||||||
s.keep ? "text-accent" : "text-muted",
|
>
|
||||||
)}
|
{s.keep ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
|
||||||
title={s.keep ? t("editor.drop") : t("editor.keep")}
|
</Pressable>
|
||||||
>
|
),
|
||||||
{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.
|
||||||
</div>
|
handles={boundaries.map((b, bi) => ({
|
||||||
))}
|
id: `cut-${bi}`,
|
||||||
{/* draggable boundary markers */}
|
label: t("editor.cutN", { n: bi + 1 }),
|
||||||
{boundaries.map((b, bi) => (
|
time: b,
|
||||||
<div
|
min: segments[bi]!.start + MIN_SEG,
|
||||||
key={bi}
|
max: segments[bi + 1]!.end - MIN_SEG,
|
||||||
className="absolute inset-y-0 -ml-1.5 w-3 flex items-center justify-center z-base"
|
valueText: tc(b),
|
||||||
style={{ left: pct(b) }}
|
step: 0.1,
|
||||||
>
|
bigStep: 1,
|
||||||
<div
|
onChange: (time: number) => moveBoundary(bi, time),
|
||||||
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) }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* hover-scrub thumbnail */}
|
||||||
{strip && hoverTime != null && (
|
{strip && hoverTime != null && (
|
||||||
@@ -605,35 +512,32 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<label className="flex items-center gap-1 text-muted">
|
<label className="flex items-center gap-1 text-muted">
|
||||||
{t("editor.in")}
|
{t("editor.in")}
|
||||||
<Input
|
<TimecodeField
|
||||||
className="w-24 tabular-nums"
|
className="w-24 tabular-nums"
|
||||||
size="sm"
|
size="sm"
|
||||||
defaultValue={tc(sel.start)}
|
field={inField}
|
||||||
key={`s-${sel.id}-${sel.start}`}
|
errorId={timeErrorId}
|
||||||
disabled={selIdx === 0}
|
disabled={selIdx === 0}
|
||||||
onBlur={(e) => {
|
|
||||||
const v = parseTc(e.target.value);
|
|
||||||
if (v != null && selIdx > 0) moveBoundary(selIdx - 1, v);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-1 text-muted">
|
<label className="flex items-center gap-1 text-muted">
|
||||||
{t("editor.out")}
|
{t("editor.out")}
|
||||||
<Input
|
<TimecodeField
|
||||||
className="w-24 tabular-nums"
|
className="w-24 tabular-nums"
|
||||||
size="sm"
|
size="sm"
|
||||||
defaultValue={tc(sel.end)}
|
field={outField}
|
||||||
key={`e-${sel.id}-${sel.end}`}
|
errorId={timeErrorId}
|
||||||
disabled={selIdx === segments.length - 1}
|
disabled={selIdx === segments.length - 1}
|
||||||
onBlur={(e) => {
|
|
||||||
const v = parseTc(e.target.value);
|
|
||||||
if (v != null && selIdx < segments.length - 1) moveBoundary(selIdx, v);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<span className="text-xs text-muted tabular-nums w-16 text-right">
|
<span className="text-xs text-muted tabular-nums w-16 text-right">
|
||||||
{tc(sel.end - sel.start)}
|
{tc(sel.end - sel.start)}
|
||||||
</span>
|
</span>
|
||||||
|
{(inField.invalid || outField.invalid) && (
|
||||||
|
<div className="basis-full">
|
||||||
|
<TimecodeError id={timeErrorId} hours={hours} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ export function DownloadRow({
|
|||||||
<div
|
<div
|
||||||
className={`font-medium leading-snug break-words line-clamp-1 @max-[40rem]/dlrow:line-clamp-2`}
|
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">
|
<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")}
|
<Scissors className="w-3 h-3" /> {t("downloads.clipBadge")}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type ReactNode } from "react";
|
import { type ReactNode, type Ref } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { RotateCcw } from "lucide-react";
|
import { RotateCcw } from "lucide-react";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "./ui/IconButton";
|
||||||
@@ -96,11 +96,13 @@ export function Ctrl({
|
|||||||
onClick,
|
onClick,
|
||||||
disabled,
|
disabled,
|
||||||
align = "center",
|
align = "center",
|
||||||
|
buttonRef,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
buttonRef?: Ref<HTMLButtonElement>;
|
||||||
// Tooltip horizontal anchor: "start"/"end" for edge buttons so it doesn't clip off the viewport.
|
// Tooltip horizontal anchor: "start"/"end" for edge buttons so it doesn't clip off the viewport.
|
||||||
align?: "start" | "center" | "end";
|
align?: "start" | "center" | "end";
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -114,6 +116,7 @@ export function Ctrl({
|
|||||||
return (
|
return (
|
||||||
<div className="relative flex group/ctrl">
|
<div className="relative flex group/ctrl">
|
||||||
<IconButton
|
<IconButton
|
||||||
|
ref={buttonRef}
|
||||||
variant="overlay"
|
variant="overlay"
|
||||||
size="player"
|
size="player"
|
||||||
round
|
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";
|
"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 =
|
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";
|
"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.
|
// `text-fg` is dark in the light theme: the highlighted row would go dark-on-dark. White instead.
|
||||||
const MEDIA_HIGHLIGHT =
|
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";
|
"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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,7 +20,8 @@ export interface IconButtonProps
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
variant?: IconButtonVariant;
|
variant?: IconButtonVariant;
|
||||||
size?: IconButtonSize;
|
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;
|
pressed?: boolean;
|
||||||
/** Not accepted: a toggle is `pressed` (JSX lets any undeclared `aria-*` through, so it is `never`). */
|
/** Not accepted: a toggle is `pressed` (JSX lets any undeclared `aria-*` through, so it is `never`). */
|
||||||
"aria-pressed"?: never;
|
"aria-pressed"?: never;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function MenuCheck({ kind, on }: { kind: "many" | "one"; on: boolean | "m
|
|||||||
return (
|
return (
|
||||||
<Check
|
<Check
|
||||||
aria-hidden="true"
|
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 (
|
return (
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import clsx from "clsx";
|
|||||||
import type { InputHTMLAttributes, ReactNode, Ref } from "react";
|
import type { InputHTMLAttributes, ReactNode, Ref } from "react";
|
||||||
|
|
||||||
// A6 (docs/DESIGN.md): checkbox, slider and colour well are the BROWSER's controls, tinted
|
// 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
|
// 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
|
// 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.
|
// `<input>` for any of them anywhere else is a lint error.
|
||||||
@@ -22,7 +23,7 @@ export function Checkbox({
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
onChange={(e) => onChange?.(e.target.checked)}
|
onChange={(e) => onChange?.(e.target.checked)}
|
||||||
className={clsx(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...rest}
|
{...rest}
|
||||||
@@ -70,7 +71,7 @@ export function Slider({
|
|||||||
type="range"
|
type="range"
|
||||||
onChange={(e) => onChange(Number(e.target.value))}
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
className={clsx(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...rest}
|
{...rest}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ function PriorityStepButton({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
// Keep receiving pointer events even if the pointer drifts off the small button mid-hold.
|
// Keep receiving pointer events even if the pointer drifts off the small button mid-hold.
|
||||||
try {
|
try {
|
||||||
@@ -162,6 +163,7 @@ export function PriorityStepper({
|
|||||||
} ${cls.value}`}
|
} ${cls.value}`}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
ref={editTarget}
|
ref={editTarget}
|
||||||
onClick={beginEdit}
|
onClick={beginEdit}
|
||||||
aria-label={t("common.priority.edit")}
|
aria-label={t("common.priority.edit")}
|
||||||
|
|||||||
@@ -35,13 +35,13 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
|
|||||||
|
|
||||||
## What lives here
|
## What lives here
|
||||||
|
|
||||||
| area | components |
|
| 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` |
|
| 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` |
|
| 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) |
|
| 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` |
|
| 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` |
|
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
|
||||||
|
|
||||||
## The size contract (E10 S1c)
|
## 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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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) => (
|
{options.map((o) => (
|
||||||
<button
|
<button
|
||||||
key={o.value}
|
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)}
|
onClick={() => onChange(o.value)}
|
||||||
title={o.title}
|
title={o.title}
|
||||||
aria-pressed={value === o.value}
|
aria-pressed={value === o.value}
|
||||||
|
|||||||
@@ -61,4 +61,16 @@ describe("Tabs", () => {
|
|||||||
fireEvent.click(tab("Beta"));
|
fireEvent.click(tab("Beta"));
|
||||||
expect(screen.getByRole("tabpanel", { name: "Beta" })).toBe(panel);
|
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,6 +2,7 @@ import { Fragment, useId, useRef, type KeyboardEvent } from "react";
|
|||||||
import { panelId, tabId } from "./tabPanel";
|
import { panelId, tabId } from "./tabPanel";
|
||||||
import { type LucideIcon } from "lucide-react";
|
import { type LucideIcon } from "lucide-react";
|
||||||
import CountBadge from "./CountBadge";
|
import CountBadge from "./CountBadge";
|
||||||
|
import { MEDIA_CHOSEN } from "./mediaChrome";
|
||||||
|
|
||||||
// Reusable pill tab-rail for in-page sub-navigation (Configuration, Users & roles, the Settings
|
// 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
|
// sections, the Stats overview/system split…). Horizontal by default; `orientation="vertical"` gives
|
||||||
@@ -125,7 +126,7 @@ export default function Tabs({
|
|||||||
className={
|
className={
|
||||||
tone === "media"
|
tone === "media"
|
||||||
? `touch-target flex-1 items-center justify-center whitespace-nowrap rounded px-2 py-1 text-2xs ${
|
? `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 ${
|
: `pill touch-target items-center rounded-lg px-3 text-sm ${
|
||||||
vertical
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
// 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.
|
// 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> = {
|
const ICON_SIZES: Record<IconButtonSize, string> = {
|
||||||
sm: "size-7 [&>svg]:size-4",
|
sm: "size-7 [&>svg]:size-4",
|
||||||
@@ -172,7 +172,7 @@ export function iconButtonClass({
|
|||||||
pressed?: boolean;
|
pressed?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return clsx(
|
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],
|
ICON_VARIANTS[variant],
|
||||||
round || variant === "media" ? "rounded-full" : "rounded-lg",
|
round || variant === "media" ? "rounded-full" : "rounded-lg",
|
||||||
pressed !== undefined && ICON_PRESSED,
|
pressed !== undefined && ICON_PRESSED,
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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
|
// 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.
|
// `has-disabled:` faded the whole box. On a coarse pointer the box grows to the 44 touch target.
|
||||||
const BASE =
|
const BASE =
|
||||||
"min-w-0 border edge-strong text-fg placeholder:text-muted transition-colors " +
|
"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 " +
|
"aria-invalid:border-danger aria-invalid:ring-danger " +
|
||||||
"has-[input[aria-invalid=true]]:border-danger has-[input[aria-invalid=true]]:ring-danger " +
|
"has-[input[aria-invalid=true]]:border-danger has-[input[aria-invalid=true]]:ring-danger " +
|
||||||
"disabled:cursor-not-allowed disabled:opacity-50 " +
|
"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,
|
download: CANCEL_DOWNLOAD,
|
||||||
upload: CANCEL_UPLOAD,
|
upload: CANCEL_UPLOAD,
|
||||||
edit: CANCEL_EDIT,
|
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
|
// 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.
|
// 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",
|
body: "downloads.confirm.deleteUploadBody",
|
||||||
},
|
},
|
||||||
edit: { title: "downloads.confirm.deleteEditTitle", body: "downloads.confirm.deleteEditBody" },
|
edit: { title: "downloads.confirm.deleteEditTitle", body: "downloads.confirm.deleteEditBody" },
|
||||||
|
clip: { title: "downloads.confirm.deleteEditTitle", body: "downloads.confirm.deleteEditBody" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useDownloadActions({
|
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",
|
"version": "0.104.0",
|
||||||
"date": "2026-10-10",
|
"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",
|
"version": "0.104.0",
|
||||||
"date": "2026-10-10",
|
"date": "2026-10-10",
|
||||||
|
|||||||
@@ -170,6 +170,13 @@ const youtubeReasons = [
|
|||||||
backend("youtube/client.py").match(/^YouTubeRefusal = Literal\[([^\]]*)\]/m)?.[1] ?? ""
|
backend("youtube/client.py").match(/^YouTubeRefusal = Literal\[([^\]]*)\]/m)?.[1] ?? ""
|
||||||
).matchAll(/"(\w+)"/g),
|
).matchAll(/"(\w+)"/g),
|
||||||
].map((m) => m[1]!);
|
].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>`
|
// 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 —
|
// (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(goneWhats).toEqual(expect.arrayContaining(["playlist", "download", "job"]));
|
||||||
expect(opaqueWhats).toEqual([]);
|
expect(opaqueWhats).toEqual([]);
|
||||||
expect(youtubeReasons).toEqual(["quota", "reconnect", "unavailable", "gone"]);
|
expect(youtubeReasons).toEqual(["quota", "reconnect", "unavailable", "gone"]);
|
||||||
|
expect(clipReasons).toEqual(expect.arrayContaining(["too_long", "past_end", "bad_crop"]));
|
||||||
expect(crewJobs).toEqual(
|
expect(crewJobs).toEqual(
|
||||||
expect.arrayContaining(["Director", "Creator", "Director of Photography"]),
|
expect.arrayContaining(["Director", "Creator", "Director of Photography"]),
|
||||||
);
|
);
|
||||||
@@ -336,6 +344,13 @@ describe("backend ids have their names in both languages", () => {
|
|||||||
).toEqual([]);
|
).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`, () => {
|
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)) {
|
for (const [group, words] of Object.entries(VOCAB)) {
|
||||||
const wanted = words.map((w) => `vocab.${group}.${vocabKey(w)}`);
|
const wanted = words.map((w) => `vocab.${group}.${vocabKey(w)}`);
|
||||||
|
|||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
"privacyPolicy": "Privacy policy",
|
"privacyPolicy": "Privacy policy",
|
||||||
"termsOfService": "Terms of service",
|
"termsOfService": "Terms of service",
|
||||||
"sourceCode": "Source code",
|
"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",
|
"close": "Close",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
@@ -69,5 +71,12 @@
|
|||||||
"done": "Copied to the clipboard",
|
"done": "Copied to the clipboard",
|
||||||
"linkDone": "Link copied to the clipboard",
|
"linkDone": "Link copied to the clipboard",
|
||||||
"failed": "Couldn't copy 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,10 +243,15 @@
|
|||||||
"detailsToggle": "Technical details",
|
"detailsToggle": "Technical details",
|
||||||
"upload": {
|
"upload": {
|
||||||
"quota_exceeded": {
|
"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."
|
"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": {
|
"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": {
|
"unknown": {
|
||||||
"short": "Couldn't make the clip",
|
"short": "Couldn't make the clip",
|
||||||
"body": "Something went wrong while making this clip. The technical details below may help."
|
"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."
|
"body": "Something went wrong with this download. The technical details below may help."
|
||||||
},
|
},
|
||||||
"quota_exceeded": {
|
"quota_exceeded": {
|
||||||
"short": "Storage full",
|
"short": "Not enough storage",
|
||||||
"body": "This download was stopped because your storage quota is already used up. Delete something from your library, then try again."
|
"body": "This download doesn't fit in the storage you have left. Delete something from your library, then try again."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"catalog": {
|
"catalog": {
|
||||||
|
|||||||
@@ -37,7 +37,9 @@
|
|||||||
"dropped": "Dropped",
|
"dropped": "Dropped",
|
||||||
"keptSeg": "Kept segment",
|
"keptSeg": "Kept segment",
|
||||||
"droppedSeg": "Dropped segment",
|
"droppedSeg": "Dropped segment",
|
||||||
"removeCut": "Remove cut",
|
"removeCut": "Remove cut {{n}}",
|
||||||
"keptSummary": "{{n}} kept · {{dur}}",
|
"keptSummary": "{{n}} kept · {{dur}}",
|
||||||
"createJoin": "Create joined clip"
|
"createJoin": "Create joined clip",
|
||||||
|
"timeline": "Timeline",
|
||||||
|
"cutN": "Cut {{n}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,5 +38,7 @@
|
|||||||
"cap": {
|
"cap": {
|
||||||
"space": "Space",
|
"space": "Space",
|
||||||
"menu": "Menu"
|
"menu": "Menu"
|
||||||
}
|
},
|
||||||
|
"clip": "Clip mode",
|
||||||
|
"clipMark": "Mark the clip’s start / end here"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
"undoAction": "Unsuspend",
|
"undoAction": "Unsuspend",
|
||||||
"title": "Suspend account?",
|
"title": "Suspend account?",
|
||||||
"undoTitle": "Unsuspend 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.",
|
"undoConfirm": "Unsuspend {{email}}? They'll be able to sign in again.",
|
||||||
"done": "{{email}} suspended",
|
"done": "{{email}} suspended",
|
||||||
"undone": "{{email}} unsuspended",
|
"undone": "{{email}} unsuspended",
|
||||||
|
|||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
"privacyPolicy": "Adatvédelmi irányelvek",
|
"privacyPolicy": "Adatvédelmi irányelvek",
|
||||||
"termsOfService": "Felhasználási feltételek",
|
"termsOfService": "Felhasználási feltételek",
|
||||||
"sourceCode": "Forráskód",
|
"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",
|
"close": "Bezárás",
|
||||||
"cancel": "Mégse",
|
"cancel": "Mégse",
|
||||||
"confirm": "Megerősítés",
|
"confirm": "Megerősítés",
|
||||||
@@ -69,5 +71,12 @@
|
|||||||
"done": "Vágólapra másolva",
|
"done": "Vágólapra másolva",
|
||||||
"linkDone": "Link a vágólapra másolva",
|
"linkDone": "Link a vágólapra másolva",
|
||||||
"failed": "Nem sikerült a vágólapra másolni"
|
"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ő"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,10 +243,15 @@
|
|||||||
"detailsToggle": "Technikai részletek",
|
"detailsToggle": "Technikai részletek",
|
||||||
"upload": {
|
"upload": {
|
||||||
"quota_exceeded": {
|
"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."
|
"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": {
|
"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": {
|
"unknown": {
|
||||||
"short": "Nem sikerült elkészíteni a klipet",
|
"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."
|
"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."
|
"body": "Valami hiba történt ennél a letöltésnél. Az alábbi technikai részletek segíthetnek."
|
||||||
},
|
},
|
||||||
"quota_exceeded": {
|
"quota_exceeded": {
|
||||||
"short": "Betelt a tárhely",
|
"short": "Nincs elég 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."
|
"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": {
|
"catalog": {
|
||||||
|
|||||||
@@ -37,7 +37,9 @@
|
|||||||
"dropped": "Eldobva",
|
"dropped": "Eldobva",
|
||||||
"keptSeg": "Megtartott szegmens",
|
"keptSeg": "Megtartott szegmens",
|
||||||
"droppedSeg": "Eldobott szegmens",
|
"droppedSeg": "Eldobott szegmens",
|
||||||
"removeCut": "Vágás törlése",
|
"removeCut": "{{n}}. vágás törlése",
|
||||||
"keptSummary": "{{n}} megtartva · {{dur}}",
|
"keptSummary": "{{n}} megtartva · {{dur}}",
|
||||||
"createJoin": "Összefűzött klip"
|
"createJoin": "Összefűzött klip",
|
||||||
|
"timeline": "Idővonal",
|
||||||
|
"cutN": "{{n}}. vágás"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,5 +38,7 @@
|
|||||||
"cap": {
|
"cap": {
|
||||||
"space": "Szóköz",
|
"space": "Szóköz",
|
||||||
"menu": "Menü"
|
"menu": "Menü"
|
||||||
}
|
},
|
||||||
|
"clip": "Klip mód",
|
||||||
|
"clipMark": "A klip eleje / vége itt"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
"undoAction": "Feloldás",
|
"undoAction": "Feloldás",
|
||||||
"title": "Felfüggeszted a fiókot?",
|
"title": "Felfüggeszted a fiókot?",
|
||||||
"undoTitle": "Feloldod a felfüggesztést?",
|
"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.",
|
"undoConfirm": "Feloldod a felfüggesztést: {{email}}? Újra be tud majd lépni.",
|
||||||
"done": "{{email}} felfüggesztve",
|
"done": "{{email}} felfüggesztve",
|
||||||
"undone": "{{email}} felfüggesztése feloldva",
|
"undone": "{{email}} felfüggesztése feloldva",
|
||||||
|
|||||||
+47
-4
@@ -31,6 +31,11 @@
|
|||||||
--color-fg: var(--fg);
|
--color-fg: var(--fg);
|
||||||
--color-muted: var(--muted);
|
--color-muted: var(--muted);
|
||||||
--color-accent: var(--accent);
|
--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-accent-fg: var(--accent-fg);
|
||||||
--color-info: var(--info);
|
--color-info: var(--info);
|
||||||
--color-info-fg: var(--info-fg);
|
--color-info-fg: var(--info-fg);
|
||||||
@@ -45,6 +50,7 @@
|
|||||||
--color-live: var(--live);
|
--color-live: var(--live);
|
||||||
--color-live-fg: var(--live-fg);
|
--color-live-fg: var(--live-fg);
|
||||||
--color-media-chrome: var(--media-chrome);
|
--color-media-chrome: var(--media-chrome);
|
||||||
|
--color-media-mark: var(--media-mark);
|
||||||
|
|
||||||
--font-sans: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
--font-sans: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
|
|
||||||
@@ -142,6 +148,35 @@
|
|||||||
border-color: var(--edge-strong);
|
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
|
/* 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
|
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
|
one to 110 (owner: "shorter lines are easier to follow"). Only where the container can
|
||||||
@@ -280,9 +315,11 @@
|
|||||||
a:focus-visible,
|
a:focus-visible,
|
||||||
[role="button"]:focus-visible,
|
[role="button"]:focus-visible,
|
||||||
[role="tab"]:focus-visible,
|
[role="tab"]:focus-visible,
|
||||||
|
[role="slider"]:focus-visible,
|
||||||
summary:focus-visible {
|
summary:focus-visible {
|
||||||
/* Solid accent: the earlier 60% mix all but vanished on the dark glass. */
|
/* Solid accent: the earlier 60% mix all but vanished on the dark glass. A scope over another
|
||||||
outline: 2px solid var(--accent);
|
ground (media-scope) may name its own ring colour in --focus. */
|
||||||
|
outline: 2px solid var(--focus, var(--accent));
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1138,6 +1175,11 @@ html[data-theme="light"] {
|
|||||||
--live-fg: #ffffff;
|
--live-fg: #ffffff;
|
||||||
--rating-media: #fcd34d; /* a score on a poster's black scrim */
|
--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 */
|
--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) */
|
/* 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
|
/* …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,
|
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). */
|
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;
|
backdrop-filter: none;
|
||||||
-webkit-backdrop-filter: none;
|
-webkit-backdrop-filter: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,6 +245,40 @@ describe("req — a structured refusal reads in the user's language", () => {
|
|||||||
expect(err.detail).toBeUndefined();
|
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"])(
|
it.each(["quota", "reconnect", "unavailable"])(
|
||||||
"says what to do about an action YouTube refused (%s)",
|
"says what to do about an action YouTube refused (%s)",
|
||||||
async (reason) => {
|
async (reason) => {
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ function localizeDetail(d: unknown): string {
|
|||||||
// "check your input" message rather than leaking them into a translated sentence.
|
// "check your input" message rather than leaking them into a translated sentence.
|
||||||
if (Array.isArray(d)) return t("errors.validation");
|
if (Array.isArray(d)) return t("errors.validation");
|
||||||
const o = d as
|
const o = d as
|
||||||
| { code?: string; reason?: string; limit?: number; min?: number }
|
| { code?: string; reason?: string; limit?: number; limit_s?: number; min?: number }
|
||||||
| null
|
| null
|
||||||
| undefined;
|
| undefined;
|
||||||
if (o?.code === "quota") {
|
if (o?.code === "quota") {
|
||||||
@@ -94,6 +94,13 @@ function localizeDetail(d: unknown): string {
|
|||||||
}
|
}
|
||||||
if (o?.code === "edit")
|
if (o?.code === "edit")
|
||||||
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
|
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")
|
if (o?.code === "cookies")
|
||||||
return t(`downloads.errors.cookies.${o.reason}`, t("downloads.errors.cookies.generic"));
|
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
|
// Password sign-in refusals. The card renders the account-shaped ones as a full panel
|
||||||
|
|||||||
@@ -44,10 +44,22 @@ interface DownloadAsset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type DownloadStatus = "queued" | "running" | "paused" | "done" | "error" | "canceled";
|
export type DownloadStatus = "queued" | "running" | "paused" | "done" | "error" | "canceled";
|
||||||
// "download" (default), "edit" (a per-user trim/crop derivative cut from another job), or "upload"
|
// "download" (default), "edit" (a per-user trim/crop derivative cut from another job), "upload"
|
||||||
// (a locally-uploaded file — no re-fetchable source, so re-download is rejected backend-side).
|
// (a locally-uploaded file) or "clip" (a marked range of a YouTube video or a Plex item, cut straight
|
||||||
// Local (not exported): consumers compare `job.job_kind` against literals, never name the type.
|
// from the source). Only a download has a re-fetchable source; re-download refuses the rest.
|
||||||
export type DownloadJobKind = "download" | "edit" | "upload";
|
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
|
// 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).
|
// 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)
|
extra_links: string[]; // extra reference URLs (beyond source_url)
|
||||||
job_kind?: DownloadJobKind; // see DownloadJobKind; absent reads as "download"
|
job_kind?: DownloadJobKind; // see DownloadJobKind; absent reads as "download"
|
||||||
source_job_id?: number | null; // parent download an edit was cut from
|
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;
|
can_download: boolean;
|
||||||
expired: boolean;
|
expired: boolean;
|
||||||
asset: DownloadAsset | null;
|
asset: DownloadAsset | null;
|
||||||
@@ -226,6 +239,10 @@ export const downloadsApi = {
|
|||||||
display_name?: string;
|
display_name?: string;
|
||||||
}): Promise<DownloadJob> =>
|
}): Promise<DownloadJob> =>
|
||||||
req("/api/downloads/edit", { method: "POST", body: JSON.stringify(body) }),
|
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> =>
|
downloadStoryboard: (id: number): Promise<StoryboardMeta> =>
|
||||||
req(`/api/downloads/${id}/storyboard`),
|
req(`/api/downloads/${id}/storyboard`),
|
||||||
downloads: (): Promise<DownloadJob[]> => req("/api/downloads"),
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
DEFAULT_CLIP_S,
|
||||||
|
MAX_CLIP_S,
|
||||||
|
handleBounds,
|
||||||
|
markIn,
|
||||||
|
markOut,
|
||||||
|
openAt,
|
||||||
|
zoomWindow,
|
||||||
|
} from "./clipRange";
|
||||||
|
|
||||||
|
const D = 3600;
|
||||||
|
|
||||||
|
describe("clipRange", () => {
|
||||||
|
it("opens a default-length range at the playhead, shorter at the item's end", () => {
|
||||||
|
expect(openAt(100, D)).toEqual({ start: 100, end: 100 + DEFAULT_CLIP_S });
|
||||||
|
expect(openAt(3590, D)).toEqual({ start: 3590, end: 3600 });
|
||||||
|
expect(openAt(3600, D)).toEqual({ start: 3599, end: 3600 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves In alone while the range stays one the server takes", () => {
|
||||||
|
expect(markIn({ start: 100, end: 130 }, 90, D)).toEqual({ start: 90, end: 130 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries Out along when In passes it, and keeps the cap when In goes too far back", () => {
|
||||||
|
expect(markIn({ start: 100, end: 130 }, 200, D)).toEqual({ start: 200, end: 230 });
|
||||||
|
expect(markIn({ start: 1000, end: 1030 }, 100, D)).toEqual({
|
||||||
|
start: 100,
|
||||||
|
end: 100 + MAX_CLIP_S,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries In along when Out goes before it or too far ahead", () => {
|
||||||
|
expect(markOut({ start: 100, end: 130 }, 150, D)).toEqual({ start: 100, end: 150 });
|
||||||
|
expect(markOut({ start: 100, end: 130 }, 50, D)).toEqual({ start: 20, end: 50 });
|
||||||
|
expect(markOut({ start: 100, end: 130 }, 1000, D)).toEqual({ start: 400, end: 1000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every marked range is at least a second and at most the cap", () => {
|
||||||
|
for (const [r, t] of [
|
||||||
|
[{ start: 0, end: 30 }, 0],
|
||||||
|
[{ start: 0, end: 30 }, 3600],
|
||||||
|
[{ start: 3000, end: 3600 }, 0],
|
||||||
|
] as const) {
|
||||||
|
for (const next of [markIn(r, t, D), markOut(r, t, D)]) {
|
||||||
|
expect(next.end - next.start).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(next.end - next.start).toBeLessThanOrEqual(MAX_CLIP_S);
|
||||||
|
expect(next.start).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(next.end).toBeLessThanOrEqual(D);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds each handle so a drag never breaks the range", () => {
|
||||||
|
expect(handleBounds({ start: 100, end: 130 }, D)).toEqual({
|
||||||
|
in: { min: 0, max: 129 },
|
||||||
|
out: { min: 101, max: 700 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zooms to the range with room either side, inside the item", () => {
|
||||||
|
expect(zoomWindow({ start: 100, end: 140 }, D)).toEqual({ from: 90, to: 150 });
|
||||||
|
expect(zoomWindow({ start: 2, end: 10 }, D)).toEqual({ from: 0, to: 15 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,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);
|
||||||
|
}
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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)", () => {
|
describe("contentRect (display-px letterbox mapping)", () => {
|
||||||
it("returns the full box when box aspect matches the video aspect", () => {
|
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();
|
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) });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -46,3 +46,88 @@ export function cropToSourcePx(crop: Frac, srcW: number, srcH: number): CropPx |
|
|||||||
const y = Math.min(evenOff(crop.y * srcH), srcH - h);
|
const y = Math.min(evenOff(crop.y * srcH), srcH - h);
|
||||||
return { x, y, w, 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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ describe("libraryRowActions", () => {
|
|||||||
|
|
||||||
it("an edit clip cannot be re-downloaded", () => {
|
it("an edit clip cannot be re-downloaded", () => {
|
||||||
expect(libraryRowActions(job({ job_kind: "edit" }, {})).redownload).toBe(false);
|
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", () => {
|
it("edit is hidden when the media has no duration", () => {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function libraryRowActions(job: DownloadJob): LibraryRowActions {
|
|||||||
play: media,
|
play: media,
|
||||||
edit: media && job.can_download && hasDuration(job),
|
edit: media && job.can_download && hasDuration(job),
|
||||||
editDetails: media,
|
editDetails: media,
|
||||||
redownload: job.job_kind !== "edit" && job.job_kind !== "upload",
|
redownload: !job.job_kind || job.job_kind === "download",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ describe("failTitleKey", () => {
|
|||||||
await i18n.changeLanguage("en");
|
await i18n.changeLanguage("en");
|
||||||
expect(failTitleKey({ job_kind: "upload" })).toBe("downloads.errors.fail.titleUpload");
|
expect(failTitleKey({ job_kind: "upload" })).toBe("downloads.errors.fail.titleUpload");
|
||||||
expect(failTitleKey({ job_kind: "edit" })).toBe("downloads.errors.fail.titleEdit");
|
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({ job_kind: "download" })).toBe("downloads.errors.fail.title");
|
||||||
expect(failTitleKey({})).toBe("downloads.errors.fail.title");
|
expect(failTitleKey({})).toBe("downloads.errors.fail.title");
|
||||||
for (const lng of ["en", "hu"]) {
|
for (const lng of ["en", "hu"]) {
|
||||||
@@ -80,6 +81,44 @@ describe("failTitleKey", () => {
|
|||||||
expect(i18n.t(failCopyKeys(download, "body"))).toBe(
|
expect(i18n.t(failCopyKeys(download, "body"))).toBe(
|
||||||
i18n.t("downloads.errors.fail.cat.quota_exceeded.body"),
|
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 () => {
|
it("an upload's own failures read as an upload's", async () => {
|
||||||
await i18n.changeLanguage("en");
|
await i18n.changeLanguage("en");
|
||||||
for (const code of ["convert_failed", "upload_missing"]) {
|
for (const code of ["convert_failed", "upload_missing"]) {
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ export function failCopyKeys(
|
|||||||
job: Pick<DownloadJob, "error_code" | "error_detail" | "job_kind">,
|
job: Pick<DownloadJob, "error_code" | "error_detail" | "job_kind">,
|
||||||
part: "short" | "body",
|
part: "short" | "body",
|
||||||
): string[] {
|
): 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) => [
|
const category = (code: string) => [
|
||||||
...(kind === "download" ? [] : [`downloads.errors.fail.${kind}.${code}.${part}`]),
|
...(kind === "download" ? [] : [`downloads.errors.fail.${kind}.${code}.${part}`]),
|
||||||
`downloads.errors.fail.cat.${code}.${part}`,
|
`downloads.errors.fail.cat.${code}.${part}`,
|
||||||
@@ -30,6 +31,7 @@ const TITLE_KEYS: Record<DownloadJobKind, string> = {
|
|||||||
download: "downloads.errors.fail.title",
|
download: "downloads.errors.fail.title",
|
||||||
upload: "downloads.errors.fail.titleUpload", // its failure is a conversion, not a download
|
upload: "downloads.errors.fail.titleUpload", // its failure is a conversion, not a download
|
||||||
edit: "downloads.errors.fail.titleEdit", // a clip being cut
|
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. */
|
/** The title over a failed job's explanation, worded for what the job was making. */
|
||||||
|
|||||||
@@ -41,6 +41,19 @@ describe("matches", () => {
|
|||||||
expect(matches(press({ key: "k", ctrlKey: true, altKey: true }), "Mod+k")).toBe(false);
|
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", () => {
|
it("treats Mod as Ctrl or Cmd", () => {
|
||||||
expect(matches(press({ key: "k", ctrlKey: true }), "Mod+k")).toBe(true);
|
expect(matches(press({ key: "k", ctrlKey: true }), "Mod+k")).toBe(true);
|
||||||
expect(matches(press({ key: "k", metaKey: true }), "Mod+k")).toBe(true);
|
expect(matches(press({ key: "k", metaKey: true }), "Mod+k")).toBe(true);
|
||||||
@@ -111,10 +124,23 @@ describe("dispatch", () => {
|
|||||||
subtitle: noop,
|
subtitle: noop,
|
||||||
info: noop,
|
info: noop,
|
||||||
stats: noop,
|
stats: noop,
|
||||||
|
clip: () => calls.push("clip"),
|
||||||
|
markIn: () => calls.push("markIn"),
|
||||||
|
markOut: () => calls.push("markOut"),
|
||||||
back: () => calls.push("back"),
|
back: () => calls.push("back"),
|
||||||
escape: () => calls.push("escape"),
|
escape: () => calls.push("escape"),
|
||||||
}) as const;
|
}) 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", () => {
|
it("routes each arrow variant to its own id", () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const h = plexHandlers(calls);
|
const h = plexHandlers(calls);
|
||||||
|
|||||||
@@ -166,6 +166,11 @@ export const SHORTCUTS = [
|
|||||||
{ id: "nextVideo", scope: "ytPlayer", keys: ["Shift+ArrowRight"], labelKey: "keys.video" },
|
{ id: "nextVideo", scope: "ytPlayer", keys: ["Shift+ArrowRight"], labelKey: "keys.video" },
|
||||||
{ id: "volumeUp", scope: "ytPlayer", keys: ["ArrowUp"], labelKey: "keys.volume" },
|
{ id: "volumeUp", scope: "ytPlayer", keys: ["ArrowUp"], labelKey: "keys.volume" },
|
||||||
{ id: "volumeDown", scope: "ytPlayer", keys: ["ArrowDown"], 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",
|
id: "close",
|
||||||
scope: "ytPlayer",
|
scope: "ytPlayer",
|
||||||
@@ -202,6 +207,9 @@ export const SHORTCUTS = [
|
|||||||
{ id: "subtitle", scope: "plexPlayer", keys: ["s"], labelKey: "keys.subtitle" },
|
{ id: "subtitle", scope: "plexPlayer", keys: ["s"], labelKey: "keys.subtitle" },
|
||||||
{ id: "info", scope: "plexPlayer", keys: ["i"], labelKey: "keys.info" },
|
{ id: "info", scope: "plexPlayer", keys: ["i"], labelKey: "keys.info" },
|
||||||
{ id: "stats", scope: "plexPlayer", keys: ["d"], labelKey: "keys.stats" },
|
{ 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
|
// 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
|
// 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.
|
// 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";
|
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 {
|
function keyMatches(e: KeyboardEvent, name: string): boolean {
|
||||||
if (name === "Space") return e.key === " " || e.code === "Space";
|
if (name === "Space") return e.key === " " || e.code === "Space";
|
||||||
if (name.length === 1) return e.key.toLowerCase() === name.toLowerCase();
|
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 parts = chord.split("+");
|
||||||
const key = parts[parts.length - 1] ?? "";
|
const key = parts[parts.length - 1] ?? "";
|
||||||
const mods = parts.slice(0, -1).map((m) => m.toLowerCase());
|
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.ctrlKey || e.metaKey) !== mods.includes("mod")) return false;
|
||||||
if (e.shiftKey !== mods.includes("shift")) return false;
|
if (e.shiftKey !== mods.includes("shift")) return false;
|
||||||
if (e.altKey !== mods.includes("alt")) return false;
|
if (e.altKey !== mods.includes("alt")) return false;
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function mountPlayer(setPlayerError: (code: number | null) => void) {
|
|||||||
setPlayerError,
|
setPlayerError,
|
||||||
setLiveData: () => {},
|
setLiveData: () => {},
|
||||||
reapplyStored: () => {},
|
reapplyStored: () => {},
|
||||||
focusModal: () => {},
|
reclaimFocus: () => {},
|
||||||
advanceOnEnd: () => {},
|
advanceOnEnd: () => {},
|
||||||
sessionPosRef: { current: new Map() },
|
sessionPosRef: { current: new Map() },
|
||||||
qc: new QueryClient(),
|
qc: new QueryClient(),
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ function mountPlayer(qc: QueryClient, sessionPos: Map<string, number>) {
|
|||||||
setPlayerError: () => {},
|
setPlayerError: () => {},
|
||||||
setLiveData: () => {},
|
setLiveData: () => {},
|
||||||
reapplyStored: () => {},
|
reapplyStored: () => {},
|
||||||
focusModal: () => {},
|
reclaimFocus: () => {},
|
||||||
advanceOnEnd: () => {},
|
advanceOnEnd: () => {},
|
||||||
sessionPosRef: { current: sessionPos },
|
sessionPosRef: { current: sessionPos },
|
||||||
qc,
|
qc,
|
||||||
|
|||||||
@@ -81,11 +81,11 @@ function loadYouTubeApi(): Promise<YTNamespace> {
|
|||||||
export function useYtVolume({
|
export function useYtVolume({
|
||||||
playerRef,
|
playerRef,
|
||||||
dialogRef,
|
dialogRef,
|
||||||
focusModal,
|
reclaimFocus,
|
||||||
}: {
|
}: {
|
||||||
playerRef: RefObject<YTPlayer | null>;
|
playerRef: RefObject<YTPlayer | null>;
|
||||||
dialogRef: RefObject<HTMLDivElement | null>;
|
dialogRef: RefObject<HTMLDivElement | null>;
|
||||||
focusModal: () => void;
|
reclaimFocus: () => void;
|
||||||
}): {
|
}): {
|
||||||
/** Level (0–100) to flash in the on-player overlay, or null when hidden. */
|
/** Level (0–100) to flash in the on-player overlay, or null when hidden. */
|
||||||
volumeUi: number | null;
|
volumeUi: number | null;
|
||||||
@@ -168,7 +168,7 @@ export function useYtVolume({
|
|||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
e.preventDefault();
|
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);
|
nudgeVolume(e.deltaY < 0 ? 5 : -5);
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -204,11 +204,11 @@ export function useYtVolume({
|
|||||||
export function useNativeControlsYield({
|
export function useNativeControlsYield({
|
||||||
playerRef,
|
playerRef,
|
||||||
stageRef,
|
stageRef,
|
||||||
focusModal,
|
reclaimFocus,
|
||||||
}: {
|
}: {
|
||||||
playerRef: RefObject<YTPlayer | null>;
|
playerRef: RefObject<YTPlayer | null>;
|
||||||
stageRef: RefObject<HTMLDivElement | null>;
|
stageRef: RefObject<HTMLDivElement | null>;
|
||||||
focusModal: () => void;
|
reclaimFocus: () => void;
|
||||||
}): {
|
}): {
|
||||||
nativeControls: boolean;
|
nativeControls: boolean;
|
||||||
setNativeControls: (on: boolean) => void;
|
setNativeControls: (on: boolean) => void;
|
||||||
@@ -221,7 +221,7 @@ export function useNativeControlsYield({
|
|||||||
});
|
});
|
||||||
const rearmOurControls = useEffectEvent(() => {
|
const rearmOurControls = useEffectEvent(() => {
|
||||||
setNativeControls(false);
|
setNativeControls(false);
|
||||||
focusModal();
|
reclaimFocus();
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// activeElement settles on the tick after blur, so defer the check.
|
// activeElement settles on the tick after blur, so defer the check.
|
||||||
@@ -249,11 +249,11 @@ export function useNativeControlsYield({
|
|||||||
export function useYtBigView({
|
export function useYtBigView({
|
||||||
stageRef,
|
stageRef,
|
||||||
playerRef,
|
playerRef,
|
||||||
focusModal,
|
reclaimFocus,
|
||||||
}: {
|
}: {
|
||||||
stageRef: RefObject<HTMLDivElement | null>;
|
stageRef: RefObject<HTMLDivElement | null>;
|
||||||
playerRef: RefObject<YTPlayer | null>;
|
playerRef: RefObject<YTPlayer | null>;
|
||||||
focusModal: () => void;
|
reclaimFocus: () => void;
|
||||||
}): {
|
}): {
|
||||||
isFullscreen: boolean;
|
isFullscreen: boolean;
|
||||||
maximized: boolean;
|
maximized: boolean;
|
||||||
@@ -299,7 +299,7 @@ export function useYtBigView({
|
|||||||
const ours = !!fsEl && (fsEl === stageRef.current || fsEl === frame);
|
const ours = !!fsEl && (fsEl === stageRef.current || fsEl === frame);
|
||||||
isFullscreenRef.current = ours; // read by the once-bound keydown handler
|
isFullscreenRef.current = ours; // read by the once-bound keydown handler
|
||||||
setIsFullscreen(ours); // drives the overlay's pointer-events
|
setIsFullscreen(ours); // drives the overlay's pointer-events
|
||||||
if (!fsEl) focusModal();
|
if (!fsEl) reclaimFocus();
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return onFullscreenChange(() => syncFullscreen()); // both spellings — see lib/fullscreen
|
return onFullscreenChange(() => syncFullscreen()); // both spellings — see lib/fullscreen
|
||||||
@@ -348,7 +348,7 @@ export function useYtProgress({
|
|||||||
setPlayerError,
|
setPlayerError,
|
||||||
setLiveData,
|
setLiveData,
|
||||||
reapplyStored,
|
reapplyStored,
|
||||||
focusModal,
|
reclaimFocus,
|
||||||
advanceOnEnd,
|
advanceOnEnd,
|
||||||
sessionPosRef,
|
sessionPosRef,
|
||||||
qc,
|
qc,
|
||||||
@@ -364,7 +364,7 @@ export function useYtProgress({
|
|||||||
setPlayerError: (code: number | null) => void;
|
setPlayerError: (code: number | null) => void;
|
||||||
setLiveData: (d: { title?: string; author?: string } | null) => void;
|
setLiveData: (d: { title?: string; author?: string } | null) => void;
|
||||||
reapplyStored: () => void;
|
reapplyStored: () => void;
|
||||||
focusModal: () => void;
|
reclaimFocus: () => void;
|
||||||
advanceOnEnd: () => void;
|
advanceOnEnd: () => void;
|
||||||
// Positions this player session has actually STORED, per video id. Written from the server's
|
// 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
|
// 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,
|
setPlayerError,
|
||||||
setLiveData,
|
setLiveData,
|
||||||
reapplyStored,
|
reapplyStored,
|
||||||
focusModal,
|
reclaimFocus,
|
||||||
advanceOnEnd,
|
advanceOnEnd,
|
||||||
qc,
|
qc,
|
||||||
}));
|
}));
|
||||||
@@ -396,7 +396,7 @@ export function useYtProgress({
|
|||||||
setPlayerError,
|
setPlayerError,
|
||||||
setLiveData,
|
setLiveData,
|
||||||
reapplyStored,
|
reapplyStored,
|
||||||
focusModal,
|
reclaimFocus,
|
||||||
advanceOnEnd,
|
advanceOnEnd,
|
||||||
qc,
|
qc,
|
||||||
} = itemSnapshot();
|
} = itemSnapshot();
|
||||||
@@ -458,7 +458,7 @@ export function useYtProgress({
|
|||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
onReady: () => {
|
onReady: () => {
|
||||||
focusModal();
|
reclaimFocus();
|
||||||
reapplyStored();
|
reapplyStored();
|
||||||
},
|
},
|
||||||
onStateChange: (e: YTPlayerEvent) => {
|
onStateChange: (e: YTPlayerEvent) => {
|
||||||
@@ -554,7 +554,7 @@ export function useYtProgress({
|
|||||||
// and refs (which never change).
|
// and refs (which never change).
|
||||||
//
|
//
|
||||||
// The render values the body uses — `resumeAt`, `setWatched`, `advanceOnEnd`, `reapplyStored`,
|
// 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
|
// 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
|
// 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
|
// 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
Reference in New Issue
Block a user