Compare commits

...
27 Commits
Author SHA1 Message Date
peter 5a93a9a8e7 release: 0.117.0 (improvement/e57-files-and-small-ideas) 2026-11-06 03:09:58 +01:00
peter 911fea3243 release: 0.117.0 2026-11-06 02:57:39 +01:00
peter 4aece8aff7 docs(design): log the Hungarian unlock button's noun form 2026-11-06 02:46:27 +01:00
peter adb5e94511 fix(watch): the Hungarian unlock button says a noun for a video too, as for a file
"Megnézem" (a first-person verb) and "Megnyitás" (a noun) labelled the same button by link kind;
the app's Hungarian buttons are nouns, so a video's reads "Megtekintés".
2026-11-06 02:46:18 +01:00
peter 673decf4d4 fix(channels): a pick in the +N fold keeps the focus on its chip after the fold closes
The closing popover hands the focus back to "+N" in a microtask it queues as it unmounts, after the
chip was focused (seen in Chrome; jsdom does not show it). A second focus, queued after it, wins.
2026-11-06 02:32:49 +01:00
peter 4d1cf66d96 fix: tag counts, chip focus, upload naming, share links and unsized clips hold after round 2
- A tag counts the followed channels the Subscriptions tab shows: hidden ones are left out, and a
  subscribe, unsubscribe, hide or tag toggle refreshes the count. The Tag manager's row and its
  hover list count the same channels as the chip; /api/tags sends both counts under their names.
- A chip's count keeps the chip's colour after a dot (a dimmed count fell under 4.5:1). A pick in
  the open "+N" fold closes it; a chip that leaves the list hands the focus to the one in its place.
  The fold is a dialog of toggle buttons, not an empty menu.
- Upload: a folder name left by a press is made once the press is over, if the dialog is still
  open, instead of guessing a dismissal from Modal's markup. While the folder is made, its place
  holds the focus and a live region says so.
- Share: an expired link has no Copy and a warn badge; the link made on open goes into the cached
  list, so a reopen makes no second one; the New link form survives the link made on open.
- Watch: a repeated Enter sends one unlock while one is on its way. See all buttons are named with
  their rail.
- An unsized clip of a rotated file gets the upright size its crop cut.
2026-11-06 02:20:52 +01:00
peter 2baad2ebae fix(files): a folder made from the upload dialog gives the keyboard back the target once it is made 2026-11-06 00:23:09 +01:00
peter 8383459f70 fix: share, upload, crop, auto-skip, tag chips and See all hold what they claim
- Share: a link that expired is never copied (the dialog and one-click Share pick a live one,
  else make one), says it expired, and the copy-on-open claim clears on the next copy.
- A password-protected file link asks to open a file, not to watch a video.
- Upload: Cancel, the close button or the backdrop while naming a folder makes no folder; a failed
  refetch keeps the target; a folder being made says so.
- Crop: an infinite number is dropped, not a 500; an unprobed clip's size follows a shown-size crop;
  the edit_spec docs name frame_w/frame_h; the join and rotated paths are tested.
- Auto-skip: the wait gives way on a marker too short for it, so the skip still fires.
- Tag chips count the followed channels (subscribed_count) and a toggled chip keeps the focus.
- Continue watching's See all sorts by last watched when in progress was already on.
2026-11-06 00:09:39 +01:00
peter c34f6f3297 fix(channels): picked tag chips lead the row, so the +N fold never hides an active filter 2026-11-05 22:34:12 +01:00
peter e4b6d918e4 fix(plex,share): Recently added's See all opens newest first, and a share link says open
See all on Recently added opened Browse in whatever sort was last used, by title on the account it
was seen on. The share dialog said anyone can watch a link that may hold a zip.
2026-11-04 19:43:02 +01:00
peter bf5cf011ce feat: tag chips count their channels, Plex shows a file's size, a skip waits, Continue watching opens whole
The channel manager's tag chips carry a muted count. A Plex item's Info page names the file's size.
An auto-skip's countdown starts 3 s into the marker, with its button there from the start and a delay of
0 still immediate. Continue watching has a See all, opening Browse on in progress, last watched first;
Recently added's sets the watch state back so it opens what it says.
2026-11-04 19:27:46 +01:00
peter 6e6e47652a feat(files): the upload dialog says where the file goes, and sharing copies on open
The upload dialog shows its target folder and can change it, with New folder in the same list; a
file never goes while a folder is being named or made. The share dialog puts the link on the clipboard
when it opens, says so only if the browser let it, and has Open in new tab beside Copy. A file's
format joins the line under its name.
2026-11-04 19:16:20 +01:00
peter 4c09a22add feat(share): a link's address says what it opens
A video stays on /watch/, anything else is given out on /file/ and a folder on /folder/. The token
is the whole key, so every path opens every link and the links already sent keep working. The new
paths are kept out of search indexes like /watch/, boot the player alone, and the unfurl card calls a
file a file.
2026-11-04 19:10:09 +01:00
peter 2bdf1ec76f fix(editor): a crop of a video with wide pixels keeps what its frame showed
The browser reports such a video by its shown size and ffmpeg's crop counts stored pixels, so a
frame over the middle half of a 320 stored, 640 shown video kept the whole width. A crop now carries
the size it was measured on and is scaled to the file's own pixels. A spec from before it did is cut
as it always was. Also drops a stale note in downloads/quota.py: edit jobs are held to the cap.
2026-11-04 19:01:49 +01:00
peter f7e036abef merge: e2e page loads retried on a machine out of buffers (improvement/e2e-goto-retry) 2026-11-04 00:53:23 +01:00
peter f2de8f73aa test(e2e): a page load the machine had no buffer for is tried again
net::ERR_NO_BUFFER_SPACE stopped a release at its first page with nothing else running, and the
unchanged rerun passed. Every page load of the suite now goes through one helper, held by a test.
2026-11-04 00:26:50 +01:00
peter 643f305814 release: 0.116.3 (bug/lost-stylesheet) 2026-11-04 00:19:42 +01:00
peter cbe1b3bd63 chore(release): 0.116.3 2026-11-04 00:05:30 +01:00
peter ae775b383c fix(boot): a stylesheet lost on the way is asked for again
A lost stylesheet left the page unstyled until a reload, and every e2e step that finds elements by
testid still passed on it. Only axe noticed: the one-off target-size failure on eight scattered
controls. Losing the main stylesheet on purpose gives those same eight.
2026-11-03 23:32:40 +01:00
peter 358d5af70b release: 0.116.2 (bug/e56-hls-reap) 2026-11-03 21:51:59 +01:00
peter c6831090b5 chore(release): 0.116.2 2026-11-03 21:35:19 +01:00
peter 38fc95d772 docs(plex): a quiet reclaim's replay is named as accepted 2026-11-03 21:22:17 +01:00
peter 3f6638bf96 fix(plex): a seek during a quiet reclaim is no longer undone
A quiet request leaves `live` alone, so a seek inside the old session's
range went native and the answer then jumped back to where it asked from.
`seek` now also goes by `pending`; the server-away retry had the same hole.

Comments that still named 404 for a lost session say 410, and the hls.js
retry comment now tells a finished playlist from a growing one.
2026-11-03 21:10:51 +01:00
peter 469c8aa481 test(e2e): the drag onto the rail waits for the rail's lists 2026-11-03 20:31:41 +01:00
peter df2997e9f3 fix(plex): a lost HLS session is reclaimed at the first answer
The route answers 410 for a session it no longer has and keeps 404 for a segment not there yet.
hls.js retries even a 4xx on a stream with one video variant, so the player waited about half a
minute before asking again, and minutes in a throttled page.
2026-11-03 19:20:22 +01:00
peter f69725503f fix(plex): a finished HLS session outlives a throttled heartbeat 2026-11-03 19:15:18 +01:00
peter d5bc7e8031 feat(plex): every HLS session reap logs its rule and idle time 2026-11-03 18:35:33 +01:00
95 changed files with 3003 additions and 376 deletions
+25
View File
@@ -7,6 +7,31 @@ edit the release notes and re-run the generator.
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
## 0.117.0 — 2026-11-06
### New
- A share link's address now says what it opens: /file/ for a file, /folder/ for a folder, /watch/ for a video. Links you already sent keep working.
- The upload dialog shows which folder the file goes to, and you can pick another one or make a new folder there. Opening Share copies a link at once.
- The Channels tag chips show how many followed channels each holds. Continue watching in Plex has a See all, and a Plex item's Info shows the file size.
### Fixed
- An expired share link is marked as expired and is never copied. A crop of a video with wide pixels keeps exactly what its frame showed.
- A Plex auto-skip waits 3 seconds into the marker before its countdown starts.
## 0.116.3 — 2026-11-04
### Fixed
- If a stylesheet is lost on the way, the app now asks for it again, so a poor connection no longer leaves the page unstyled until a reload.
## 0.116.2 — 2026-11-03
### Fixed
- A Plex video paused or muted in a background tab no longer loses its session after a minute, and if a session is lost anyway, playback comes back at once instead of after half a minute.
## 0.116.1 — 2026-11-03
### Fixed
+1 -1
View File
@@ -74,7 +74,7 @@ only along a declared edge and writes them only through that unit's service; cor
cannot (an ORM write on another unit's loaded row, SQL in `text()`, a commit by `begin()`) is review's to catch, listed in
[`backend/app/README.md`](backend/app/README.md) with the conventions.
Frontend, by folder under `src/`: `main.tsx` provider tree → `shell/` (`App`, header, nav rail, palette,
boot; a shared `/watch` link boots from its own entry, `watch.html` → `watchMain.tsx` → `watch/`, held
boot; a share link (`/watch`, `/file`, `/folder`) boots from its own entry, `watch.html` → `watchMain.tsx` → `watch/`, held
app-free by a contract test) → **`modules/<page>/`** (one folder per page: the page, its rail, its store, its tests,
and its `module.ts` definition — label, icon, gate, the lazy page and rail, its store — collected in nav order by
`features/navigation/moduleRegistry`, which also mounts every store at the root; a new page is its folder, its id in
+1 -1
View File
@@ -1 +1 @@
0.116.1
0.117.0
+10
View File
@@ -201,6 +201,16 @@ def _ratio(v: object) -> float | None:
return n / d if n > 0 and d > 0 else None
def quarter_turned(probe: dict) -> bool | None:
"""Whether the first video stream of an ffprobe `-show_streams` JSON is stored turned a quarter
(ffmpeg stands it upright before any filter, so its filters see width and height swapped). None
when there is no video stream."""
for s in probe.get("streams") or []:
if s.get("codec_type") == "video":
return _quarter_turned(s)
return None
def _quarter_turned(stream: dict) -> bool:
rotation: object = (stream.get("tags") or {}).get("rotate")
for sd in stream.get("side_data_list") or []:
+48 -5
View File
@@ -11,7 +11,9 @@ with the worker branching on the asset's `source_kind` to run ffmpeg instead of
(the `-ss` seeks first), so a short clip is cheap even from a long source
* crop (± trim) → always re-encode (a filter can't be stream-copied), frame-accurate
`edit_spec = {trim?: {start_s, end_s}, crop?: {x, y, w, h}, accurate?: bool}`. `accurate` is the
`edit_spec = {trim?: {start_s, end_s}, crop?: {x, y, w, h, frame_w?, frame_h?}, accurate?: bool}`. A
crop with `frame_w`/`frame_h` counts the picture as it is SHOWN, measured on a frame of that size
(`crop_filter`); one without them is in the file's stored pixels (its older form). `accurate` is the
per-edit user choice for a trim-only cut (ignored when a crop forces a re-encode anyway). `split`
is a frontend concept: the
UI fans a split out into N trim jobs, so there is one output file per job here.
@@ -60,6 +62,46 @@ class EditAborted(Exception):
_MAX_SEGMENTS = 100
def _crop_frame(crop: dict) -> dict | None:
"""The picture size a crop was measured on, or None when it has none it could be scaled by (a
spec from before it was sent, which `crop_filter` cuts in the file's own pixels)."""
try:
w, h = int(crop["frame_w"]), int(crop["frame_h"])
# silent-ok: no frame is a crop's older form, and it is still the crop that was asked for. An
# infinite number (JSON `1e400`) is OverflowError, and it is no more a frame than "x" is.
except (KeyError, TypeError, ValueError, OverflowError):
return None
return {"frame_w": w, "frame_h": h} if w > 0 and h > 0 else None
def crop_filter(crop: dict) -> str:
"""The ffmpeg `crop` of an edit. Its numbers count the picture as the browser reports it, which
is its SHOWN size: a video with wide pixels (320 stored, 640 shown) would be cut off to one side
in ffmpeg's stored ones, so they are scaled to `iw` / `ih` by the size they were measured on. A
rotated file needs nothing more: both the browser and ffmpeg's filters see it upright."""
frame_w, frame_h = crop.get("frame_w"), crop.get("frame_h")
if not frame_w or not frame_h:
return f"crop={crop['w']}:{crop['h']}:{crop['x']}:{crop['y']}"
return (
f"crop=iw*{crop['w']}/{frame_w}:ih*{crop['h']}/{frame_h}"
f":iw*{crop['x']}/{frame_w}:ih*{crop['y']}/{frame_h}"
)
def crop_stored_size(crop: dict, src_w: int | None, src_h: int | None) -> tuple[int, int] | None:
"""The size `crop_filter` cuts out of a `src_w` x `src_h` frame, evened as `EVEN_FRAME` evens it —
the clip's size when ffprobe cannot tell. The frame is the one ffmpeg's filters see: a rotated
file's stood upright, its stored size turned (the caller's to turn). A crop counted in the shown
picture is a fraction of that frame, not its own pixel count; None when the size is unknown."""
if crop.get("frame_w") and crop.get("frame_h"):
if not src_w or not src_h:
return None
w, h = src_w * crop["w"] // crop["frame_w"], src_h * crop["h"] // crop["frame_h"]
else:
w, h = crop["w"], crop["h"]
return w // 2 * 2, h // 2 * 2
def normalize_edit_spec(spec: dict | None) -> dict:
"""Canonicalize a raw edit spec: clamp/round trim, coerce crop to ints, drop empties.
@@ -72,7 +114,7 @@ def normalize_edit_spec(spec: dict | None) -> dict:
if crop:
try:
c = {k: int(round(float(crop[k]))) for k in ("x", "y", "w", "h")}
except (KeyError, TypeError, ValueError) as exc:
except (KeyError, TypeError, ValueError, OverflowError) as exc:
# The clip still renders — uncropped. The user asked for a crop, got a whole frame,
# and nothing anywhere called it a failure; this line is the only trace there is.
log.warning(
@@ -81,7 +123,8 @@ def normalize_edit_spec(spec: dict | None) -> dict:
)
c = None
if c and c["w"] > 0 and c["h"] > 0 and c["x"] >= 0 and c["y"] >= 0:
out["crop"] = c
frame = _crop_frame(crop)
out["crop"] = {**c, **frame} if frame else c
# Multi-segment JOIN (cut-list → one concatenated file). Takes precedence over `trim`.
segs = spec.get("segments")
@@ -268,7 +311,7 @@ def build_edit_cmd(src: Path, dest: Path, spec: dict, out_ext: str) -> list[str]
if reencode:
# Input -ss + re-encode is frame-accurate (decodes from the prior keyframe, discards up to
# the exact cut). A crop adds the filter; an accurate trim only evens the frame.
cropf = f"crop={crop['w']}:{crop['h']}:{crop['x']}:{crop['y']}," if crop else ""
cropf = f"{crop_filter(crop)}," if crop else ""
cmd += ["-vf", cropf + EVEN_FRAME, *X264_ARGS, *AAC_ARGS]
else:
cmd += ["-c", "copy", "-avoid_negative_ts", "make_zero"]
@@ -313,7 +356,7 @@ def build_concat_plan(src: Path, dest: Path, spec: dict, out_ext: str, staging:
]
if needs_reencode(spec):
cropf = f",crop={crop['w']}:{crop['h']}:{crop['x']}:{crop['y']}" if crop else ""
cropf = f",{crop_filter(crop)}" if crop else ""
parts, labels = [], []
for i, s in enumerate(segs):
st, en = s["start_s"], s["end_s"]
+23 -2
View File
@@ -127,14 +127,35 @@ def check_grant(token: str, grant: str | None, password_version: int) -> bool:
# --- serialization -------------------------------------------------------------------------
def owner_view(link: DownloadLink, base_url: str = "", token: str | None = None) -> dict:
def opens(db: Session, link: DownloadLink) -> str:
"""What a link opens, in the words its address and its preview use: "folder", "video" or
"file". A file still being made reads as the video it mostly is."""
from app.downloads import mediakind
if link.folder_id is not None:
return "folder"
job = db.get(DownloadJob, link.job_id)
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
kind = asset.media_kind if asset else None
return "file" if kind and kind != mediakind.VIDEO else "video"
def share_path(db: Session, link: DownloadLink, token: str) -> str:
"""The address a link is given out on: its path says what it opens (`opens`). Only a word for the
reader, the token is the whole key: every prefix of `main.SHARE_PREFIXES` opens every link, so
the links already sent on /watch/ work for good."""
prefix = {"folder": "folder", "file": "file", "video": "watch"}[opens(db, link)]
return f"/{prefix}/{token}"
def owner_view(db: Session, link: DownloadLink, token: str | None = None) -> dict:
"""What the file owner sees when managing their links. `token` is the one just made (it is in
hand even where no copy is kept); else it is read back, and `url` is None when it cannot be."""
token = token or recover(link)
return {
"id": link.id,
"token": token,
"url": f"{base_url}/watch/{token}" if token else None,
"url": share_path(db, link, token) if token else None,
"allow_download": link.allow_download,
"has_password": link.password_hash is not None,
"expires_at": link.expires_at.isoformat() if link.expires_at else None,
+3 -1
View File
@@ -234,7 +234,9 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
source_asset_id: Mapped[int | None] = mapped_column(
ForeignKey("media_assets.id", ondelete="SET NULL")
)
# For edit jobs: the trim/crop recipe {trim?:{start_s,end_s}, crop?:{x,y,w,h}} in pixels. For clip
# For edit jobs: the trim/crop recipe {trim?:{start_s,end_s}, crop?:{x,y,w,h,frame_w?,frame_h?}}, its
# crop in the SHOWN picture's pixels when it carries the frame it was measured on (`edit.crop_filter`),
# else in the file's stored ones. 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)
+12 -7
View File
@@ -1,4 +1,4 @@
"""Server-rendered Open Graph tags for the public /watch/{token} page.
"""Server-rendered Open Graph tags for the public page of a share link (/watch/, /file/, /folder/).
The watch page is a client-side SPA, so a social crawler (facebookexternalhit, Twitterbot, …) that
fetches /watch/{token} would otherwise only see the generic index.html — a blank/greyed link card.
@@ -76,16 +76,21 @@ def _watch_tags(token: str, db: Session) -> str | None:
link = linksmod.find(db, token)
if link is None or linksmod.is_expired(link):
return None
url = f"{settings.app_base}/watch/{token}"
folder = link.folder_id is not None
url = f"{settings.app_base}{linksmod.share_path(db, link, token)}"
# What the card calls it, as its address does.
kind = linksmod.opens(db, link)
folder, video = kind == "folder", kind == "video"
if link.password_hash:
# Password-gated: don't leak the title/thumbnail in the unfurl.
return "\n ".join(
[
"<title>Siftlode</title>",
_tag("og:title", "Password-protected " + ("folder" if folder else "video")),
_tag(
"og:title",
"Password-protected " + ("folder" if folder else "video" if video else "file"),
),
_tag("og:description", "Shared via Siftlode"),
_tag("og:type", "website" if folder else "video.other"),
_tag("og:type", "video.other" if video else "website"),
_tag("og:url", url),
_tag("og:site_name", "Siftlode"),
]
@@ -96,7 +101,7 @@ def _watch_tags(token: str, db: Session) -> str | None:
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
if job is None or asset is None or asset.status != "ready":
return None
title = (job.display_name or asset.title or "Video").strip()
title = (job.display_name or asset.title or ("Video" if video else "File")).strip()
channel = (job.display_uploader or asset.uploader or "").strip()
# Pick the link-preview image. Facebook's scraper REJECTS our own poster ("Corrupted Image" in the
# Sharing Debugger): the poster is an ffmpeg-produced JPEG whose non-standard marker order FB's
@@ -118,7 +123,7 @@ def _watch_tags(token: str, db: Session) -> str | None:
f"<title>{html.escape(title)}</title>",
_tag("og:title", title),
_tag("og:description", channel or "Shared via Siftlode"),
_tag("og:type", "video.other"),
_tag("og:type", "video.other" if video else "website"),
_tag("og:url", url),
_tag("og:site_name", "Siftlode"),
_tag("twitter:title", title, attr="name"),
+21 -4
View File
@@ -837,6 +837,18 @@ def _fail_edit(
)
def _filter_frame(
path: Path, width: int | None, height: int | None
) -> tuple[int | None, int | None]:
"""A file's frame as ffmpeg's filters see it: the stored size, turned when the stream is stored
turned a quarter (ffmpeg stands it upright first). Unknown when the file cannot be read."""
data = probe.probe_json(path)
turned = clipmod.quarter_turned(data) if data else None
if turned is None:
return None, None
return (height, width) if turned else (width, height)
def _process_edit(job_id: int, asset_id: int) -> None:
"""Produce a per-user trim/crop clip from the source asset's downloaded file via ffmpeg."""
with SessionLocal() as db:
@@ -905,6 +917,12 @@ def _process_edit(job_id: int, asset_id: int) -> None:
size = final.stat().st_size if final.exists() else None
# The clip's real frame: a re-encode evens an odd crop or source (`editmod.EVEN_FRAME`).
_, _, out_w, out_h, _ = probe.probe_media(final)
# Unsized, a crop's own size stands in, cut from the frame the filters saw.
cut = (
editmod.crop_stored_size(edit_spec["crop"], *_filter_frame(src_path, src_w, src_h))
if crop and not (out_w and out_h)
else None
)
with SessionLocal() as db:
asset = db.get(MediaAsset, asset_id)
@@ -916,11 +934,10 @@ def _process_edit(job_id: int, asset_id: int) -> None:
asset.duration_s = total_s or asset.duration_s
if out_w and out_h:
asset.width, asset.height = out_w, out_h
elif crop:
asset.width = edit_spec["crop"]["w"]
asset.height = edit_spec["crop"]["h"]
elif cut:
asset.width, asset.height = cut
else:
asset.width, asset.height = src_w, src_h
asset.width, asset.height = (None, None) if crop else (src_w, src_h)
asset.error = None
asset.last_access_at = datetime.now(timezone.utc)
job = db.get(DownloadJob, job_id)
+2 -1
View File
@@ -16,7 +16,8 @@ That is a per-job ceiling, not an aggregate ledger: concurrent jobs of one user
remainder (in-flight bytes are not `ready`, so `footprint` cannot see them), so a wave of
max_concurrent downloads can still land over the cap — the deliberate trade, since a download's size
is unknown until it finishes. 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).
their output's real size before it is placed. Edit jobs consult it with their output's real size
before it is placed (`test_edit_cap.py`).
"""
from dataclasses import dataclass
+11 -5
View File
@@ -151,7 +151,7 @@ async def _reap_hls_sessions() -> None:
somebody pressed play again or the process restarted.
A FINISHED session is reaped on the short rule here, not kept for the full idle window: an open
player checkpoints every 10 s and that route touches the session, so a paused viewer is no longer
player keeps checkpointing and that route touches the session, so a paused viewer is no longer
invisible to this pass (see `reap_idle`). Keeping every finished session for ten minutes instead
held one whole segmented copy of a film per item on the download volume."""
while True:
@@ -368,6 +368,10 @@ INDEX_HTML = STATIC_DIR / "index.html"
# A public share link's own page: the player alone, without the app's bundle (frontend/watch.html).
WATCH_HTML = STATIC_DIR / "watch.html"
INDEX_HEADERS = {"Cache-Control": "no-cache"}
# The paths a share link opens on, by what it opens (`links.share_path`). Any of them opens any link.
# A new one also goes in frontend/public/robots.txt, the Vite dev entry and WatchPage's token parsing
# (watchEntry.contract.test.ts).
SHARE_PREFIXES = ("watch/", "file/", "folder/")
app.mount(
"/assets",
StaticFiles(directory=STATIC_DIR / "assets", check_dir=False),
@@ -381,8 +385,10 @@ async def index() -> FileResponse:
@app.get("/watch/{token}")
async def watch_page(token: str):
"""Serve the public player page for a share link, with per-video Open Graph tags injected so the
@app.get("/file/{token}")
@app.get("/folder/{token}")
async def share_page(token: str):
"""Serve the public page of a share link, with per-link Open Graph tags injected so the
link unfurls richly (title/channel/thumbnail) in Messenger/social crawlers. A real browser
ignores the extra tags and boots WatchPage; invalid/expired/password links fall back to the
generic card."""
@@ -409,8 +415,8 @@ async def spa_fallback(full_path: str) -> FileResponse:
# Real SPA-root assets (welcome images, favicon, robots.txt) rarely change and have
# stable names, so a moderate cache is safe and satisfies the cache-lifetime audit.
return FileResponse(candidate, headers={"Cache-Control": "public, max-age=604800"})
# A /watch/ path the route above does not match (a trailing segment, no token) is still the
# A share path the route above does not match (a trailing segment, no token) is still the
# player's: WatchPage reads the token off the path and says when a link is gone.
if full_path.startswith("watch/"):
if full_path.startswith(SHARE_PREFIXES):
return FileResponse(WATCH_HTML, headers=INDEX_HEADERS)
return FileResponse(INDEX_HTML, headers=INDEX_HEADERS)
+27 -13
View File
@@ -125,7 +125,7 @@ class HlsSession:
# replaces the viewer's registered session for the item — and the old player's next request
# for `seg_12.ts`, resolved by (viewer, rating_key) alone, was answered 200 with the NEW session's twelfth segment, produced from a different offset
# under `-copyts`. Wrong bytes, not an error, so the client's reclaim never fired on it.
# A request naming a session that is no longer registered is a 404,
# A request naming a session that is no longer registered is a 410,
# which the player recovers from by restarting at the playhead.
self.id = secrets.token_urlsafe(9)
self.key = key # the item's rating_key (what the logs name)
@@ -729,7 +729,7 @@ def _enforce_caps(
whole segmented copy of the film under `_HLS_ROOT`, written faster than realtime. Counted only
by the transcode cap, N distinct items were N ffmpegs and N film-sized scratch directories.
Somebody's playback stops at each drop. The route turns the missing session into a retryable 404
Somebody's playback stops at each drop. The route turns the missing session into a 410
and the player restarts at the playhead, so from the server nothing failed — and "my film keeps
rebuffering" has no other explanation anywhere, hence the line."""
@@ -918,7 +918,7 @@ def start_session(
# stale and stops the session it names (`stop_session`), which stopped the playback that won.
# Never None either: the POST /session route turns a None return into a "Media file not found
# on disk" 404 — dishonest for a file that exists. A dead session instead degrades to a
# retryable 404 ("No active playback session" / "Not available yet") the client re-requests.
# 410 ("session_gone") on its URLs, and the client asks again.
return s
s = respawned
ready = wait_for(_seg(0), timeout=25.0, proc=s.proc)
@@ -1160,9 +1160,13 @@ def sweep_orphans(older_than: float | None = None) -> int:
return dropped
# How long a session whose ffmpeg has FINISHED may go unfetched before a new session request reaps
# it (see reap_idle).
_FINISHED_IDLE_S = 30
# The slowest heartbeat a live player sends: a browser cuts the timers of a hidden page that makes no
# sound to one a minute (measured on Chromium: a paused and a muted player, about a minute after
# hiding; a hidden player with sound keeps its 10 s).
_THROTTLED_HEARTBEAT_S = 60
# How long a session whose ffmpeg has FINISHED may go without a signal before `reap_idle` takes it:
# the beat before the reaper looks, one lost on the way, and the time the next one takes to arrive.
_FINISHED_IDLE_S = 3 * _THROTTLED_HEARTBEAT_S
def reap_idle(finished_idle_s: float = _FINISHED_IDLE_S) -> int:
@@ -1171,20 +1175,23 @@ def reap_idle(finished_idle_s: float = _FINISHED_IDLE_S) -> int:
A finished session is also what a PAUSED viewer has: a remux runs far faster than realtime, so
ffmpeg is done a minute or two in and writes ENDLIST; hls.js then stops asking for the playlist,
and with its forward buffer full a paused player fetches nothing at all. With the 30 s rule that
killed a one-minute pause, and playback died ~30 s after resuming.
and with its forward buffer full a paused player fetches nothing at all.
What makes the short rule safe again is that fetching is no longer the only signal: a player
parked MID-ITEM checkpoints its position every 10 s, and that route calls `touch_session`, so a
viewer who is merely paused says so far more often than this rule asks. The heartbeat
What makes the short rule safe is that fetching is not the only signal: a player parked MID-ITEM
checkpoints its position and that route calls `touch_session`. That beat is every 10 s only while
the page is visible or making sound; a hidden, silent one sends it once a minute, which a 30 s
rule killed whenever the reaper happened to run more than 30 s after the beat (measured: a paused
session gone that way, and one that lived on the same cadence because its phase was luckier).
So the rule is sized to the throttled beat (`_THROTTLED_HEARTBEAT_S`). The heartbeat
stops once the item is finished or before a position is known (`usePlexProgress.flush`) — both
states where reaping is correct, and where the player's 404 → reclaim recovers a viewer who
scrubs back in anyway. Do not restate this as "every 10 s whether or not it is playing":
states where reaping is correct, and where the player's 410 → reclaim recovers a viewer who
scrubs back in anyway. Do not restate this as "whether or not it is playing":
a review found that claim in two comments and not in the code. An earlier blanket
`reap_abandoned` bought the same safety by keeping every finished session for ten minutes — each
one a whole segmented copy of the film on a volume shared with the download center."""
now = time.time()
doomed: list[tuple[HlsSession, Path | None]] = []
reaped: list[tuple[str, str, int]] = []
with _lock:
for reg, s in list(_sessions.items()):
done = s.proc.poll() is not None
@@ -1192,6 +1199,13 @@ def reap_idle(finished_idle_s: float = _FINISHED_IDLE_S) -> int:
if idle > _SESSION_IDLE_S or (done and idle > finished_idle_s):
_sessions.pop(reg, None)
doomed.append(_detach(s))
reaped.append((s.key, "finished_idle" if done else "idle", int(idle)))
# The viewer of a session wrongly taken sees only a 410 later, so this line is the one record of why.
for key, reason, idle_s in reaped:
log.info(
"an idle session was reaped",
extra=logs.event("plex.stream.session_reaped", key=key, reason=reason, idle_s=idle_s),
)
_finish(doomed)
return len(doomed)
+18 -4
View File
@@ -41,11 +41,25 @@ def video(db: Session, video_id: str) -> Video | None:
# --- tags ------------------------------------------------------------------------------------
def tags_with_counts(db: Session, user: User) -> list[tuple[Tag, int]]:
"""The tags this user sees (the shared system ones and their own), each with how many channel
links it has, by category then name."""
def tags_with_counts(db: Session, user: User) -> list[tuple[Tag, int, int]]:
"""The tags this user sees (the shared system ones and their own), by category then name, each
with how many channel links it has and how many of the user's own links sit on a channel they
subscribe to and have not hidden — the rows the Channels page's tag filter shows (a hidden
channel has its own tab). A link outlives an unsubscribe and a hide, so the two differ."""
subscribed = select(Subscription.channel_id).where(
owned_by(Subscription.user_id, user), Subscription.hidden.is_(False)
)
on_subscribed = dict(
db.execute(
select(ChannelTag.tag_id, func.count(ChannelTag.id))
.where(owned_by(ChannelTag.user_id, user), ChannelTag.channel_id.in_(subscribed))
.group_by(ChannelTag.tag_id)
)
.tuples()
.all()
)
return [
(tag, count)
(tag, count, on_subscribed.get(tag.id, 0))
for tag, count in db.execute(
select(Tag, func.count(ChannelTag.id))
.outerjoin(ChannelTag, ChannelTag.tag_id == Tag.id)
+5 -5
View File
@@ -611,7 +611,7 @@ def list_links(
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> list[dict]:
own_job(db, user, job_id)
return [linksmod.owner_view(link) for link in queries.links_to(db, job_id=job_id)]
return [linksmod.owner_view(db, link) for link in queries.links_to(db, job_id=job_id)]
class CreateLinkIn(BaseModel):
@@ -653,7 +653,7 @@ def create_link(
raise refused(409, "share_unfinished")
link, token = _new_link(db, user, body, job_id=job_id)
db.commit()
return linksmod.owner_view(link, token=token)
return linksmod.owner_view(db, link, token=token)
def _shareable_job(db: Session, user: User, job_id: int) -> "DownloadJob":
@@ -684,7 +684,7 @@ def list_folder_links(
folder_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> list[dict]:
own_folder(db, user, folder_id)
return [linksmod.owner_view(link) for link in queries.links_to(db, folder_id=folder_id)]
return [linksmod.owner_view(db, link) for link in queries.links_to(db, folder_id=folder_id)]
@router.post("/folders/{folder_id}/links")
@@ -697,7 +697,7 @@ def create_folder_link(
_shareable_folder(db, user, folder_id)
link, token = _new_link(db, user, body, folder_id=folder_id)
db.commit()
return linksmod.owner_view(link, token=token)
return linksmod.owner_view(db, link, token=token)
class UpdateLinkIn(BaseModel):
@@ -724,7 +724,7 @@ def update_link(
sent=frozenset(body.model_fields_set),
)
db.commit()
return linksmod.owner_view(link)
return linksmod.owner_view(db, link)
@router.delete("/links/{link_id}")
+1
View File
@@ -432,6 +432,7 @@ def item_detail(
"summary": (it.summary_hu or it.summary) if hu else it.summary,
"year": it.year,
"duration_seconds": it.duration_s,
"size_bytes": it.size_bytes,
"playable": it.playable,
# Playback-class detail: HW/SW transcode split + the codec/bit-depth the info-page chip shows.
"transcode_hw": it.transcode_hw,
+10 -7
View File
@@ -323,13 +323,14 @@ def item_progress(
`_should_push_resume`). `final` (sent on pause / seek / pagehide / unmount) pushes immediately and
resets the throttle window, so Plex sees a settled position the instant the user pauses or seeks."""
it = item_or_404(db, rating_key)
# A player parked mid-item — playing or PAUSED — checkpoints every 10 s, so this is the signal
# A player parked mid-item — playing or PAUSED — keeps checkpointing (every 10 s, once a minute
# in a hidden silent page), so this is the signal
# that such a viewer still wants their HLS session: the one thing `last_access` could not see,
# since it moved only on a file fetch and a paused player fetches nothing (see
# stream.reap_idle). It is NOT every open player: `usePlexProgress.flush` sends nothing once the
# finish latch is up, or before the first position is known. Those are the states in which
# reaping is the right answer anyway, and a viewer who scrubs back into a reaped session gets
# the same 404 → reclaim the player already handles.
# the same 410 → reclaim the player already handles.
plex_stream.touch_session(user.id, str(rating_key))
position = max(0, int(body.position_seconds))
duration = int(body.duration_seconds)
@@ -609,7 +610,9 @@ def stream_hls_file(
is per (key, viewer, offset, tag) — so resolving by the key alone handed the old player the new
session's `seg_12.ts`, produced from a different offset under `-copyts`: wrong
bytes with a 200, which the player has no way to notice. A request for a
session that is no longer the registered one is a 404, and the player reclaims at the playhead.
session that is no longer the registered one is a 410, and the player reclaims at the playhead.
410, not 404: a 404 here means "that segment is not there yet", worth hls.js's retries, and the
player can only read the status (a gone session retried for half a minute behind that 404).
That reclaim is also why a request with no live session is no longer auto-started here: the
auto-start could only ever begin at 0, silently throwing away the viewer's position."""
@@ -625,7 +628,7 @@ def stream_hls_file(
raise not_found("file")
s = plex_stream.current_session(user.id, str(rating_key))
if s is None or s.id != session_id:
raise refused(404, "session_gone")
raise refused(410, "session_gone")
path = s.dir / filename
# Only a name THIS session can actually produce is worth waiting for: its own playlists, and a
# segment of one of its own renditions whose predecessor is already on disk (ffmpeg writes them
@@ -648,13 +651,13 @@ def stream_hls_file(
except OSError:
# The session was torn down between the wait and the read — teardown moves the whole
# directory aside and deletes it, and nothing holds `_lock` across those two steps. It has
# to present as the same 404 a gone session does: the player's recovery is keyed on the
# STATUS (`code === 404`), so
# to present as the same 410 a gone session does: the player's recovery is keyed on the
# STATUS, so
# a 500 here skipped the reclaim and ended playback with "couldn't play" instead of
# restarting at the playhead. Ordinary trigger: `loadSession` awaits the new session — which
# kills and rmtrees the old one — before destroying the old hls instance, so the outgoing
# instance's in-flight segment request races the rmtree on every seek-restart.
raise refused(404, "session_gone") from None
raise refused(410, "session_gone") from None
return Response(
content=content,
media_type=_HLS_CT[ext],
+9 -2
View File
@@ -176,8 +176,15 @@ def watch_meta(token: str, db: Session = Depends(get_db)) -> dict:
returns only `needs_password` until the viewer unlocks it (no title/thumbnail leak)."""
link = _link_or_404(db, token)
if link.password_hash:
# Which kind it opens, for the password prompt's title — never what is in it.
return {"needs_password": True, "kind": "folder" if link.folder_id is not None else "file"}
# Which kind it opens, for the password prompt's words — never what is in it. `video` tells
# a video (watched) from any other file (opened); the address is only a hint, every
# prefix opens every link.
opens = linksmod.opens(db, link)
return {
"needs_password": True,
"kind": "folder" if opens == "folder" else "file",
"video": opens == "video",
}
meta = _opened(db, link, token)
db.commit()
return meta
+2 -1
View File
@@ -48,8 +48,9 @@ def list_tags(user: User = Depends(current_user), db: Session = Depends(get_db))
"category": tag.category,
"system": tag.user_id is None,
"channel_count": count,
"subscribed_count": subscribed,
}
for tag, count in queries.tags_with_counts(db, user)
for tag, count, subscribed in queries.tags_with_counts(db, user)
]
+209
View File
@@ -0,0 +1,209 @@
"""The editor's crop lands on the part of the picture the frame was drawn on, also when a video's
pixels are not square. The browser reports such a video by its shown size (320 stored, 640 shown),
and ffmpeg's `crop` counts stored pixels: measured on a real clip, a frame over the middle half kept
half the frame's red surroundings. So a crop carries the size it was measured on.
Real ffmpeg: the pixels of the output are the oracle.
"""
import shutil
import subprocess
from pathlib import Path
import pytest
from app.config import settings
from app.downloads import edit, pipeline, service, storage
from app.downloads import upload as uploadmod
from app.downloads.models import DownloadJob, MediaAsset
pytestmark = pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
def _source(path: Path, size: str, box: str, sar: int, rotate: int = 0) -> None:
"""Red, with a green box: a crop of the box holds no red. `rotate` tags the file to be shown
turned by that many degrees counter-clockwise, as a phone records a portrait video."""
tag = ["-display_rotation:v:0", str(rotate)] if rotate else []
subprocess.run(
[
"ffmpeg", "-y", "-loglevel", "error",
"-f", "lavfi", "-i", f"color=c=red:s={size}:d=2:r=10",
"-f", "lavfi", "-i", "sine=duration=2",
"-vf", f"drawbox={box}:c=lime:t=fill,setsar={sar}",
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", str(path),
],
check=True,
) # fmt: skip
if tag:
tagged = path.with_name("tagged-" + path.name)
subprocess.run(
["ffmpeg", "-y", "-loglevel", "error", *tag, "-i", str(path), "-c", "copy", str(tagged)],
check=True,
) # fmt: skip
tagged.replace(path)
def _red_and_green(path: Path) -> tuple[int, int]:
raw = subprocess.run(
["ffmpeg", "-loglevel", "error", "-i", str(path), "-frames:v", "1",
"-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
check=True,
capture_output=True,
).stdout # fmt: skip
red = green = 0
for i in range(0, len(raw) - 2, 3):
if raw[i] > 180 and raw[i + 1] < 80:
red += 1
elif raw[i + 1] > 180 and raw[i] < 80:
green += 1
return red, green
def _cut_asset(db, make_user, src: Path, spec: dict, then=lambda: None) -> MediaAsset:
user = make_user()
source = uploadmod.ingest_upload(
db, user.id, filename="clip.mp4", src_path=src, size=src.stat().st_size
)
assert source.status == "done"
then()
job = service.enqueue_edit(db, user.id, source, spec)
asset = db.get(MediaAsset, job.asset_id)
job.status, asset.status = "running", "downloading" # the worker's claim
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, asset.status) == ("done", None, "ready")
return asset
def _cut(db, make_user, tmp_path, src: Path, crop: dict, **spec) -> Path:
asset = _cut_asset(db, make_user, src, {"crop": crop, **spec})
return storage.abs_path(str(tmp_path), asset.rel_path)
def test_a_crop_of_a_video_with_wide_pixels_keeps_what_the_frame_showed(
db, make_user, tmp_path, monkeypatch
):
monkeypatch.setattr(settings, "files_root", str(tmp_path))
src = tmp_path / "in.mp4"
_source(src, "320x240", "x=80:y=60:w=160:h=120", sar=2)
# What the editor sends for the middle half of this video: the browser shows it at 640 x 240.
final = _cut(
db, make_user, tmp_path, src,
{"x": 160, "y": 60, "w": 320, "h": 120, "frame_w": 640, "frame_h": 240},
) # fmt: skip
red, green = _red_and_green(final)
assert (red, green) == (0, 160 * 120)
def test_a_crop_of_square_pixels_is_cut_where_it_was_drawn(db, make_user, tmp_path, monkeypatch):
monkeypatch.setattr(settings, "files_root", str(tmp_path))
src = tmp_path / "in.mp4"
_source(src, "480x640", "x=120:y=160:w=240:h=320", sar=1)
final = _cut(
db, make_user, tmp_path, src,
{"x": 120, "y": 160, "w": 240, "h": 320, "frame_w": 480, "frame_h": 640},
) # fmt: skip
assert _red_and_green(final) == (0, 240 * 320)
def test_a_crop_stored_before_it_carried_its_frame_is_cut_as_it_always_was():
spec = edit.normalize_edit_spec({"crop": {"x": 4, "y": 6, "w": 100, "h": 50}})
assert spec["crop"] == {"x": 4, "y": 6, "w": 100, "h": 50}
cmd = edit.build_edit_cmd(Path("in.mp4"), Path("out.mp4"), spec, "mp4")
assert cmd[cmd.index("-vf") + 1].startswith("crop=100:50:4:6,")
@pytest.mark.parametrize("frame", [{"frame_w": 0, "frame_h": 240}, {"frame_w": "x", "frame_h": 2}])
def test_a_frame_that_cannot_be_read_is_not_divided_by(frame):
spec = edit.normalize_edit_spec({"crop": {"x": 4, "y": 6, "w": 100, "h": 50, **frame}})
assert spec["crop"] == {"x": 4, "y": 6, "w": 100, "h": 50}
def test_a_crop_of_a_join_keeps_what_the_frame_showed(db, make_user, tmp_path, monkeypatch):
"""A cut-list joins through `filter_complex`, a crop path of its own."""
monkeypatch.setattr(settings, "files_root", str(tmp_path))
src = tmp_path / "in.mp4"
_source(src, "320x240", "x=80:y=60:w=160:h=120", sar=2)
final = _cut(
db, make_user, tmp_path, src,
{"x": 160, "y": 60, "w": 320, "h": 120, "frame_w": 640, "frame_h": 240},
segments=[{"start_s": 0, "end_s": 0.5}, {"start_s": 1, "end_s": 1.5}], accurate=True,
) # fmt: skip
assert _red_and_green(final) == (0, 160 * 120)
def test_a_crop_of_a_rotated_video_is_cut_where_it_was_drawn(db, make_user, tmp_path, monkeypatch):
"""The browser shows a rotated file upright, so the frame was drawn on the upright picture."""
monkeypatch.setattr(settings, "files_root", str(tmp_path))
src = tmp_path / "in.mp4"
# Stored 320 x 240, green on its left half; shown turned a quarter, 240 x 320.
_source(src, "320x240", "x=0:y=0:w=160:h=240", sar=1, rotate=90)
# Turned counter-clockwise, the stored left half is the shown bottom half.
final = _cut(
db, make_user, tmp_path, src,
{"x": 0, "y": 160, "w": 240, "h": 160, "frame_w": 240, "frame_h": 320},
) # fmt: skip
assert _red_and_green(final) == (0, 240 * 160)
def test_a_clip_ffprobe_cannot_size_gets_the_size_its_crop_cut(
db, make_user, tmp_path, monkeypatch
):
monkeypatch.setattr(settings, "files_root", str(tmp_path))
src = tmp_path / "in.mp4"
_source(src, "320x240", "x=80:y=60:w=160:h=120", sar=2)
def probe_fails():
monkeypatch.setattr(pipeline.probe, "probe_media", lambda _p: (None,) * 5)
asset = _cut_asset(
db, make_user, src,
{"crop": {"x": 160, "y": 60, "w": 320, "h": 120, "frame_w": 640, "frame_h": 240}},
then=probe_fails,
) # fmt: skip
# Half the shown width of a 320-wide file: 160 stored, not the 320 the frame counted.
assert (asset.width, asset.height) == (160, 120)
def test_a_rotated_clip_ffprobe_cannot_size_gets_the_upright_size_its_crop_cut(
db, make_user, tmp_path, monkeypatch
):
"""The frame was drawn on the upright picture, which is the stored one turned: so is its cut."""
monkeypatch.setattr(settings, "files_root", str(tmp_path))
src = tmp_path / "in.mp4"
_source(src, "320x240", "x=0:y=0:w=160:h=240", sar=1, rotate=90)
def probe_fails():
monkeypatch.setattr(pipeline.probe, "probe_media", lambda _p: (None,) * 5)
asset = _cut_asset(
db, make_user, src,
{"crop": {"x": 0, "y": 160, "w": 240, "h": 160, "frame_w": 240, "frame_h": 320}},
then=probe_fails,
) # fmt: skip
# What ffmpeg cut (the rotated test above): 240 x 160, not the stored 320 x 120.
assert (asset.width, asset.height) == (240, 160)
@pytest.mark.parametrize("key", ["frame_w", "x"])
def test_an_infinite_number_in_a_crop_is_dropped_not_a_crash(key):
crop = {"x": 4, "y": 6, "w": 100, "h": 50, "frame_w": 640, "frame_h": 240}
spec = edit.normalize_edit_spec({"crop": {**crop, key: float("inf")}})
if key == "frame_w":
assert spec["crop"] == {"x": 4, "y": 6, "w": 100, "h": 50}
else:
assert "crop" not in spec
+2 -1
View File
@@ -150,6 +150,7 @@ def test_a_password_folder_link_shows_nothing_until_unlocked(shelf, client):
assert client.get(f"/api/public/watch/{token}").json() == {
"needs_password": True,
"kind": "folder",
"video": False,
}
bare = f"/api/public/watch/{token}/files/{shelf['inside'][0]}/file"
assert client.get(bare).status_code == 403
@@ -312,7 +313,7 @@ def test_a_password_file_link_says_it_opens_a_file(shelf, client):
.json()
)
meta = client.get(f"/api/public/watch/{made['token']}").json()
assert meta == {"needs_password": True, "kind": "file"}
assert meta == {"needs_password": True, "kind": "file", "video": True}
def test_a_file_whose_bytes_are_gone_is_not_offered(shelf, client, db):
+52 -7
View File
@@ -5,11 +5,11 @@ POST /session: after the last playback, an abandoned session (its ffmpeg still w
film, its directory a whole segmented copy of it) lived until somebody pressed play again.
And it must not take a PAUSED viewer's session: a remux finishes long before the film does, and a
paused player with a full buffer fetches nothing, so "finished and unfetched for 30 s" does not mean
paused player with a full buffer fetches nothing, so "finished and unfetched for a while" does not mean
"abandoned". An earlier fix bought that safety by keeping EVERY finished session for the full
ten-minute idle window — one segmented copy of a film per item, on the volume the download center
shares. Its successor replaced the blanket retention with the signal that already existed: a player
parked mid-item checkpoints its position every 10 s, paused or not, and that route says "still
parked mid-item keeps checkpointing its position, paused or not, and that route says "still
here" (it stops once the item is finished, where reaping is correct).
Both call sites are pinned here, because the bug each time was that only one of them moved: the
@@ -81,6 +81,9 @@ class _Proc:
return 0
_PAST_THE_RULE = stream._FINISHED_IDLE_S + 5
class _Session:
def __init__(self, tmp_path, name: str, finished: bool, idle_s: float):
self.key = name
@@ -98,11 +101,29 @@ def sessions(monkeypatch):
def test_the_periodic_pass_reaps_a_finished_session_nobody_has_claimed(tmp_path, sessions):
sessions["12345"] = _Session(tmp_path, "done", finished=True, idle_s=45)
sessions["12345"] = _Session(tmp_path, "done", finished=True, idle_s=_PAST_THE_RULE)
assert stream.reap_idle() == 1
assert sessions == {}
@pytest.mark.parametrize("idle_s", [65, 125])
def test_a_heartbeat_throttled_to_once_a_minute_keeps_a_finished_session(
tmp_path, sessions, idle_s
):
"""A hidden page that makes no sound gets its timers cut to one a minute (measured on a paused and
on a muted player, about a minute after hiding), so that is the viewer's heartbeat there: the
reaper can see a beat over a minute old, and two if one is lost on the way."""
s = _Session(tmp_path, "hidden", finished=True, idle_s=idle_s)
sessions[(1, "12345")] = s
assert stream.reap_idle() == 0
assert (1, "12345") in sessions and s.dir.exists() and not s.proc.killed
def test_an_abandoned_finished_session_is_still_gone_within_minutes(tmp_path, sessions):
sessions[(1, "12345")] = _Session(tmp_path, "gone", finished=True, idle_s=185)
assert stream.reap_idle() == 1
def test_the_periodic_pass_still_reaps_what_nobody_has_touched_for_the_idle_limit(
tmp_path, sessions
):
@@ -116,8 +137,32 @@ def test_the_periodic_pass_still_reaps_what_nobody_has_touched_for_the_idle_limi
assert sessions == {}
def test_every_reap_says_which_rule_took_the_session(tmp_path, sessions, caplog):
"""A session that vanishes leaves the viewer a 410 and nothing else: with no line here, a live
stall could only ever be blamed on the reaper by inference."""
sessions["1"] = _Session(
tmp_path, "gone_done", finished=True, idle_s=stream._SESSION_IDLE_S - 5
)
sessions["2"] = _Session(
tmp_path, "gone_live", finished=False, idle_s=stream._SESSION_IDLE_S + 5
)
sessions["3"] = _Session(tmp_path, "kept", finished=False, idle_s=5)
with caplog.at_level("INFO"):
assert stream.reap_idle() == 2
reaped = {
r.attrs["key"]: r.attrs
for r in caplog.records
if getattr(r, "event", "") == "plex.stream.session_reaped"
}
assert set(reaped) == {"gone_done", "gone_live"}
assert reaped["gone_done"]["reason"] == "finished_idle"
assert reaped["gone_live"]["reason"] == "idle"
assert reaped["gone_done"]["idle_s"] == pytest.approx(stream._SESSION_IDLE_S - 5, abs=2)
assert reaped["gone_live"]["idle_s"] == pytest.approx(stream._SESSION_IDLE_S + 5, abs=2)
def test_touching_a_session_keeps_it_through_the_short_rule(tmp_path, sessions):
s = _Session(tmp_path, "paused", finished=True, idle_s=90)
s = _Session(tmp_path, "paused", finished=True, idle_s=_PAST_THE_RULE)
sessions[(1, "12345")] = s
stream.touch_session(1, "12345")
assert stream.reap_idle() == 0
@@ -160,7 +205,7 @@ def test_a_paused_viewers_checkpoint_keeps_their_session_alive(
user = User(email="paused@example.com")
db.add(user)
db.commit()
s = _Session(tmp_path, "paused", finished=True, idle_s=90)
s = _Session(tmp_path, "paused", finished=True, idle_s=_PAST_THE_RULE)
sessions[(user.id, "paused1")] = s
resp = _client(db, user, override_dep).post(
f"/api/plex/item/{it.rating_key}/progress",
@@ -175,7 +220,7 @@ def test_starting_a_session_leaves_another_viewers_paused_one_alone(
db, monkeypatch, tmp_path, sessions, override_dep
):
"""The opportunistic sweep this route used to run was not scoped to the key being started: it
walked the WHOLE registry with the 30 s finished rule, so pressing play on any item killed a
walked the WHOLE registry with the finished rule, so pressing play on any item killed a
paused viewer's session — the very case the retention fix set out to protect."""
monkeypatch.setattr(state, "_configured_cache", True)
monkeypatch.setattr(plex_paths, "local_media_path", lambda db_, fp: "/local/a.mkv")
@@ -183,7 +228,7 @@ def test_starting_a_session_leaves_another_viewers_paused_one_alone(
user = User(email="starter@example.com")
db.add(user)
db.commit()
paused = _Session(tmp_path, "paused", finished=True, idle_s=90)
paused = _Session(tmp_path, "paused", finished=True, idle_s=_PAST_THE_RULE)
sessions[(999, "someone-else")] = paused
def fake_start(db_, item, user_id, start, audio=None, aoff=0.0, multi=False, transcode=False):
+2
View File
@@ -219,6 +219,7 @@ def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch,
library_id=lib.id,
kind="movie",
title="The Godfather",
size_bytes=15_000_000_000,
genres=["Crime", "Drama"],
directors=["Francis Ford Coppola"],
studio="Paramount",
@@ -238,6 +239,7 @@ def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch,
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m1")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["size_bytes"] == 15_000_000_000
assert body["imdb_rating"] == 8.7
assert body["imdb_id"] == "tt0068646"
assert body["imdb_url"] == "https://www.imdb.com/title/tt0068646/"
+10 -10
View File
@@ -5,7 +5,7 @@ seek-restart — or a second viewer of the same item — replaces the registered
viewer's next request for `seg_12.ts`, resolved by rating_key, was answered 200 with the NEW
session's twelfth segment: bytes produced from a different `-ss` offset under `-copyts`, i.e. a
timeline the client is not on. Wrong content with a success status is the one failure a player cannot
notice, and the client's reclaim-on-404 could never fire on it.
notice, and the client's reclaim could never fire on it.
No `db` fixture: the route reads nothing from the database (its own `Depends(get_db)` went with the
removed auto-start, and `current_user` is overridden wholesale), so these must not sit in the DB
@@ -88,10 +88,10 @@ def test_another_viewers_session_of_the_same_item_is_not_this_viewers(client, tm
(d / "index.m3u8").write_bytes(b"#EXTM3U")
sessions[(7, "m1")] = _Session("sid-a", d)
r = client.get("/api/plex/stream/m1/hls/sid-a/index.m3u8")
assert r.status_code == 404, r.text
assert r.status_code == 410, r.text
def test_a_segment_of_a_replaced_session_is_a_404_not_the_new_sessions_bytes(
def test_a_segment_of_a_replaced_session_is_gone_not_the_new_sessions_bytes(
client, tmp_path, sessions
):
"""Viewer A is at 0:00; viewer B seeks to 25:00 and their session takes the registry slot. A's
@@ -102,15 +102,15 @@ def test_a_segment_of_a_replaced_session_is_a_404_not_the_new_sessions_bytes(
(theirs / "seg_12.ts").write_bytes(b"theirs")
sessions[(VIEWER, "m1")] = _Session("sid-b", theirs)
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts")
assert r.status_code == 404, r.text
assert r.status_code == 410, r.text
assert b"theirs" not in r.content
def test_a_playlist_with_no_live_session_is_a_404_not_a_silent_restart_at_zero(
def test_a_playlist_with_no_live_session_is_gone_not_a_silent_restart_at_zero(
client, monkeypatch, sessions
):
"""The entry playlist used to auto-start a session when none was registered — always at 0.0,
silently throwing away the viewer's position. A 404 sends the player through its own reclaim,
silently throwing away the viewer's position. A 410 sends the player through its own reclaim,
which restarts at the playhead."""
started: list[float] = []
monkeypatch.setattr(
@@ -119,7 +119,7 @@ def test_a_playlist_with_no_live_session_is_a_404_not_a_silent_restart_at_zero(
lambda *a, **k: started.append(a[3] if len(a) > 3 else 0.0),
)
r = client.get("/api/plex/stream/m1/hls/sid-gone/index.m3u8")
assert r.status_code == 404, r.text
assert r.status_code == 410, r.text
assert started == []
@@ -235,12 +235,12 @@ def test_an_absurd_segment_index_is_a_404_not_a_500(client, tmp_path, sessions,
assert r.status_code == 404, r.text
def test_a_session_torn_down_during_the_read_is_a_404_not_a_500(
def test_a_session_torn_down_during_the_read_is_gone_not_a_500(
client, monkeypatch, tmp_path, sessions
):
"""Teardown moves the whole directory aside and nothing holds `_lock` across the wait and the read,
so the file can vanish in between — on every seek-restart, since `loadSession` awaits the new
session before destroying the old hls instance. The player's recovery is keyed on the 404
session before destroying the old hls instance. The player's recovery is keyed on the
STATUS, so a 500 here skipped the reclaim and ended playback with "couldn't play"."""
d = tmp_path / "m1_0_0_+0.00"
d.mkdir()
@@ -249,7 +249,7 @@ def test_a_session_torn_down_during_the_read_is_a_404_not_a_500(
# The file existed when the wait checked, and is gone by the time the route reads it.
monkeypatch.setattr(plex_stream, "wait_for", lambda *a, **k: True)
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts")
assert r.status_code == 404, r.text
assert r.status_code == 410, r.text
def test_a_dead_producer_is_not_waited_out(client, tmp_path, sessions):
+34 -2
View File
@@ -14,7 +14,7 @@ from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from app.discover import pins
from app.catalog.models import Channel, Video
from app.catalog.models import Channel, Subscription, Video
from app.reader import queries, service
from app.reader.models import ChannelTag, ComfortPin, Playlist, PlaylistItem, Tag, VideoState
@@ -60,12 +60,44 @@ def test_a_system_tag_is_nobodys_to_edit(db, make_user):
service.create_tag(db, other, "Theirs", "other", None) # another user's tag stays theirs
db.commit()
assert queries.own_tag(db, user, system.id) is None
assert [t.id for t, _ in queries.tags_with_counts(db, user)] == [system.id]
assert [t.id for t, *_ in queries.tags_with_counts(db, user)] == [system.id]
# --- tags ------------------------------------------------------------------------------------
def _music_on_four(db, make_user):
"""A tag on four channels: two followed, one followed but hidden, one no longer followed."""
user, other = make_user(), make_user()
tag = service.create_tag(db, user, "Music", "other", None)
for cid in ("UCa", "UCb", "UChid", "UCgone"):
db.add(Channel(id=cid, title=cid))
db.flush()
for cid in ("UCa", "UCb"):
db.add(Subscription(user_id=user.id, channel_id=cid))
db.add(Subscription(user_id=user.id, channel_id="UChid", hidden=True))
db.add(Subscription(user_id=other.id, channel_id="UCgone"))
for cid in ("UCa", "UCb", "UChid", "UCgone"):
db.add(ChannelTag(channel_id=cid, tag_id=tag.id, user_id=user.id, source="user"))
db.commit()
return user, tag
def test_a_tag_counts_apart_the_links_on_channels_the_user_still_follows(db, make_user):
"""An unsubscribe and a hide keep the channel's tag links; the Channels page's filter lists only
the followed channels not hidden, so its count must not hold the others."""
user, tag = _music_on_four(db, make_user)
[(_, count, subscribed)] = [r for r in queries.tags_with_counts(db, user) if r[0].id == tag.id]
assert (count, subscribed) == (4, 2)
def test_the_tag_list_sends_each_count_under_its_own_name(db, make_user, as_user):
"""Both counts are ints, so a swap would type-check: the answer itself pins which is which."""
user, tag = _music_on_four(db, make_user)
[row] = [t for t in as_user(user).get("/api/tags").json() if t["id"] == tag.id]
assert (row["channel_count"], row["subscribed_count"]) == (4, 2)
def test_a_tag_is_made_once_per_name_and_an_unknown_category_is_other(db, make_user):
user = make_user()
tag = service.create_tag(db, user, "Later", "nonsense", "#fff")
+148
View File
@@ -0,0 +1,148 @@
"""A share link's address says what it opens: /watch/ a video, /file/ anything else, /folder/ a folder.
Only a word for the reader: the token is the key, so every path opens every link, and the links
already sent out on /watch/ keep working for good. DB-lane.
"""
import pytest
from cryptography.fernet import Fernet
from app import main, security
from app.config import settings
from app.downloads import og
from app.downloads import probe as probemod
_PAGE = """<!doctype html><html><head>
<!--OG:START--><meta property="og:title" content="Siftlode" /><!--OG:END-->
</head><body><script src="/assets/{entry}.js"></script></body></html>"""
_VIDEO = {
"streams": [
{"codec_type": "video", "codec_name": "h264", "pix_fmt": "yuv420p"},
{"codec_type": "audio", "codec_name": "aac"},
],
"format": {"format_name": "mov,mp4", "duration": "5"},
}
_AUDIO = {
"streams": [{"codec_type": "audio", "codec_name": "aac"}],
"format": {"format_name": "mov,mp4", "duration": "5"},
}
@pytest.fixture
def pages(tmp_path, monkeypatch):
index, watch = tmp_path / "index.html", tmp_path / "watch.html"
index.write_text(_PAGE.format(entry="app"), encoding="utf-8")
watch.write_text(_PAGE.format(entry="watch"), encoding="utf-8")
monkeypatch.setattr(main, "INDEX_HTML", index)
monkeypatch.setattr(main, "WATCH_HTML", watch)
@pytest.fixture
def key(monkeypatch):
"""A link's address is read back from its encrypted token."""
monkeypatch.setattr(security, "_fernet", Fernet(Fernet.generate_key()))
@pytest.fixture
def shared(as_user, make_user, tmp_path, monkeypatch, upload_headers):
"""Uploads a file ffprobe reads as `data` (None: it cannot read it) and shares it."""
monkeypatch.setattr(settings, "files_root", str(tmp_path / "media"))
owner = as_user(make_user())
def share(name: str, data: dict | None, **link: object) -> dict:
monkeypatch.setattr(probemod, "probe_json", lambda _p: data)
job = owner.post(
"/api/files/upload",
content=b"some bytes",
headers=upload_headers(name, display_name=name),
)
assert job.status_code == 200, job.text
made = owner.post(f"/api/downloads/{job.json()['id']}/links", json=link)
assert made.status_code == 200, made.text
return made.json()
share.owner = owner # type: ignore[attr-defined]
return share
@pytest.mark.parametrize(
("name", "data", "prefix"),
[
("clip.mp4", _VIDEO, "/watch/"),
("song.m4a", _AUDIO, "/file/"),
("backup.tar.gz", None, "/file/"),
],
)
def test_a_link_is_given_out_on_the_path_of_what_it_opens(shared, name, data, prefix):
made = shared(name, data)
assert made["url"] == f"{prefix}{made['token']}"
def test_a_folder_link_is_given_out_on_folder(shared, key):
owner = shared.owner
folder = owner.post("/api/files/folders", json={"name": "Friends"}).json()
made = owner.post(f"/api/downloads/folders/{folder['id']}/links", json={}).json()
assert made["url"] == f"/folder/{made['token']}"
listed = owner.get(f"/api/downloads/folders/{folder['id']}/links").json()
assert [entry["url"] for entry in listed] == [made["url"]]
def test_the_list_gives_the_same_address_as_the_making(shared, key):
made = shared("backup.tar.gz", None)
owner = shared.owner
files = owner.get("/api/files").json()
job_id = next(f["id"] for f in files if f["display_name"] == "backup.tar.gz")
listed = owner.get(f"/api/downloads/{job_id}/links").json()
assert [entry["url"] for entry in listed] == [made["url"]]
@pytest.mark.parametrize("path", ["watch", "file", "folder"])
@pytest.mark.parametrize(
("name", "data"), [("clip.mp4", _VIDEO), ("backup.tar.gz", None)], ids=["video", "file"]
)
def test_every_path_opens_every_link(pages, shared, client, path, name, data):
"""The links already sent on /watch/ open whatever they open, for good."""
token = shared(name, data)["token"]
r = client.get(f"/{path}/{token}")
assert r.status_code == 200
assert "/assets/watch.js" in r.text and "/assets/app.js" not in r.text
assert f'content="{name}"' in r.text
@pytest.mark.parametrize("path", ["/file/no-such-token", "/folder/no-such-token", "/file/a/b"])
def test_a_new_path_without_a_live_link_still_gets_the_player(pages, client, path):
r = client.get(path)
assert r.status_code == 200
assert "/assets/watch.js" in r.text and "/assets/app.js" not in r.text
def test_a_card_names_a_file_as_a_file(shared, db):
made = shared("backup.tar.gz", None)
tags = og._watch_tags(made["token"], db)
assert f'{made["url"]}"' in tags
assert "video.other" not in tags
locked = shared("notes.tar.gz", None, password="pw")
hidden = og._watch_tags(locked["token"], db)
assert "Password-protected file" in hidden and "notes" not in hidden
assert "video.other" not in hidden
def test_a_card_of_a_video_stays_a_video(shared, db):
made = shared("clip.mp4", _VIDEO)
tags = og._watch_tags(made["token"], db)
assert f"/watch/{made['token']}" in tags and "video.other" in tags
locked = shared("other.mp4", _VIDEO, password="pw")
assert "Password-protected video" in og._watch_tags(locked["token"], db)
@pytest.mark.parametrize(
("name", "data", "video"),
[("clip.mp4", _VIDEO, True), ("song.m4a", _AUDIO, False), ("backup.tar.gz", None, False)],
)
def test_a_password_link_says_whether_it_opens_a_video(shared, client, name, data, video):
"""The lock screen asks to watch a video and to open anything else, whichever path it is on."""
token = shared(name, data, password="pw")["token"]
meta = client.get(f"/api/public/watch/{token}").json()
assert meta == {"needs_password": True, "kind": "file", "video": video}
+154 -146
View File
@@ -301,7 +301,8 @@ Also in force:
`fade-below` utility (`fade-below-panel` / `fade-above-panel` on a panel); the page band and a
scrolling panel under its header fade by the scroller's own edge mask (`useScrollFade`), because what
they sit on is the backdrop, not a colour. The chip rows fold their overflow into "+N" rather than
scroll, so no row has clipped ends; the carousels fade theirs. The dialog edges come with C4's pinned
scroll, so no row has clipped ends, and a filter row puts its picked chips first so the fold never
holds the only sign that a filter is on; the carousels fade theirs. The dialog edges come with C4's pinned
footer (S3f).
- **Viewport height:** `dvh`, never `vh` / `h-screen`: `100vh` is the viewport with a mobile browser's
toolbar retracted, so an `h-screen` shell runs its last rows under the toolbar (contract-tested).
@@ -717,148 +718,155 @@ of a full-width panel and nothing on the right.
## Decision log
| Date | Decision | Chosen | Over | Why |
| ---------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-09-24 | Floating button | Back to top: a round 48 glass FAB; "round" = floats over something (media or the page) | A Material 3 rounded square; a labelled pill; the old 64 circle | The common web convention for back to top; round tells it apart from every toolbar button; 64 was oversized |
| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function |
| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows |
| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" |
| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags |
| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers |
| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass |
| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context |
| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites |
| 2026-09-24 | B1 Density | Compact 32 / 28 / 24 by explicit height | Standard 38 / 32 / 26 | The owner's read of the lab; touch is covered separately (A3), and one toolbar row lines up at 28 |
| 2026-09-24 | B2 Scheme set | Midnight, Forest→cyan, Slate→fuchsia, Matrix→lime | Today's six | The accent collided with danger (YouTube), warn (Slate) and success (Forest, Matrix); Midnight and Starship were one scheme twice; 4 of 6 light accents failed 4.5:1 |
| 2026-09-24 | B3 State layers | Text colour at 10% / 16% over anything | Material's 8 / 12%; today's opacity + bg-card | Visible in every scheme and mode (≥ 1.2:1); today's row hover was 1.00:1 on a card |
| 2026-09-24 | B4 Page chrome | One sticky band + fade; filters fold when short | A floating glass bar; everything scrolls | Tabs and filters always in reach, in one place; a keyboard-up tablet keeps its content |
| 2026-09-24 | B5 Surfaces | One class + radius per role | Today's per-site mix | Nested corners and no per-site alpha drift |
| 2026-09-24 | B6 /watch | The app's surfaces, native video controls | Flat and minimal | The page strangers see should look like the product; native controls work on any device |
| 2026-09-24 | B7 Nav rail | Soft tint + edge bar, optional labels (a user setting), slim | Solid accent (recommended); a Material rail | The owner's combination: calmer than a solid fill, labels for who wants them, no width cost |
| 2026-09-24 | B8 Loading | Skeleton by shape, spinner in a control | A spinner everywhere | Nothing jumps when data lands; a long wait doesn't look stuck |
| 2026-09-24 | B9 Fade | Toolbars / headers, panel tabs, dialog edges, chip rows | Thumbnails; buttons | The north-star depth where content scrolls under chrome; not on content or small controls |
| 2026-09-24 | C1 Action classification | The table (lab C1): state + primary at rest, everyday toggles on hover, "…" on touch, destructive into the menu | Visible buttons per site, as today | One action list per object, nothing gesture-only, fewer buttons without losing a function |
| 2026-09-24 | C2 Side panels | A tap opens an overlay drawer; pin is a separate button ≥ 1024 | A tap pins and the content reflows | Below 768 the filters were unreachable; on touch a tap pinned and pushed the grid |
| 2026-09-24 | C3 Players on touch | Tap = show the controls; 44px seek hit area; 44 buttons; the YT description behind ⓘ | Today (tap also pauses; 7.5px seek bar) | Measured live: you couldn't reach the seek bar without pausing, and the hover-only description never opened on a tap |
| 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up |
| 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip |
| 2026-09-24 | Glass tuner | Retired; the schemes are the only colour choice | Keep it with ranges clamped to the rules | Free tuning is the drift the constitution exists to stop (owner) |
| 2026-09-24 | Matrix toned down | Moss #86a05a at Midnight's lightness, near-neutral greys; every white-on-accent part → `--accent-fg` | The B2 lime #a3e635 | The owner found the lime neon and tiring; it was the loudest accent (L 0.85, C 0.21), and fixed white parts sat on it at 1.5:1 |
| 2026-09-24 | Field primitives | `Input` / `Textarea` / `InnerInput` + `fieldClass`, `SelectTrigger`, the native controls; the lint rule "no raw `<input>`" | `inputCls` and ~40 hand-styled boxes | Six edges, four radii and two focus looks for one control; the ≥ 3:1 edge held nowhere |
| 2026-09-25 | Surface roles | `SURFACE` roles in `ui/surfaces.ts`; the lint rule "no bare glass class"; menus p-1 with r8 rows | Three tiers paired ad hoc with five radii and three menu paddings | ~140 sites drifted; nested corners only hold if the radius comes with the role |
| 2026-09-25 | Static labels | Flat tonal `Badge`, no edge; a filtering tag is an edged chip | A glass pill with an edge, like the chips | Edged = pressable at a glance; a static tag that looks like a chip gets clicked and does nothing |
| 2026-09-25 | Header search | A 40 glass pill with the field edge (`lg`); what sits inside it is round | 44, r16, a glass box with a faint edge | The one rounded box among pills, and its edge under 3:1; the hero search is the YouTube/Gmail pill |
| 2026-09-25 | Switch row (F12) | The whole settings row toggles (`SwitchRow`; a hand-laid row points a label at the switch) | Only the 36×24 switch toggles | The iOS / Material settings row; the text was a dead click beside the control it names |
| 2026-09-25 | Edges and hovers (F14) | The ≥ 3:1 edge on the field family (fields, the form trigger); labelled pills keep the faint edge and the accent-edge hover | The ≥ 3:1 edge and a state-layer hover on every control | 1.4.11 asks an edge only where nothing else identifies the control; a heavy edge on every chip and tab weighs the whole UI down |
| 2026-09-25 | Rating and live (F16) | Own tokens: `--rating` (theme-scoped gold), `--live`/`--live-fg`, `--rating-media`, `--media-chrome`; palette classes lint-refused | Map them to `--warn` / `--danger` | A gold star and a red LIVE are what the eye expects; as warn/danger they would read as a status. The old amber score was 2:1 on light |
| 2026-09-25 | Fjord trial (B2 exception) | A fifth scheme, temporary: Midnight's neutrals, Playnite's steel accent (#337599 lifted to #5aa1cb) and steel rules; exit = fold into Midnight or remove | Retune Midnight straight away; borrow only the qualities | The owner wants to live with the calmer palette app-wide before it replaces the default; it breaks "own hue family" on purpose, and only until that call |
| 2026-09-25 | Whole-card row (owner) | A card row's B3 layer is the card's `::before` under its content; the open button's hit layer spans the card, the actions sit above it | The name button's own hover, ending at the first badge or button | A row that half-answers the pointer reads as broken; a badge click did nothing |
| 2026-09-25 | Badge tones (F29) | `Badge tone` neutral / accent / warn at 6%; light `--warn` #b45309 → #a14a07; edged label spans lint-refused | Keep edged status pills (a private Badge in Users, Settings "Granted", the suggestions count) | Edged = pressable at a glance; amber-700 was 4.58:1 as text and fell under 4.5 on its own tint |
| 2026-09-26 | Page band built (B4) | Controls only in the band (intros scroll); the fold shows the same row in place; one `h1` per page (`PageHeading` steps the hidden one aside); `SectionLabel` headings; Skip to content | A popover of the folded filters; the page title kept visible | A folded row is still the same controls, nothing duplicated; the rail and the tab title already name the page |
| 2026-09-26 | Fade built (B9) | `fade-below` on sticky toolbars and table headers; the scroller mask where the band sits on the backdrop | A painted band over the backdrop | The backdrop is the colour under the band, so fading the content into it is the same edge |
| 2026-09-26 | Viewport units | `dvh` everywhere (the Plex player stage with S3f) | `vh` / `h-screen` | `100vh` hides the last rows under a tablet browser's toolbar |
| 2026-09-26 | Video actions built (C1) | One list per video (menu, right-click, long-press, "…"); the card keeps watched / save / add to playlist on its picture on hover; download, details, copy, hide, reset in the menu | The six-button row under the card and the hover ⓘ | The menu is the one door every device has; the card loses a line of height and the touch card its row of 28px buttons |
| 2026-09-26 | Plex actions built (C1) | One list per Plex title (play/open, watched, add to playlist, media info, copy title, pin); the poster keeps watched / add / "…" on its picture on hover; seasons get open / watched / add / copy | The hover ⓘ, the pin-only menu, an episode's add button at rest, a collection's "Remove" X on the card | The same door as a video's; the collection grid goes to two columns on a phone so the touch "…" leaves the title its line |
| 2026-09-26 | Playlist actions built (C1) | A playlist row's "…" (play, pin, copy title / link, move, order, remove), Delete + an undo toast; one list per playlist (rename, pin, YouTube open / push / revert, delete) from its rail row and its header's "…" | The row's X column and its confirm; the header's pencil, YouTube link, push, revert and trash buttons | Remove is reversible, so it needs no confirm; the header keeps Play all; a narrow table drops "#" and Channel so the title keeps a width |
| 2026-09-26 | Channel + Plex list actions (C1) | A channel row's "…" (open, pin, YouTube, copy, full history, hide + undo, unsubscribe); a Plex playlist's title / show / season "…", Delete + undo; its header's "…" (rename, delete); an action toast lasts ≥ 8 s | The channel row's full-history, hide and unsubscribe buttons; the Plex list's red X on every row, show and season; the header's pencil and trash | Hiding and removing are reversible, so they need no confirm and the undo needs time; unsubscribe and deleting a playlist keep theirs |
| 2026-09-26 | Download / notification / Scheduler actions (C1) | A download row's "…" (play, share, save, edit, details, re-download, manage sharing, copies, cancel / delete / remove); a library row's Share stays in sight, ahead of save; a notification row's "…" and hover dismiss; the inbox band's and the Scheduler strip's "…" | The download row's five rare buttons and its cancel / delete / remove; the source link's copy button; an activity notice's Find / Undo links; the band's Clear everything; the strip's Purge discovery | The destructive rows keep their confirm, and Clear everything gains one; a row link keeps the browser's own menu; Find in feed drops the tag / search / age filters that could hide its video |
| 2026-09-26 | Channel menu: Tags + Priority (C1) | Tags A–Z as in-place checks; Priority as fixed steps 10 · 5 · 3 · 2 · 1 · 0, the current one ticked; a pick re-sorts at once and the page + focus follow the row; one drawn `MenuCheck` for every checkable row that cannot be an `<input>` | Relative steps (+1 / +5); straight to the top; the stepper only; seven hand-drawn check marks (three box sizes, a tinted box, a × for "on", a trailing tick) | Bubbling a channel up is one pick; fixed levels keep the scale the reader already uses; the list moving where the reader can see it is the feedback |
| 2026-09-26 | Reorder without a drag (WCAG 2.5.7) | Row menus: Top / Up / Down / Bottom (YouTube + Plex playlists); saved views: four buttons with the place beside the selected view (owner: A); side panels in edit mode: an Up / Down pair per group (owner: A), the name wrapping there | A "…" on every saved-view row; a "…" on every panel group; drag only | A visible, one-click path where the list is edited; the edge moves shown off so the buttons never shift |
| 2026-09-26 | Titles in click surfaces | The surface stays a button, flagged `selectable`: its text selects, and the click that ends a selection does nothing; every plain `Pressable` pinned with its reason | Rebuilding each card and row so the title sits outside the button; exceptions for master-detail lists | Chrome selects inside a button once it is `user-select: text` (measured), so one flag on the primitive keeps the keyboard, focus and list walking every surface already has |
| 2026-09-26 | Right-click on a selection | The browser's own menu opens on selected words; beside them the object's menu | A "Copy selection" row in the app menu | The reader selected the words to do something with them; the native menu copies, searches and translates, and changes nothing else |
| 2026-09-26 | Keyboard ring on a flush row | Inside the row (a table row by its tag, a log line, a show header in its clipping card, with the card's corners) | The ring outside, as on a card | Outside it ran under the table head and past the table, and a clipping card cut it away entirely (invisible focus) |
| 2026-09-26 | Fade at rest | The sticky header's fade appears with the first 1rem of scroll (a scroll-driven animation; no fade where unsupported) | A fade that is always drawn | At rest it dimmed the first row and hid its ring's top edge; nothing is under the header yet |
| 2026-09-26 | Narrow playlist table | "#" and Channel hide by the table's own width (under 42rem), not the window's | Hiding below the `sm` window width | Beside the playlist rail a desktop window left the table 432 px and the title 0 px |
| 2026-09-26 | Background menu built | Five list pages (feed, Plex grid, channels, playlist, notifications); the toolbar's sort / view / filters and the band's actions; no destructive row | Every page with a toolbar; the "…" menus copied whole, delete included | The tab-only pages have nothing a menu would add; a blank-area right-click must not be one click from delete |
| 2026-09-26 | Media card anatomy (C) | One frame; the meta line pinned to its bottom; the length a badge on the picture; the Plex card on the same primitive as the video card | A: the year as an overline over the title; B: the year inline after it; a fixed two-line title slot | A one-line title left a hole the eye reads as a missing line; C puts nothing under a variable-height title |
| 2026-09-26 | One Play | `MediaPlayGlyph` (the media button variant) and only where the click plays | The Plex card's hand-painted accent circle; a playable dot on every card | One Play for every card; a dot that only a colour and a tooltip explain says nothing |
| 2026-09-26 | Filtering values on the info page | A value that filters (year, certificate, studio, director) is an edged chip; a fact is flat text | Everything flat, the clickable ones only hover-tinted | The S3d rule: edged = pressable, so the reader sees which values do something |
| 2026-09-26 | Shelf density | On any strip: one title line over one meta line | The grid's two lines and full meta on a strip | The tallest card set the strip's height and left holes in the cards with less to say |
| 2026-09-26 | Compact list row (B) | The title leads; a faint "…" at the end; the toggles float over the date on hover | A leading 128px action column | That column sat empty at rest, a hole at the start of every row |
| 2026-09-26 | Media-info layout (B) | A main column (collections first, crew, cast) beside a facts sidebar; an empty column is not drawn | Every section full width, stacked | The collection is the most used; the keywords' column left a void beside a short details box |
| 2026-09-26 | Mixed Home shelf (A) | Plex titles as wide art beside videos | Posters, with the videos not stretched | Stretching a video card to a poster's height left an ~80px gap |
| 2026-09-27 | Home hero | A card (hover row + Play on the picture, the whole menu, a touch "…"; owner: A); three quarters wide and centred on a wide window; one fact line; the summary at the measure | The hero's pin row alone; full width; an 18rem picture column | The one answer stands in focus, and it offers what the same card offers anywhere |
| 2026-09-27 | Home shelf tiles | A channel / playlist tile: Play, ↗ open, "…" with Open; a show acts on its next episode, ⓘ opens it; the shelf slot 232px | The tile's pin row alone; 200px | A tile without its object's actions read as broken; at 200 the Play touched the hover row |
| 2026-09-27 | Synopsis preview (C) | A ~0.9 s mouse rest opens a passive panel beside the card | The synopsis as the picture's `title` tooltip; an ⓘ to hover; none | One long tooltip line fired anywhere on the art and clashed with the cut title's own tooltip |
| 2026-09-27 | Reading measure | Prose capped at ~65 characters a line (`measure`) | The container's full width | At 110–130 characters a line the eye loses the next line's start |
| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide |
| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
| 2026-09-27 | C4 as built | `initialFocus` required on every `Modal`; the commit row in `ModalFooter`, portalled under the body from any depth; the fades driven by the body's scroll timeline; `interactive-widget=resizes-content` | An optional prop defaulting to the ✕; a `footer` prop (the issue composer's buttons live three components down); always-on fades | A default is how 21 dialogs came to open on the ✕; lifting the composer's state to reach a prop is the larger change; a resting fade dimmed the first line |
| 2026-09-27 | C3 + C5 as built | A tap toggles the bar through `pointerType` from `pointerdown`; a double-tap takes back its first tap's toggle; the card's pair is the video card's `MediaResumeButtons` (Continue / Restart), a sibling over the picture; the YT description is an ⓘ click popover | A delayed single tap (wait out the double-tap window); new "Resume / From the start" words for Plex; the pair inside the picture's button | A delayed tap feels dead; one media card says one pair of words; a button inside a button is invalid and the hover row already sits beside the picture |
| 2026-09-27 | C2 as built | The side panel's drawer is `ui/Modal` `drawer-start`; drawer = a coarse pointer or below 768; the pin offered in its header ≥ 1024 | A drawer of its own inside `SidePanel` | Esc, Back, the scrim, the trap and the scroll lock are the dialog's; a second implementation is how two shells drift |
| 2026-09-30 | Composition and the reader (E30 S6) | A chapter and a Conformance step: the space is used, one focal point, worst real data, where am I, a click answers at once, zoom and text size first-class; the person page as two columns (facts and bio left, "Known for" right) | Rules for pieces only; the bio in a full-width panel; a narrower panel | The person page passed every piece rule with half its panel empty; the owner reads zoomed in, so zoom is a requirement, not a nicety |
| 2026-09-30 | Hungarian words (E30 S5) | The D3 list: Feed, Shorts, Admin, Token, Port stay; stream, Prio, Job, Audit, Demo, Auto, Info, Frontend/Backend, Backfill, transcode get the Hungarian word; one name per thing (kérelem, behúzás, one "Test connection") | Keep every loanword; translate Feed/Shorts too | The owner chose the list (D3); the final sweep found the same feature under two names in three places |
| 2026-09-29 | Numbers and order in the UI language (E30 S3) | Every number through `lib/format.ts` in the UI language (decimal mark, grouping, compact, units); A→Z by the shown name in the UI language's alphabet, client and server alike; the language comes off `useTranslation`, never the singleton | `toFixed`, hand-built "1.2K", `localeCompare` in the browser's locale, byte-order server sorts | A Hungarian reader saw "8.3", "1.2K", "Életrajzi" after "Z"; the singleton read also left values in the old language after a switch (React Compiler) |
| 2026-09-27 | Narrow rows by container | The download row, the playlist table and the Channels tag row stack by a container query; a fact line never wraps | Breakpoints on the window | The iPad zoomed in, split screen and a column beside a rail are narrow while the window is not; the zoomed download row broke "18.9 / MB" apart |
| 2026-09-27 | Phones deferred | A phone-only layout (the hamburger nav, a folded feed band) waits for its own epic; E28 stays desktop + tablet | Fix the phone views inside E28 | Principle 2; the first phone attempts made the feed worse, and the epic's review grows with every sprint |
| 2026-09-27 | Conformance check (S3g) | A four-step check before a UI change is done: primitive at a named rung, the chapter re-read, the rendered page measured by kind and look, every state seen | A reviewer's eye per sprint | The S1 probe found 69 button looks; after S3 every control kind is drawn by its primitive, and the check keeps a new look from arriving unnoticed |
| 2026-09-27 | Segmented rung | `SegmentedToggle` defaults to the toolbar rung (28, `text-xs`); 24 only for a panel's captioned match toggle | The small default | Two toolbars had inherited 10px type from the default beside 28px buttons |
| 2026-09-27 | Copy rule (S3g) | One name per feature (the rail may shorten it); four verbs for four meanings; sentence case with the app's places capitalised; "…" when a command asks first; one-sentence messages without a stop; curly quotes; dates relative in lists with the exact minute on hover, three absolute forms; units from Intl, short | Word-for-word names everywhere; the verbs as they were; all lower case; no "…"; always a stop; always absolute dates; long or compact units | The copy survey found 7 EN and 6 HU double names, 22 Delete against 27 Remove, five date shapes and three spellings of a minute; the owner picked every recommended option |
| 2026-09-27 | One glyph, one meaning (S4) | Play only for playback or resuming a paused transfer; Retry = `RefreshCw`; reset / revert = `RotateCcw` | Retry drawn with Play | The first UI-review run (Downloads) found a failed row whose Retry showed ▷ in the row and its menu |
| 2026-09-27 | Fjord kept | Fjord stays a fifth scheme beside Midnight; the trial wording goes | Fold it into Midnight; remove it | The owner lived with it and likes it; the vivid Midnight stays for those who want it. B2's own-hue rule still binds any new scheme |
| 2026-09-27 | Tablet columns | A table hides `tabletHidden` columns below 64rem of its own width; Channels drops Prio, About, Length | Hide About only; give the name a bigger share | At 1024 the channel name had 60 px; the owner chose to drop all three there |
| 2026-09-27 | Glyph pager | First / previous / next / last as glyph buttons round the page box, names as tooltips | Words beside the arrows (hidden only on a phone) | The owner's call: the words widened the row; four arrows are the shared convention |
| 2026-09-27 | Match switch on the panel | `ui/MatchToggle` above the picker from two picks, everywhere; one wording (Any / All) | The picker's popover footer (Feed, Plex collection) | A closed picker hid an active "all"; six pickers had drawn it two ways |
| 2026-09-27 | Missing picture, bare link | A glyph of the kind for no picture; a download with no name is titled by its short link, once | An empty box; the URL as title, channel and link | The owner's picks 4A and 3A: an empty box read as loading, the URL showed three times |
| 2026-09-27 | Copy on a selection | Right-click on selected words keeps the browser's menu; no app Copy row | An app menu with Copy first | A real browser's menu has copy, search and translate; only the desktop pane's is thin |
| 2026-10-01 | Zoom target 125–133% | Screens are held at 125% and 133% browser zoom (1536 × 729, 1444 × 685 on a 1080p window) and the largest text size | 200% browser zoom | The owner: 200% was a guess; 125–133% is what they read Siftlode and most sites at, so 200% would be an upper bound nobody uses |
| 2026-10-01 | E31 Nav rail at short heights | One "Admin" rail item opening a panel of the admin pages | A denser rail when short; a visible "+4" hint | The rail fits at 133–150% without a hidden scroll; the admin pages are rare and admin-only |
| 2026-10-01 | E31 Configuration sections | A vertical, grouped section list, as Settings has | Group tabs + sub-tabs; one long page with a contents list | Sixteen tabs wrapped to three rows at 133%; one pattern for both settings pages |
| 2026-10-01 | E31 Statistics overview | Number tiles, and the per-operation counts as horizontal bars | Three narrow cards; a 30-day chart | The label sits by its value; what spends the quota is seen at a glance |
| 2026-10-01 | E31 Channel page header | The full banner, folding to one row once the grid scrolls | Always compact; no banner | The header is fixed and took 40% of a 685px window; the channel keeps its face at first sight |
| 2026-10-01 | E31 Channel page watch state | A fresh channel page shows the unwatched videos; a picked state is remembered | Show all, forgotten on reload | The owner: open a channel to see what is new; a choice made there should stick |
| 2026-10-01 | E31 Audit log runs | A job's successful runs fold into one row that opens, whatever ran between; only that job's failure (never folded, danger colour) breaks it | A filter defaulting to admin actions; both | Nothing hidden, the noise collapsed, the failure seen |
| 2026-10-01 | E31 Users row | Role and state as badges, the actions in the row's one menu | An inline role toggle; icon-only buttons | Action placement: the row shows state, rare and destructive actions live in the menu |
| 2026-10-01 | E31 YouTube player | Video beside an info panel at 1280px and wider (the video as large as the height allows); stacked below | Today's stack with a one-line bar; full-window like Plex | The owner's eyesight: about 1460px of video on 1080p instead of 1008, the actions still visible |
| 2026-10-01 | E31 Plex info and show pages | A second column for the facts, keywords and crew, as the person page; a collection's hero sized to its content | Full-width text; a larger poster and fanart | The right ~40% was empty; one composition for every media page |
| 2026-10-01 | E31 Nav rail rows are links | Each page row is a real link: a middle-click or Ctrl-click opens the page in a new tab | A button | The owner: open a page in a tab of its own, as on any site |
| 2026-10-02 | E31 Plex detail second column (refined) | The facts over the key crew beside the text; the keywords, chips that filter like the genres, under the text; the show page gets all three too | All three in the column, as the mockup drew it | At 20 keywords and 12 crew the column ran to twice the text's height (849 vs ~400 px at 133%): a hole under the text instead of beside it |
| 2026-10-02 | E31 Plex detail poster and crew | The movie and show poster keeps its 2:3 and grows a rung in a wide hero (w-56 at a 64rem panel); the crew shows its first 6 names and "Show N more" (a list of 8 or fewer shows whole) | Poster stretched to the row; a "More" on the keywords | The row is as tall as the crew column, so the poster drew ~175×590 and cropped; the crew, not the keywords, set the hero's height |
| 2026-10-04 | E32 `field` focus skips a mode switch | A form opens on its first field; a control above it that is not a field (the composer's Edit / Preview) is passed over by marking the field `data-initial-focus` | The first text field everywhere | That moved the focus in the download dialog off its profile choice and in the upload dialog off its drop zone; the mark changes only the form that needs it |
| 2026-10-04 | E35 Lint for z-levels and edged labels | Both rules stay; the two eslint selectors (`Z_RAW`, `EDGED_LABEL`) that refused them go | Keep the selectors | 0 hits since the E28 sweep that fixed the sites: a check that never fires costs a probe test and a snapshot each, and the UI review holds both rules |
| 2026-10-06 | `player` focus | A dialog that is a player opens focused on its panel (the YouTube player's rule, now `Modal`'s too); on the media element itself, Space and the arrows stay the element's own | `close`, or the media element | On the ✕ Space closed the player; on the element the browser's own controls acted on the same key as the player's (one Space played and paused, measured in Chrome) |
| 2026-10-08 | Captions on touch | A `Tooltip` opens for a mouse and a key's focus only, never for a finger | A caption on tap; a long-press caption | On an iPad the tap that showed the caption was taken as a hover and lost its click (every rail button took two taps), and no finger sends the mouseleave that hides it; the rail's labels setting names the buttons on touch |
| 2026-10-08 | Header search on touch | No key cap on a touch screen; the pill grows to 52 px so a ring shows round the 44 px Go; Go drops to its icon by the column's width | Keep the key cap; a 44 px Go overhanging a 40 px pill | On a tablet the 44 px key cap and Go sat on the pill's edge and cut the placeholder; an overhanging Go breaks the nested radii |
| 2026-10-08 | Plex subtitles and the control bar | The player draws the cues itself; while the bar shows they rise above it (200 ms), then settle back | The browser's own cues, moved by `line` | On an iPad the bar covered the text; a native cue cannot be animated or measured, and our layer behaves the same on every engine |
| 2026-10-08 | Hover on a button over a picture | The `media` variant's hover is a 2px accent ring over a darker body (Plex + YT cards, rail arrows; option B) | An accent fill (reads as "selected"); a white wash (weak on a bright poster) | The 60 → 75% black step was invisible, and over a card a rail arrow could not be told from the card's own Play under it |
| 2026-10-08 | Plex "Add to collection" | A movie or show card's menu row opens a panel beside the card: its library's manual collections tick in place, a name + Enter creates one with the title in it (admin; option A) | An Add submenu with a "New…" dialog; the per-title editor modal from the menu | A collection took a trip to the Collections page, a name, a title search, then a second search per title; the panel is the video card's "Add to playlist" |
| 2026-10-08 | YouTube modal chrome | No prev/next strips beside the card; Close an icon (the panel's top right; stacked, the title row's end); the description clamped to three lines + More; the panel shuts to a 2.75rem strip of close, expand, prev/next (option D1) | Keep the strips; a "Close" text button; the full description in a scroller; a panel that hides outright | The strips cost the video 7rem of width for a second copy of the panel's buttons; a long description pushed the channel and the actions out of sight; a hidden panel left no way to step the queue without the keyboard |
| 2026-10-08 | The wheel over the YouTube modal | Over the video: volume. Over a panel with something to scroll: scroll only, never volume, not even at its end. Over a panel with nothing to scroll: volume (owner) | Volume everywhere; a scroll that hands over to the volume at its end | Reading a long description turned the volume down once it hit the end |
| 2026-10-09 | A Plex show card plays | Its picture plays the show's next episode everywhere (an episode with a resume point → Continue + Restart, else the first unwatched, else S1·E1), resolved on the click; the preview names it once up; title and ⓘ open the show (option A + hover ask) | The picture opens the show page (as before); every card carrying `next` | A show card on Discover had no Play while Home's had one; carrying `next` costs a grid page up to 180 queries for the few cards anyone plays |
| 2026-10-10 | Clip mode in the players (E38) | Scissors in the control row + `C`, `[` `]`; the panel in the player with the watched video as preview; overview + zoomed strip on `ui/RangeTrack` (ends are sliders); a toast + the Ready notification after Cut | A ⋯ menu entry; permanent In / Out buttons; a big dialog with its own preview (a second Plex transcode / YouTube iframe) | Owner's picks A, P1, a; a film is hours and a clip seconds, so only a zoomed strip gives a hand the precision. The Downloads editor's cuts moved onto the same track (keyboard-steppable now) |
| 2026-10-10 | Crop in clip mode (E38) | A Crop toggle in the clip panel: a frame over the playing picture (`ui/CropFrame`, the editor's crop too), Free / 16:9 / 9:16 / 1:1, Whole picture; maximised YouTube keeps the panel in the bar's place; the wide side panel shuts while clipping | A frame always on the picture in clip mode (K2); a free shape only | Owner's K1 + a: a crop is rare, so it never covers the picture unasked; a phone or square clip needs a held shape. The iframe hides its picture's size: drawn on the 16:9 box, the cut fits it to the real picture |
| 2026-10-10 | Marks on the player chrome | `--media-mark` (amber) and `--media-danger` (light red), the same in both themes; `media-marks` points the focus ring (`--focus`) and the chosen mark (`--chosen`: a track's tick, a tab's underline; the label stays white) at the amber on the player and every chrome surface; `media-scope` (a form) also the errors at the red | The theme's accent ring, chosen text, warn and danger | On the chrome (#171717) a light theme's accent ring (Slate 2.84:1), warn (2.99:1) and danger (2.77:1) all fall under 3:1; the accent as chosen text under 4.5:1 |
| 2026-10-11 | Files page (one library) | List rows by default, the media grid a toggle remembered per folder; a sidebar of automatic source folders, own folders, Shared with me and the storage meter; the paste-a-link box in the toolbar | A media grid by default | The library holds videos AND plain files: rows read both, show more per screen and scan by size and date |
| 2026-10-11 | Library list columns | Every kind of value in its own column (name, source, phone-safe, retention, size, added, actions), one grid with a subgrid per row so they line up; an action that does not apply keeps its slot empty; a right-click menu on every row (the ⋯ opens the same menu) | Badges flowing after the title; icons that shift when one is missing | Owner: values of one kind must fall in one vertical line; a row's every action reachable by right click |
| 2026-10-11 | Running downloads | An app-wide tray, bottom right, shown only while something runs or failed (reason + retry), foldable to a pill | A rail icon with a count and a popover | Downloading became an action you start anywhere; a running one must be visible without going to look |
| 2026-10-11 | Download done | The notification says whether it plays on a phone and carries Link másolása + Megnyitás (the folder, the item focused); never an automatic jump | A plain "ready" notice; auto-navigation to the item | The daily path is paste → done → copy the link; a jump would pull the user out of what they were doing |
| 2026-10-11 | Library row quick icons (owner A) | On a wide list only the ⋯ holds a place; Copy link, Share and Save come up over the date while the row is hovered or holds the keyboard; a card (phone) keeps them in its own line; Keeps narrower | Three icons always in sight (the name kept ~150px at 1200); dropping Added below 64rem | Owner: the name is what a row is read by; every action stays in the ⋯ and the right-click menu |
| 2026-10-12 | Files preview (owner ★) | A file that is not media opens from its name in the dialog shell, wide: an image, a PDF in the browser's own viewer, text as numbered plain text (HTML and SVG too) with a wrap toggle, else a no-preview card with Save; ← / → page the list's such files in its order; an image under 8 MB is its own thumbnail | A side panel beside the list; a new browser tab; pdf.js; syntax highlighting; a click that saves a file without a preview | Owner: one way to open anything, paged like the list; the browser's PDF viewer is free and isolated; a page is never rendered on our origin |
| 2026-10-12 | A running download's step (owner ★) | The tray row names the step beside its bar ("Downloading audio · 12%", "Merging" with a pulse); a paused-only tray shows a pause mark, never the done check | A bare bar and % | A download fetches video and audio as two streams: unnamed, the second fill reads as a restart; the tray is the only place a download is followed |
| 2026-10-14 | API tokens (owner A/A/A) | Settings → API tokens: a row per token in three lines (name + prefix, its folders and rights, last use and expiry), revoked from its ⋯; a new token asks the kind first (Hub exchange or Custom: rights as chips per folder), expiry 1 year by default; the token shows once in the same dialog, with Copy and a curl line | A table; one form with a preset link; the token in a banner above the list | A table truncates the rights at this width; the common token never shows a matrix to get wrong; closing the dialog is plainly when the token is gone |
| 2026-10-15 | Folder links (owner A/A/A) | An own folder's menu has Copy link and Share with link… as a file row does (the dialog is the link half of the file's); the link opens a list of the folder's files as it is now, Play opening the watch player above it with the playing row lit, a document only downloading; the rail marks a folder with a live link beside its count | Share with link… alone; cards; a download-only list; no mark | Mostly videos from a phone, read on a phone: a list gives a long name its line and the next one is a tap away; the folder link is the one place where moving a file in publishes it, so where it is must show |
| 2026-10-17 | Copy-only changes (owner ★) | Step 4 of the Conformance check alone (not 1–3 or 5): every state the changed text renders in, seen in both languages | The whole five-step check; no screenshot at all (DOM test + key parity) | Steps 1–3 and 5 judge controls, patterns and composition, which a text change does not touch; the one risk left is a longer Hungarian string wrapping or clipping, and only a screenshot shows that |
| 2026-10-17 | Text on glass (owner ★) | Light `--muted` 15% toward `--fg` in every scheme (≥ 6:1 on the bare page); the item count, the badges and the current Files row on opaque tints mixed into the surface | Only the two failing components; the item count's number in `--fg` | Pixel-measured over every scheme × mode × backdrop, the count fell to 3.8:1 (dark too: thumbnails scroll under the toolbar's glass) and Channels' tabs to 4.1:1; an `--fg` count outshouts the filters beside it |
| 2026-10-18 | Scheduler run history (owner D4, E1, E2 ★) | A row's last three runs as dots, newest first (interrupted: a hollow warn ring, set apart from skipped and failed), and a history button that opens the job's runs on the Activity page; a user's ad-hoc runs in their own block under the jobs, with no interval and no run button | An inline expandable history per row; a dark-grey interrupted dot; ad-hoc runs only on the Activity page | Three dots answer "did it run, did it work" at a glance; the whole history needs filters, which the Activity page has |
| 2026-10-18 | Activity page (owner D1, D2, G1–G3 ★) | One page, "Napló" / "Activity": four tabs (Runs, Admin actions, Server log, Quota) under one filter bar (text, a period of 1 h · 24 h · 7 d · 30 d · 90 d defaulting to 7 days, an account), the view in the URL; runs and admin actions as dense rows that open in place as a log line does, filtered and paged on the server; a run's or a request's log lines one pivot away, as a history entry | One merged timeline; a page per source; a default period per tab; card rows; the audit log's client-side table | Each source keeps its own table and retention, read in one place; one bar keeps what you narrowed across tabs; about 1.8k runs a day need a log's density and a server-side filter |
| 2026-10-24 | Related videos (owner U1–U3 ★, D1 A, D3) | On demand: a tab beside the description (wide), a drawer (stacked); the narrow thumbnailed row, its menu alone, a followed check; plays in place and the list stays the opened video's; an on-topic / Hungarian-trending switch kept on the account | Always shown; the end-of-video grid alone; a dialog; a big card or a text row; a list that follows the playing video | The modal is full on a small screen; a picture is what a choice among strangers rests on; a list that stays put is tried one by one, and spends no more of the day's cap |
| 2026-10-25 | Related: one played; off (owner D1 a, D2 a) | Played from the list, it is the player's item: its title, controls and progress, marked watched at its end, the queue left where it was; a daily limit of 0 takes the tab and the button away | A linked video's in-place play (no controls, nothing kept); a tab that only shows a refusal | What is watched should count as watched, wherever it was started from; a control that can only be refused is not offered |
| 2026-10-25 | Player panel glyphs (owner 1A, 2A, 3) | In the side panel the queue row is glyph-only on one line, and Mark watched is a glyph toggle among the actions (✓ / ✓✓); the docked bar keeps its labels; the related switch at the default size, "Trending" | Labelled buttons wrapping onto three lines; glyphs on the docked bar too; Mark watched on its own line | The panel is narrow and the list controls are the same few every time; each glyph keeps its full name and shortcut in its tooltip; the docked bar has the width for words |
| 2026-10-25 | Auto-advance glyphs (owner E A, H a) | Auto-advance wears list glyphs (`ListEnd` next and off, `ListStart` previous, `Shuffle` random), in the panel and the docked bar; the auto-advance and loop cycles are `lit`, not `pressed` | The skip arrows for the mode; an X'd list for off; `aria-pressed` on a cycle | Beside Previous / Next the skip arrows read as a second Next; a cycle of three or four modes is no two-state switch, and its name already says which |
| 2026-10-25 | Restart and Reset glyphs (owner L A) | A menu's Play from the beginning wears `RotateCcw`, as the card's own Restart does; Reset (clear progress and status) wears `Eraser`; Redownload wears `RefreshCw`, the retry arrow | The skip arrow ⏮ on the menu's Restart; `RotateCcw` on both Restart and Reset; `TimerReset` for Restart | ⏮ is Previous beside the player; one action wore two glyphs, and two actions in one menu would have worn one |
| 2026-10-28 | Notification order (owner A-D1 a, A-D2 a, ★ a) | What asks for the reader first, from either source ("Needs you · n"), then one stream newest first; read rows older than a week fold under "Earlier (n)", closed, its label in the section-heading style with a chevron | One stream with no band; this browser's activity above the server's (the two fixed sections); a server-side retention that deletes old rows | An admin's access requests sat under up to a hundred server rows; week-old read notices crowded what is new; folding keeps the rows (nothing is deleted) and an unread row never folds, whatever its age |
| 2026-11-01 | Docked bar glyphs (owner D8 a) | The maximised view's docked bar is glyph-only too (tooltips carry the names and keys): transport, auto-advance, loop, Mark watched (✓ / ✓✓); its controls scroll on a narrow window while Fullscreen and Exit stay pinned at its right end | Labels from `xl` up | On a tablet the labelled bar ran past the right edge and took Exit with it; the side panel had already lost its labels |
| 2026-11-01 | Toolbar group glyphs (owner D9 a, D10 a, D11 a) | The feed's, channel page's and Plex Browse's filter pills name their group by a glyph (`Eye` watch state, `Shapes` content types, `ArrowDownUp` sort), the word in the tooltip and the accessible name; Sort + View end the toolbar's row, the chip row shows only with a chip; a channel page has no count pill | The value alone; Sort + View on a row of their own | "Watch state:" and "Sort:" cost a tablet row its room; the value says enough once the glyph names the group; the channel header already counts the videos |
| 2026-11-01 | Channel header on a tablet (owner E2) | Below `xl` the action row drops its two words: "Prio" (the stepper's tooltip names it) and "Subscribed" (a ✓ glyph; its name in the aria-label, Unsubscribe in the tooltip); the tags keep the row's rest, folding into "+N" | The tag lane on a full line of its own below ~12rem; the tags under the channel name | On an iPad (820 px, 44 px targets) the lane got 29 px, a sliver; now 130 px, and every tag shows at 1180 |
| 2026-11-01 | Clear watch progress (owner E a) | A video menu's Reset reads "Clear watch progress" in English (HU "Megtekintés visszaállítása" stays); "Reset view" is only a page's view reset (sort, filters, grouping) | "Reset view" for both; the playlist page's view reset renamed instead | On a playlist page the two stood one right-click apart under one name, and one of them erases the video's progress with no undo |
| 2026-11-01 | Clear watch progress clears only that (owner A) | The row shows only on a video with a resume point and clears that alone: a hidden video stays hidden, a watched one watched. Un-hiding and un-watching have rows of their own | The old Reset, which also un-hid and un-watched | A hidden video stays hidden: what the row's name says is all it does |
| 2026-11-01 | Selecting a list's rows (owner D2 a) | An always visible box first in the row and one over the column for the rows shown; Shift+click a range, `x` the row under the keyboard, Esc clears; the selected row wears `accent/12` with its tick. Rows a filter hides stay selected, an action covers those shown; Watch later offers no selecting. | A "Select" button that shows the boxes on demand | Found at once and works on touch, with no mode to leave; costs about 2 rem a row |
| 2026-11-01 | Selection band (owner D1 a, D3 a, D4 a) | The toolbar's second row while rows are selected, no floating bar: move (the last target as one click) and remove, no copy; both undo back to the places, a video the target held before stays there; a single row's move undoes too. | A floating bar at the bottom; copying to a list too; no undo on a move | It covers no row, and the player and the notices live at the bottom; twenty videos sent to the wrong list need a way back to their places |
| 2026-11-01 | Grip on a phone (owner A) | Below a 24rem table a playlist row has no grip: the title keeps its width, and the row menu keeps the moves. | The grip and its drag kept, the title at 44 px | The title gets its width back, and the rail is shut on a phone, so a drag had nowhere to go |
| 2026-11-01 | Selection band on a tablet (owner A) | Below `xl` the band is glyphs on one row, the names in tooltip and aria-label, as on the channel header; the replaced row stays in the same grid cell, invisible, so the first tick moves no row under the second tap. | The buttons in words, wrapping to a second row | The first tick pushed the list from under the second tap |
| 2026-11-01 | Playlist heading on a narrow window (owner A) | The list's name had no width left: below `xl` Unsynced is its dot (the word read out, the hint in the tooltip); on a phone the cover is gone and Play all is its glyph, named in the tooltip. | Every word kept, the list's name at no width | A heading without its name says nothing |
| 2026-11-02 | Dragging onto a list (owner D5 a, D6 a) | The grip carries a row, or every selected row with a selected one, onto a list in the rail; the toast undoes. Many dropped inside the list stay; a coarse pointer's drawer never peeks, so its rail is a target only when pinned. | Only the grabbed row going along; the grip off under a view | One row moving out of three lit ones misleads, and a filter is how the rows to move are found |
| 2026-11-02 | Delete with every tick hidden (owner a) | It removes nothing and a warning says the filter hides them | Taking the row under the keyboard | While the band shows a selection, Delete means that selection: a row no one ticked must not go |
| 2026-11-02 | Undo of a video gone further (owner a) | A video the move put in the target comes back only if the target still holds it, and a warning says how many | Everything back, whatever it did since | A move skips a video that left the source; the same rule back keeps a video out of lists the reader never put it in |
| Date | Decision | Chosen | Over | Why |
| ---------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-09-24 | Floating button | Back to top: a round 48 glass FAB; "round" = floats over something (media or the page) | A Material 3 rounded square; a labelled pill; the old 64 circle | The common web convention for back to top; round tells it apart from every toolbar button; 64 was oversized |
| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function |
| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows |
| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" |
| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags |
| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers |
| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass |
| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context |
| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites |
| 2026-09-24 | B1 Density | Compact 32 / 28 / 24 by explicit height | Standard 38 / 32 / 26 | The owner's read of the lab; touch is covered separately (A3), and one toolbar row lines up at 28 |
| 2026-09-24 | B2 Scheme set | Midnight, Forest→cyan, Slate→fuchsia, Matrix→lime | Today's six | The accent collided with danger (YouTube), warn (Slate) and success (Forest, Matrix); Midnight and Starship were one scheme twice; 4 of 6 light accents failed 4.5:1 |
| 2026-09-24 | B3 State layers | Text colour at 10% / 16% over anything | Material's 8 / 12%; today's opacity + bg-card | Visible in every scheme and mode (≥ 1.2:1); today's row hover was 1.00:1 on a card |
| 2026-09-24 | B4 Page chrome | One sticky band + fade; filters fold when short | A floating glass bar; everything scrolls | Tabs and filters always in reach, in one place; a keyboard-up tablet keeps its content |
| 2026-09-24 | B5 Surfaces | One class + radius per role | Today's per-site mix | Nested corners and no per-site alpha drift |
| 2026-09-24 | B6 /watch | The app's surfaces, native video controls | Flat and minimal | The page strangers see should look like the product; native controls work on any device |
| 2026-09-24 | B7 Nav rail | Soft tint + edge bar, optional labels (a user setting), slim | Solid accent (recommended); a Material rail | The owner's combination: calmer than a solid fill, labels for who wants them, no width cost |
| 2026-09-24 | B8 Loading | Skeleton by shape, spinner in a control | A spinner everywhere | Nothing jumps when data lands; a long wait doesn't look stuck |
| 2026-09-24 | B9 Fade | Toolbars / headers, panel tabs, dialog edges, chip rows | Thumbnails; buttons | The north-star depth where content scrolls under chrome; not on content or small controls |
| 2026-09-24 | C1 Action classification | The table (lab C1): state + primary at rest, everyday toggles on hover, "…" on touch, destructive into the menu | Visible buttons per site, as today | One action list per object, nothing gesture-only, fewer buttons without losing a function |
| 2026-09-24 | C2 Side panels | A tap opens an overlay drawer; pin is a separate button ≥ 1024 | A tap pins and the content reflows | Below 768 the filters were unreachable; on touch a tap pinned and pushed the grid |
| 2026-09-24 | C3 Players on touch | Tap = show the controls; 44px seek hit area; 44 buttons; the YT description behind ⓘ | Today (tap also pauses; 7.5px seek bar) | Measured live: you couldn't reach the seek bar without pausing, and the hover-only description never opened on a tap |
| 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up |
| 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip |
| 2026-09-24 | Glass tuner | Retired; the schemes are the only colour choice | Keep it with ranges clamped to the rules | Free tuning is the drift the constitution exists to stop (owner) |
| 2026-09-24 | Matrix toned down | Moss #86a05a at Midnight's lightness, near-neutral greys; every white-on-accent part → `--accent-fg` | The B2 lime #a3e635 | The owner found the lime neon and tiring; it was the loudest accent (L 0.85, C 0.21), and fixed white parts sat on it at 1.5:1 |
| 2026-09-24 | Field primitives | `Input` / `Textarea` / `InnerInput` + `fieldClass`, `SelectTrigger`, the native controls; the lint rule "no raw `<input>`" | `inputCls` and ~40 hand-styled boxes | Six edges, four radii and two focus looks for one control; the ≥ 3:1 edge held nowhere |
| 2026-09-25 | Surface roles | `SURFACE` roles in `ui/surfaces.ts`; the lint rule "no bare glass class"; menus p-1 with r8 rows | Three tiers paired ad hoc with five radii and three menu paddings | ~140 sites drifted; nested corners only hold if the radius comes with the role |
| 2026-09-25 | Static labels | Flat tonal `Badge`, no edge; a filtering tag is an edged chip | A glass pill with an edge, like the chips | Edged = pressable at a glance; a static tag that looks like a chip gets clicked and does nothing |
| 2026-09-25 | Header search | A 40 glass pill with the field edge (`lg`); what sits inside it is round | 44, r16, a glass box with a faint edge | The one rounded box among pills, and its edge under 3:1; the hero search is the YouTube/Gmail pill |
| 2026-09-25 | Switch row (F12) | The whole settings row toggles (`SwitchRow`; a hand-laid row points a label at the switch) | Only the 36×24 switch toggles | The iOS / Material settings row; the text was a dead click beside the control it names |
| 2026-09-25 | Edges and hovers (F14) | The ≥ 3:1 edge on the field family (fields, the form trigger); labelled pills keep the faint edge and the accent-edge hover | The ≥ 3:1 edge and a state-layer hover on every control | 1.4.11 asks an edge only where nothing else identifies the control; a heavy edge on every chip and tab weighs the whole UI down |
| 2026-09-25 | Rating and live (F16) | Own tokens: `--rating` (theme-scoped gold), `--live`/`--live-fg`, `--rating-media`, `--media-chrome`; palette classes lint-refused | Map them to `--warn` / `--danger` | A gold star and a red LIVE are what the eye expects; as warn/danger they would read as a status. The old amber score was 2:1 on light |
| 2026-09-25 | Fjord trial (B2 exception) | A fifth scheme, temporary: Midnight's neutrals, Playnite's steel accent (#337599 lifted to #5aa1cb) and steel rules; exit = fold into Midnight or remove | Retune Midnight straight away; borrow only the qualities | The owner wants to live with the calmer palette app-wide before it replaces the default; it breaks "own hue family" on purpose, and only until that call |
| 2026-09-25 | Whole-card row (owner) | A card row's B3 layer is the card's `::before` under its content; the open button's hit layer spans the card, the actions sit above it | The name button's own hover, ending at the first badge or button | A row that half-answers the pointer reads as broken; a badge click did nothing |
| 2026-09-25 | Badge tones (F29) | `Badge tone` neutral / accent / warn at 6%; light `--warn` #b45309 → #a14a07; edged label spans lint-refused | Keep edged status pills (a private Badge in Users, Settings "Granted", the suggestions count) | Edged = pressable at a glance; amber-700 was 4.58:1 as text and fell under 4.5 on its own tint |
| 2026-09-26 | Page band built (B4) | Controls only in the band (intros scroll); the fold shows the same row in place; one `h1` per page (`PageHeading` steps the hidden one aside); `SectionLabel` headings; Skip to content | A popover of the folded filters; the page title kept visible | A folded row is still the same controls, nothing duplicated; the rail and the tab title already name the page |
| 2026-09-26 | Fade built (B9) | `fade-below` on sticky toolbars and table headers; the scroller mask where the band sits on the backdrop | A painted band over the backdrop | The backdrop is the colour under the band, so fading the content into it is the same edge |
| 2026-09-26 | Viewport units | `dvh` everywhere (the Plex player stage with S3f) | `vh` / `h-screen` | `100vh` hides the last rows under a tablet browser's toolbar |
| 2026-09-26 | Video actions built (C1) | One list per video (menu, right-click, long-press, "…"); the card keeps watched / save / add to playlist on its picture on hover; download, details, copy, hide, reset in the menu | The six-button row under the card and the hover ⓘ | The menu is the one door every device has; the card loses a line of height and the touch card its row of 28px buttons |
| 2026-09-26 | Plex actions built (C1) | One list per Plex title (play/open, watched, add to playlist, media info, copy title, pin); the poster keeps watched / add / "…" on its picture on hover; seasons get open / watched / add / copy | The hover ⓘ, the pin-only menu, an episode's add button at rest, a collection's "Remove" X on the card | The same door as a video's; the collection grid goes to two columns on a phone so the touch "…" leaves the title its line |
| 2026-09-26 | Playlist actions built (C1) | A playlist row's "…" (play, pin, copy title / link, move, order, remove), Delete + an undo toast; one list per playlist (rename, pin, YouTube open / push / revert, delete) from its rail row and its header's "…" | The row's X column and its confirm; the header's pencil, YouTube link, push, revert and trash buttons | Remove is reversible, so it needs no confirm; the header keeps Play all; a narrow table drops "#" and Channel so the title keeps a width |
| 2026-09-26 | Channel + Plex list actions (C1) | A channel row's "…" (open, pin, YouTube, copy, full history, hide + undo, unsubscribe); a Plex playlist's title / show / season "…", Delete + undo; its header's "…" (rename, delete); an action toast lasts ≥ 8 s | The channel row's full-history, hide and unsubscribe buttons; the Plex list's red X on every row, show and season; the header's pencil and trash | Hiding and removing are reversible, so they need no confirm and the undo needs time; unsubscribe and deleting a playlist keep theirs |
| 2026-09-26 | Download / notification / Scheduler actions (C1) | A download row's "…" (play, share, save, edit, details, re-download, manage sharing, copies, cancel / delete / remove); a library row's Share stays in sight, ahead of save; a notification row's "…" and hover dismiss; the inbox band's and the Scheduler strip's "…" | The download row's five rare buttons and its cancel / delete / remove; the source link's copy button; an activity notice's Find / Undo links; the band's Clear everything; the strip's Purge discovery | The destructive rows keep their confirm, and Clear everything gains one; a row link keeps the browser's own menu; Find in feed drops the tag / search / age filters that could hide its video |
| 2026-09-26 | Channel menu: Tags + Priority (C1) | Tags A–Z as in-place checks; Priority as fixed steps 10 · 5 · 3 · 2 · 1 · 0, the current one ticked; a pick re-sorts at once and the page + focus follow the row; one drawn `MenuCheck` for every checkable row that cannot be an `<input>` | Relative steps (+1 / +5); straight to the top; the stepper only; seven hand-drawn check marks (three box sizes, a tinted box, a × for "on", a trailing tick) | Bubbling a channel up is one pick; fixed levels keep the scale the reader already uses; the list moving where the reader can see it is the feedback |
| 2026-09-26 | Reorder without a drag (WCAG 2.5.7) | Row menus: Top / Up / Down / Bottom (YouTube + Plex playlists); saved views: four buttons with the place beside the selected view (owner: A); side panels in edit mode: an Up / Down pair per group (owner: A), the name wrapping there | A "…" on every saved-view row; a "…" on every panel group; drag only | A visible, one-click path where the list is edited; the edge moves shown off so the buttons never shift |
| 2026-09-26 | Titles in click surfaces | The surface stays a button, flagged `selectable`: its text selects, and the click that ends a selection does nothing; every plain `Pressable` pinned with its reason | Rebuilding each card and row so the title sits outside the button; exceptions for master-detail lists | Chrome selects inside a button once it is `user-select: text` (measured), so one flag on the primitive keeps the keyboard, focus and list walking every surface already has |
| 2026-09-26 | Right-click on a selection | The browser's own menu opens on selected words; beside them the object's menu | A "Copy selection" row in the app menu | The reader selected the words to do something with them; the native menu copies, searches and translates, and changes nothing else |
| 2026-09-26 | Keyboard ring on a flush row | Inside the row (a table row by its tag, a log line, a show header in its clipping card, with the card's corners) | The ring outside, as on a card | Outside it ran under the table head and past the table, and a clipping card cut it away entirely (invisible focus) |
| 2026-09-26 | Fade at rest | The sticky header's fade appears with the first 1rem of scroll (a scroll-driven animation; no fade where unsupported) | A fade that is always drawn | At rest it dimmed the first row and hid its ring's top edge; nothing is under the header yet |
| 2026-09-26 | Narrow playlist table | "#" and Channel hide by the table's own width (under 42rem), not the window's | Hiding below the `sm` window width | Beside the playlist rail a desktop window left the table 432 px and the title 0 px |
| 2026-09-26 | Background menu built | Five list pages (feed, Plex grid, channels, playlist, notifications); the toolbar's sort / view / filters and the band's actions; no destructive row | Every page with a toolbar; the "…" menus copied whole, delete included | The tab-only pages have nothing a menu would add; a blank-area right-click must not be one click from delete |
| 2026-09-26 | Media card anatomy (C) | One frame; the meta line pinned to its bottom; the length a badge on the picture; the Plex card on the same primitive as the video card | A: the year as an overline over the title; B: the year inline after it; a fixed two-line title slot | A one-line title left a hole the eye reads as a missing line; C puts nothing under a variable-height title |
| 2026-09-26 | One Play | `MediaPlayGlyph` (the media button variant) and only where the click plays | The Plex card's hand-painted accent circle; a playable dot on every card | One Play for every card; a dot that only a colour and a tooltip explain says nothing |
| 2026-09-26 | Filtering values on the info page | A value that filters (year, certificate, studio, director) is an edged chip; a fact is flat text | Everything flat, the clickable ones only hover-tinted | The S3d rule: edged = pressable, so the reader sees which values do something |
| 2026-09-26 | Shelf density | On any strip: one title line over one meta line | The grid's two lines and full meta on a strip | The tallest card set the strip's height and left holes in the cards with less to say |
| 2026-09-26 | Compact list row (B) | The title leads; a faint "…" at the end; the toggles float over the date on hover | A leading 128px action column | That column sat empty at rest, a hole at the start of every row |
| 2026-09-26 | Media-info layout (B) | A main column (collections first, crew, cast) beside a facts sidebar; an empty column is not drawn | Every section full width, stacked | The collection is the most used; the keywords' column left a void beside a short details box |
| 2026-09-26 | Mixed Home shelf (A) | Plex titles as wide art beside videos | Posters, with the videos not stretched | Stretching a video card to a poster's height left an ~80px gap |
| 2026-09-27 | Home hero | A card (hover row + Play on the picture, the whole menu, a touch "…"; owner: A); three quarters wide and centred on a wide window; one fact line; the summary at the measure | The hero's pin row alone; full width; an 18rem picture column | The one answer stands in focus, and it offers what the same card offers anywhere |
| 2026-09-27 | Home shelf tiles | A channel / playlist tile: Play, ↗ open, "…" with Open; a show acts on its next episode, ⓘ opens it; the shelf slot 232px | The tile's pin row alone; 200px | A tile without its object's actions read as broken; at 200 the Play touched the hover row |
| 2026-09-27 | Synopsis preview (C) | A ~0.9 s mouse rest opens a passive panel beside the card | The synopsis as the picture's `title` tooltip; an ⓘ to hover; none | One long tooltip line fired anywhere on the art and clashed with the cut title's own tooltip |
| 2026-09-27 | Reading measure | Prose capped at ~65 characters a line (`measure`) | The container's full width | At 110–130 characters a line the eye loses the next line's start |
| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide |
| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
| 2026-09-27 | C4 as built | `initialFocus` required on every `Modal`; the commit row in `ModalFooter`, portalled under the body from any depth; the fades driven by the body's scroll timeline; `interactive-widget=resizes-content` | An optional prop defaulting to the ✕; a `footer` prop (the issue composer's buttons live three components down); always-on fades | A default is how 21 dialogs came to open on the ✕; lifting the composer's state to reach a prop is the larger change; a resting fade dimmed the first line |
| 2026-09-27 | C3 + C5 as built | A tap toggles the bar through `pointerType` from `pointerdown`; a double-tap takes back its first tap's toggle; the card's pair is the video card's `MediaResumeButtons` (Continue / Restart), a sibling over the picture; the YT description is an ⓘ click popover | A delayed single tap (wait out the double-tap window); new "Resume / From the start" words for Plex; the pair inside the picture's button | A delayed tap feels dead; one media card says one pair of words; a button inside a button is invalid and the hover row already sits beside the picture |
| 2026-09-27 | C2 as built | The side panel's drawer is `ui/Modal` `drawer-start`; drawer = a coarse pointer or below 768; the pin offered in its header ≥ 1024 | A drawer of its own inside `SidePanel` | Esc, Back, the scrim, the trap and the scroll lock are the dialog's; a second implementation is how two shells drift |
| 2026-09-30 | Composition and the reader (E30 S6) | A chapter and a Conformance step: the space is used, one focal point, worst real data, where am I, a click answers at once, zoom and text size first-class; the person page as two columns (facts and bio left, "Known for" right) | Rules for pieces only; the bio in a full-width panel; a narrower panel | The person page passed every piece rule with half its panel empty; the owner reads zoomed in, so zoom is a requirement, not a nicety |
| 2026-09-30 | Hungarian words (E30 S5) | The D3 list: Feed, Shorts, Admin, Token, Port stay; stream, Prio, Job, Audit, Demo, Auto, Info, Frontend/Backend, Backfill, transcode get the Hungarian word; one name per thing (kérelem, behúzás, one "Test connection") | Keep every loanword; translate Feed/Shorts too | The owner chose the list (D3); the final sweep found the same feature under two names in three places |
| 2026-09-29 | Numbers and order in the UI language (E30 S3) | Every number through `lib/format.ts` in the UI language (decimal mark, grouping, compact, units); A→Z by the shown name in the UI language's alphabet, client and server alike; the language comes off `useTranslation`, never the singleton | `toFixed`, hand-built "1.2K", `localeCompare` in the browser's locale, byte-order server sorts | A Hungarian reader saw "8.3", "1.2K", "Életrajzi" after "Z"; the singleton read also left values in the old language after a switch (React Compiler) |
| 2026-09-27 | Narrow rows by container | The download row, the playlist table and the Channels tag row stack by a container query; a fact line never wraps | Breakpoints on the window | The iPad zoomed in, split screen and a column beside a rail are narrow while the window is not; the zoomed download row broke "18.9 / MB" apart |
| 2026-09-27 | Phones deferred | A phone-only layout (the hamburger nav, a folded feed band) waits for its own epic; E28 stays desktop + tablet | Fix the phone views inside E28 | Principle 2; the first phone attempts made the feed worse, and the epic's review grows with every sprint |
| 2026-09-27 | Conformance check (S3g) | A four-step check before a UI change is done: primitive at a named rung, the chapter re-read, the rendered page measured by kind and look, every state seen | A reviewer's eye per sprint | The S1 probe found 69 button looks; after S3 every control kind is drawn by its primitive, and the check keeps a new look from arriving unnoticed |
| 2026-09-27 | Segmented rung | `SegmentedToggle` defaults to the toolbar rung (28, `text-xs`); 24 only for a panel's captioned match toggle | The small default | Two toolbars had inherited 10px type from the default beside 28px buttons |
| 2026-09-27 | Copy rule (S3g) | One name per feature (the rail may shorten it); four verbs for four meanings; sentence case with the app's places capitalised; "…" when a command asks first; one-sentence messages without a stop; curly quotes; dates relative in lists with the exact minute on hover, three absolute forms; units from Intl, short | Word-for-word names everywhere; the verbs as they were; all lower case; no "…"; always a stop; always absolute dates; long or compact units | The copy survey found 7 EN and 6 HU double names, 22 Delete against 27 Remove, five date shapes and three spellings of a minute; the owner picked every recommended option |
| 2026-09-27 | One glyph, one meaning (S4) | Play only for playback or resuming a paused transfer; Retry = `RefreshCw`; reset / revert = `RotateCcw` | Retry drawn with Play | The first UI-review run (Downloads) found a failed row whose Retry showed ▷ in the row and its menu |
| 2026-09-27 | Fjord kept | Fjord stays a fifth scheme beside Midnight; the trial wording goes | Fold it into Midnight; remove it | The owner lived with it and likes it; the vivid Midnight stays for those who want it. B2's own-hue rule still binds any new scheme |
| 2026-09-27 | Tablet columns | A table hides `tabletHidden` columns below 64rem of its own width; Channels drops Prio, About, Length | Hide About only; give the name a bigger share | At 1024 the channel name had 60 px; the owner chose to drop all three there |
| 2026-09-27 | Glyph pager | First / previous / next / last as glyph buttons round the page box, names as tooltips | Words beside the arrows (hidden only on a phone) | The owner's call: the words widened the row; four arrows are the shared convention |
| 2026-09-27 | Match switch on the panel | `ui/MatchToggle` above the picker from two picks, everywhere; one wording (Any / All) | The picker's popover footer (Feed, Plex collection) | A closed picker hid an active "all"; six pickers had drawn it two ways |
| 2026-09-27 | Missing picture, bare link | A glyph of the kind for no picture; a download with no name is titled by its short link, once | An empty box; the URL as title, channel and link | The owner's picks 4A and 3A: an empty box read as loading, the URL showed three times |
| 2026-09-27 | Copy on a selection | Right-click on selected words keeps the browser's menu; no app Copy row | An app menu with Copy first | A real browser's menu has copy, search and translate; only the desktop pane's is thin |
| 2026-10-01 | Zoom target 125–133% | Screens are held at 125% and 133% browser zoom (1536 × 729, 1444 × 685 on a 1080p window) and the largest text size | 200% browser zoom | The owner: 200% was a guess; 125–133% is what they read Siftlode and most sites at, so 200% would be an upper bound nobody uses |
| 2026-10-01 | E31 Nav rail at short heights | One "Admin" rail item opening a panel of the admin pages | A denser rail when short; a visible "+4" hint | The rail fits at 133–150% without a hidden scroll; the admin pages are rare and admin-only |
| 2026-10-01 | E31 Configuration sections | A vertical, grouped section list, as Settings has | Group tabs + sub-tabs; one long page with a contents list | Sixteen tabs wrapped to three rows at 133%; one pattern for both settings pages |
| 2026-10-01 | E31 Statistics overview | Number tiles, and the per-operation counts as horizontal bars | Three narrow cards; a 30-day chart | The label sits by its value; what spends the quota is seen at a glance |
| 2026-10-01 | E31 Channel page header | The full banner, folding to one row once the grid scrolls | Always compact; no banner | The header is fixed and took 40% of a 685px window; the channel keeps its face at first sight |
| 2026-10-01 | E31 Channel page watch state | A fresh channel page shows the unwatched videos; a picked state is remembered | Show all, forgotten on reload | The owner: open a channel to see what is new; a choice made there should stick |
| 2026-10-01 | E31 Audit log runs | A job's successful runs fold into one row that opens, whatever ran between; only that job's failure (never folded, danger colour) breaks it | A filter defaulting to admin actions; both | Nothing hidden, the noise collapsed, the failure seen |
| 2026-10-01 | E31 Users row | Role and state as badges, the actions in the row's one menu | An inline role toggle; icon-only buttons | Action placement: the row shows state, rare and destructive actions live in the menu |
| 2026-10-01 | E31 YouTube player | Video beside an info panel at 1280px and wider (the video as large as the height allows); stacked below | Today's stack with a one-line bar; full-window like Plex | The owner's eyesight: about 1460px of video on 1080p instead of 1008, the actions still visible |
| 2026-10-01 | E31 Plex info and show pages | A second column for the facts, keywords and crew, as the person page; a collection's hero sized to its content | Full-width text; a larger poster and fanart | The right ~40% was empty; one composition for every media page |
| 2026-10-01 | E31 Nav rail rows are links | Each page row is a real link: a middle-click or Ctrl-click opens the page in a new tab | A button | The owner: open a page in a tab of its own, as on any site |
| 2026-10-02 | E31 Plex detail second column (refined) | The facts over the key crew beside the text; the keywords, chips that filter like the genres, under the text; the show page gets all three too | All three in the column, as the mockup drew it | At 20 keywords and 12 crew the column ran to twice the text's height (849 vs ~400 px at 133%): a hole under the text instead of beside it |
| 2026-10-02 | E31 Plex detail poster and crew | The movie and show poster keeps its 2:3 and grows a rung in a wide hero (w-56 at a 64rem panel); the crew shows its first 6 names and "Show N more" (a list of 8 or fewer shows whole) | Poster stretched to the row; a "More" on the keywords | The row is as tall as the crew column, so the poster drew ~175×590 and cropped; the crew, not the keywords, set the hero's height |
| 2026-10-04 | E32 `field` focus skips a mode switch | A form opens on its first field; a control above it that is not a field (the composer's Edit / Preview) is passed over by marking the field `data-initial-focus` | The first text field everywhere | That moved the focus in the download dialog off its profile choice and in the upload dialog off its drop zone; the mark changes only the form that needs it |
| 2026-10-04 | E35 Lint for z-levels and edged labels | Both rules stay; the two eslint selectors (`Z_RAW`, `EDGED_LABEL`) that refused them go | Keep the selectors | 0 hits since the E28 sweep that fixed the sites: a check that never fires costs a probe test and a snapshot each, and the UI review holds both rules |
| 2026-10-06 | `player` focus | A dialog that is a player opens focused on its panel (the YouTube player's rule, now `Modal`'s too); on the media element itself, Space and the arrows stay the element's own | `close`, or the media element | On the ✕ Space closed the player; on the element the browser's own controls acted on the same key as the player's (one Space played and paused, measured in Chrome) |
| 2026-10-08 | Captions on touch | A `Tooltip` opens for a mouse and a key's focus only, never for a finger | A caption on tap; a long-press caption | On an iPad the tap that showed the caption was taken as a hover and lost its click (every rail button took two taps), and no finger sends the mouseleave that hides it; the rail's labels setting names the buttons on touch |
| 2026-10-08 | Header search on touch | No key cap on a touch screen; the pill grows to 52 px so a ring shows round the 44 px Go; Go drops to its icon by the column's width | Keep the key cap; a 44 px Go overhanging a 40 px pill | On a tablet the 44 px key cap and Go sat on the pill's edge and cut the placeholder; an overhanging Go breaks the nested radii |
| 2026-10-08 | Plex subtitles and the control bar | The player draws the cues itself; while the bar shows they rise above it (200 ms), then settle back | The browser's own cues, moved by `line` | On an iPad the bar covered the text; a native cue cannot be animated or measured, and our layer behaves the same on every engine |
| 2026-10-08 | Hover on a button over a picture | The `media` variant's hover is a 2px accent ring over a darker body (Plex + YT cards, rail arrows; option B) | An accent fill (reads as "selected"); a white wash (weak on a bright poster) | The 60 → 75% black step was invisible, and over a card a rail arrow could not be told from the card's own Play under it |
| 2026-10-08 | Plex "Add to collection" | A movie or show card's menu row opens a panel beside the card: its library's manual collections tick in place, a name + Enter creates one with the title in it (admin; option A) | An Add submenu with a "New…" dialog; the per-title editor modal from the menu | A collection took a trip to the Collections page, a name, a title search, then a second search per title; the panel is the video card's "Add to playlist" |
| 2026-10-08 | YouTube modal chrome | No prev/next strips beside the card; Close an icon (the panel's top right; stacked, the title row's end); the description clamped to three lines + More; the panel shuts to a 2.75rem strip of close, expand, prev/next (option D1) | Keep the strips; a "Close" text button; the full description in a scroller; a panel that hides outright | The strips cost the video 7rem of width for a second copy of the panel's buttons; a long description pushed the channel and the actions out of sight; a hidden panel left no way to step the queue without the keyboard |
| 2026-10-08 | The wheel over the YouTube modal | Over the video: volume. Over a panel with something to scroll: scroll only, never volume, not even at its end. Over a panel with nothing to scroll: volume (owner) | Volume everywhere; a scroll that hands over to the volume at its end | Reading a long description turned the volume down once it hit the end |
| 2026-10-09 | A Plex show card plays | Its picture plays the show's next episode everywhere (an episode with a resume point → Continue + Restart, else the first unwatched, else S1·E1), resolved on the click; the preview names it once up; title and ⓘ open the show (option A + hover ask) | The picture opens the show page (as before); every card carrying `next` | A show card on Discover had no Play while Home's had one; carrying `next` costs a grid page up to 180 queries for the few cards anyone plays |
| 2026-10-10 | Clip mode in the players (E38) | Scissors in the control row + `C`, `[` `]`; the panel in the player with the watched video as preview; overview + zoomed strip on `ui/RangeTrack` (ends are sliders); a toast + the Ready notification after Cut | A ⋯ menu entry; permanent In / Out buttons; a big dialog with its own preview (a second Plex transcode / YouTube iframe) | Owner's picks A, P1, a; a film is hours and a clip seconds, so only a zoomed strip gives a hand the precision. The Downloads editor's cuts moved onto the same track (keyboard-steppable now) |
| 2026-10-10 | Crop in clip mode (E38) | A Crop toggle in the clip panel: a frame over the playing picture (`ui/CropFrame`, the editor's crop too), Free / 16:9 / 9:16 / 1:1, Whole picture; maximised YouTube keeps the panel in the bar's place; the wide side panel shuts while clipping | A frame always on the picture in clip mode (K2); a free shape only | Owner's K1 + a: a crop is rare, so it never covers the picture unasked; a phone or square clip needs a held shape. The iframe hides its picture's size: drawn on the 16:9 box, the cut fits it to the real picture |
| 2026-10-10 | Marks on the player chrome | `--media-mark` (amber) and `--media-danger` (light red), the same in both themes; `media-marks` points the focus ring (`--focus`) and the chosen mark (`--chosen`: a track's tick, a tab's underline; the label stays white) at the amber on the player and every chrome surface; `media-scope` (a form) also the errors at the red | The theme's accent ring, chosen text, warn and danger | On the chrome (#171717) a light theme's accent ring (Slate 2.84:1), warn (2.99:1) and danger (2.77:1) all fall under 3:1; the accent as chosen text under 4.5:1 |
| 2026-10-11 | Files page (one library) | List rows by default, the media grid a toggle remembered per folder; a sidebar of automatic source folders, own folders, Shared with me and the storage meter; the paste-a-link box in the toolbar | A media grid by default | The library holds videos AND plain files: rows read both, show more per screen and scan by size and date |
| 2026-10-11 | Library list columns | Every kind of value in its own column (name, source, phone-safe, retention, size, added, actions), one grid with a subgrid per row so they line up; an action that does not apply keeps its slot empty; a right-click menu on every row (the ⋯ opens the same menu) | Badges flowing after the title; icons that shift when one is missing | Owner: values of one kind must fall in one vertical line; a row's every action reachable by right click |
| 2026-10-11 | Running downloads | An app-wide tray, bottom right, shown only while something runs or failed (reason + retry), foldable to a pill | A rail icon with a count and a popover | Downloading became an action you start anywhere; a running one must be visible without going to look |
| 2026-10-11 | Download done | The notification says whether it plays on a phone and carries Link másolása + Megnyitás (the folder, the item focused); never an automatic jump | A plain "ready" notice; auto-navigation to the item | The daily path is paste → done → copy the link; a jump would pull the user out of what they were doing |
| 2026-10-11 | Library row quick icons (owner A) | On a wide list only the ⋯ holds a place; Copy link, Share and Save come up over the date while the row is hovered or holds the keyboard; a card (phone) keeps them in its own line; Keeps narrower | Three icons always in sight (the name kept ~150px at 1200); dropping Added below 64rem | Owner: the name is what a row is read by; every action stays in the ⋯ and the right-click menu |
| 2026-10-12 | Files preview (owner ★) | A file that is not media opens from its name in the dialog shell, wide: an image, a PDF in the browser's own viewer, text as numbered plain text (HTML and SVG too) with a wrap toggle, else a no-preview card with Save; ← / → page the list's such files in its order; an image under 8 MB is its own thumbnail | A side panel beside the list; a new browser tab; pdf.js; syntax highlighting; a click that saves a file without a preview | Owner: one way to open anything, paged like the list; the browser's PDF viewer is free and isolated; a page is never rendered on our origin |
| 2026-10-12 | A running download's step (owner ★) | The tray row names the step beside its bar ("Downloading audio · 12%", "Merging" with a pulse); a paused-only tray shows a pause mark, never the done check | A bare bar and % | A download fetches video and audio as two streams: unnamed, the second fill reads as a restart; the tray is the only place a download is followed |
| 2026-10-14 | API tokens (owner A/A/A) | Settings → API tokens: a row per token in three lines (name + prefix, its folders and rights, last use and expiry), revoked from its ⋯; a new token asks the kind first (Hub exchange or Custom: rights as chips per folder), expiry 1 year by default; the token shows once in the same dialog, with Copy and a curl line | A table; one form with a preset link; the token in a banner above the list | A table truncates the rights at this width; the common token never shows a matrix to get wrong; closing the dialog is plainly when the token is gone |
| 2026-10-15 | Folder links (owner A/A/A) | An own folder's menu has Copy link and Share with link… as a file row does (the dialog is the link half of the file's); the link opens a list of the folder's files as it is now, Play opening the watch player above it with the playing row lit, a document only downloading; the rail marks a folder with a live link beside its count | Share with link… alone; cards; a download-only list; no mark | Mostly videos from a phone, read on a phone: a list gives a long name its line and the next one is a tap away; the folder link is the one place where moving a file in publishes it, so where it is must show |
| 2026-10-17 | Copy-only changes (owner ★) | Step 4 of the Conformance check alone (not 1–3 or 5): every state the changed text renders in, seen in both languages | The whole five-step check; no screenshot at all (DOM test + key parity) | Steps 1–3 and 5 judge controls, patterns and composition, which a text change does not touch; the one risk left is a longer Hungarian string wrapping or clipping, and only a screenshot shows that |
| 2026-10-17 | Text on glass (owner ★) | Light `--muted` 15% toward `--fg` in every scheme (≥ 6:1 on the bare page); the item count, the badges and the current Files row on opaque tints mixed into the surface | Only the two failing components; the item count's number in `--fg` | Pixel-measured over every scheme × mode × backdrop, the count fell to 3.8:1 (dark too: thumbnails scroll under the toolbar's glass) and Channels' tabs to 4.1:1; an `--fg` count outshouts the filters beside it |
| 2026-10-18 | Scheduler run history (owner D4, E1, E2 ★) | A row's last three runs as dots, newest first (interrupted: a hollow warn ring, set apart from skipped and failed), and a history button that opens the job's runs on the Activity page; a user's ad-hoc runs in their own block under the jobs, with no interval and no run button | An inline expandable history per row; a dark-grey interrupted dot; ad-hoc runs only on the Activity page | Three dots answer "did it run, did it work" at a glance; the whole history needs filters, which the Activity page has |
| 2026-10-18 | Activity page (owner D1, D2, G1–G3 ★) | One page, "Napló" / "Activity": four tabs (Runs, Admin actions, Server log, Quota) under one filter bar (text, a period of 1 h · 24 h · 7 d · 30 d · 90 d defaulting to 7 days, an account), the view in the URL; runs and admin actions as dense rows that open in place as a log line does, filtered and paged on the server; a run's or a request's log lines one pivot away, as a history entry | One merged timeline; a page per source; a default period per tab; card rows; the audit log's client-side table | Each source keeps its own table and retention, read in one place; one bar keeps what you narrowed across tabs; about 1.8k runs a day need a log's density and a server-side filter |
| 2026-10-24 | Related videos (owner U1–U3 ★, D1 A, D3) | On demand: a tab beside the description (wide), a drawer (stacked); the narrow thumbnailed row, its menu alone, a followed check; plays in place and the list stays the opened video's; an on-topic / Hungarian-trending switch kept on the account | Always shown; the end-of-video grid alone; a dialog; a big card or a text row; a list that follows the playing video | The modal is full on a small screen; a picture is what a choice among strangers rests on; a list that stays put is tried one by one, and spends no more of the day's cap |
| 2026-10-25 | Related: one played; off (owner D1 a, D2 a) | Played from the list, it is the player's item: its title, controls and progress, marked watched at its end, the queue left where it was; a daily limit of 0 takes the tab and the button away | A linked video's in-place play (no controls, nothing kept); a tab that only shows a refusal | What is watched should count as watched, wherever it was started from; a control that can only be refused is not offered |
| 2026-10-25 | Player panel glyphs (owner 1A, 2A, 3) | In the side panel the queue row is glyph-only on one line, and Mark watched is a glyph toggle among the actions (✓ / ✓✓); the docked bar keeps its labels; the related switch at the default size, "Trending" | Labelled buttons wrapping onto three lines; glyphs on the docked bar too; Mark watched on its own line | The panel is narrow and the list controls are the same few every time; each glyph keeps its full name and shortcut in its tooltip; the docked bar has the width for words |
| 2026-10-25 | Auto-advance glyphs (owner E A, H a) | Auto-advance wears list glyphs (`ListEnd` next and off, `ListStart` previous, `Shuffle` random), in the panel and the docked bar; the auto-advance and loop cycles are `lit`, not `pressed` | The skip arrows for the mode; an X'd list for off; `aria-pressed` on a cycle | Beside Previous / Next the skip arrows read as a second Next; a cycle of three or four modes is no two-state switch, and its name already says which |
| 2026-10-25 | Restart and Reset glyphs (owner L A) | A menu's Play from the beginning wears `RotateCcw`, as the card's own Restart does; Reset (clear progress and status) wears `Eraser`; Redownload wears `RefreshCw`, the retry arrow | The skip arrow ⏮ on the menu's Restart; `RotateCcw` on both Restart and Reset; `TimerReset` for Restart | ⏮ is Previous beside the player; one action wore two glyphs, and two actions in one menu would have worn one |
| 2026-10-28 | Notification order (owner A-D1 a, A-D2 a, ★ a) | What asks for the reader first, from either source ("Needs you · n"), then one stream newest first; read rows older than a week fold under "Earlier (n)", closed, its label in the section-heading style with a chevron | One stream with no band; this browser's activity above the server's (the two fixed sections); a server-side retention that deletes old rows | An admin's access requests sat under up to a hundred server rows; week-old read notices crowded what is new; folding keeps the rows (nothing is deleted) and an unread row never folds, whatever its age |
| 2026-11-01 | Docked bar glyphs (owner D8 a) | The maximised view's docked bar is glyph-only too (tooltips carry the names and keys): transport, auto-advance, loop, Mark watched (✓ / ✓✓); its controls scroll on a narrow window while Fullscreen and Exit stay pinned at its right end | Labels from `xl` up | On a tablet the labelled bar ran past the right edge and took Exit with it; the side panel had already lost its labels |
| 2026-11-01 | Toolbar group glyphs (owner D9 a, D10 a, D11 a) | The feed's, channel page's and Plex Browse's filter pills name their group by a glyph (`Eye` watch state, `Shapes` content types, `ArrowDownUp` sort), the word in the tooltip and the accessible name; Sort + View end the toolbar's row, the chip row shows only with a chip; a channel page has no count pill | The value alone; Sort + View on a row of their own | "Watch state:" and "Sort:" cost a tablet row its room; the value says enough once the glyph names the group; the channel header already counts the videos |
| 2026-11-01 | Channel header on a tablet (owner E2) | Below `xl` the action row drops its two words: "Prio" (the stepper's tooltip names it) and "Subscribed" (a ✓ glyph; its name in the aria-label, Unsubscribe in the tooltip); the tags keep the row's rest, folding into "+N" | The tag lane on a full line of its own below ~12rem; the tags under the channel name | On an iPad (820 px, 44 px targets) the lane got 29 px, a sliver; now 130 px, and every tag shows at 1180 |
| 2026-11-01 | Clear watch progress (owner E a) | A video menu's Reset reads "Clear watch progress" in English (HU "Megtekintés visszaállítása" stays); "Reset view" is only a page's view reset (sort, filters, grouping) | "Reset view" for both; the playlist page's view reset renamed instead | On a playlist page the two stood one right-click apart under one name, and one of them erases the video's progress with no undo |
| 2026-11-01 | Clear watch progress clears only that (owner A) | The row shows only on a video with a resume point and clears that alone: a hidden video stays hidden, a watched one watched. Un-hiding and un-watching have rows of their own | The old Reset, which also un-hid and un-watched | A hidden video stays hidden: what the row's name says is all it does |
| 2026-11-01 | Selecting a list's rows (owner D2 a) | An always visible box first in the row and one over the column for the rows shown; Shift+click a range, `x` the row under the keyboard, Esc clears; the selected row wears `accent/12` with its tick. Rows a filter hides stay selected, an action covers those shown; Watch later offers no selecting. | A "Select" button that shows the boxes on demand | Found at once and works on touch, with no mode to leave; costs about 2 rem a row |
| 2026-11-01 | Selection band (owner D1 a, D3 a, D4 a) | The toolbar's second row while rows are selected, no floating bar: move (the last target as one click) and remove, no copy; both undo back to the places, a video the target held before stays there; a single row's move undoes too. | A floating bar at the bottom; copying to a list too; no undo on a move | It covers no row, and the player and the notices live at the bottom; twenty videos sent to the wrong list need a way back to their places |
| 2026-11-01 | Grip on a phone (owner A) | Below a 24rem table a playlist row has no grip: the title keeps its width, and the row menu keeps the moves. | The grip and its drag kept, the title at 44 px | The title gets its width back, and the rail is shut on a phone, so a drag had nowhere to go |
| 2026-11-01 | Selection band on a tablet (owner A) | Below `xl` the band is glyphs on one row, the names in tooltip and aria-label, as on the channel header; the replaced row stays in the same grid cell, invisible, so the first tick moves no row under the second tap. | The buttons in words, wrapping to a second row | The first tick pushed the list from under the second tap |
| 2026-11-01 | Playlist heading on a narrow window (owner A) | The list's name had no width left: below `xl` Unsynced is its dot (the word read out, the hint in the tooltip); on a phone the cover is gone and Play all is its glyph, named in the tooltip. | Every word kept, the list's name at no width | A heading without its name says nothing |
| 2026-11-02 | Dragging onto a list (owner D5 a, D6 a) | The grip carries a row, or every selected row with a selected one, onto a list in the rail; the toast undoes. Many dropped inside the list stay; a coarse pointer's drawer never peeks, so its rail is a target only when pinned. | Only the grabbed row going along; the grip off under a view | One row moving out of three lit ones misleads, and a filter is how the rows to move are found |
| 2026-11-02 | Delete with every tick hidden (owner a) | It removes nothing and a warning says the filter hides them | Taking the row under the keyboard | While the band shows a selection, Delete means that selection: a row no one ticked must not go |
| 2026-11-02 | Undo of a video gone further (owner a) | A video the move put in the target comes back only if the target still holds it, and a warning says how many | Everything back, whatever it did since | A move skips a video that left the source; the same rule back keeps a video out of lists the reader never put it in |
| 2026-11-04 | Tag chip counts (owner D5a) | A Channels tag chip shows how many followed channels it holds: a muted number after the name, no badge | The number in brackets after the name | The owner's pick; the number is a detail of the name, so it takes the name's own text, muted, rather than a `CountBadge` |
| 2026-11-05 | Picked tag chips lead the row (owner c) | On the Channels tag row the picked chips come first, in the tags' order, then the rest | Keep the order and fold the unpicked first; mark the "+N" when it holds a pick | The row folds from its end, so a picked tag late in the list sat only inside "+N" with nothing on the row saying a filter was on |
| 2026-11-05 | A toggled tag chip keeps the focus (owner a) | A chip that moves when toggled takes the keyboard focus with it: to its new place, or to "+N" when it went into the closed fold | Keep a picked chip in the "+N" popover until it closes | The row always shows the filters as they are; the focus, not the order, was what got lost |
| 2026-11-06 | Tag chip count after a dot (owner a) | The count keeps the chip's own colour, set off from the name by a "·" | The number dimmed (it fell under the 4.5:1 text floor, resting and pressed); the number in a tinted inner badge | Contrast holds in every scheme without a new token, and the dot still reads the number as a detail of the name |
| 2026-11-06 | A pick in the open fold closes it (owner a) | Toggling a chip inside the "+N" popover closes the popover; the focus goes with the chip | Keep the popover open and focus the chip now in the picked one's place | The focus never sits outside a popover left open, and a picked chip leads the row anyway |
| 2026-11-06 | An expired share link offers no Copy (owner a) | An expired row in the share dialog has a warn badge "expired <date>" and its address struck through; Open and Revoke stay, Copy goes | The warn badge alone, Copy kept | An expired link opens nothing, so it is no link to hand out; Open still lets the owner check it |
| 2026-11-06 | The shared page's unlock button is a noun in Hungarian (owner a) | "Megtekintés" for a video, "Megnyitás" for a file or folder | Both verbs, "Megnézem" / "Megnyitom" | The app's Hungarian buttons are nouns; one button should not change grammatical form by link kind |
+4
View File
@@ -24,6 +24,10 @@ outlives an `expect` timeout); Node does not, so a request from the test side fa
that one error again and passes every other failure straight on. Don't run two e2e suites on the
same machine at once.
**A page load the machine had no buffer for** — on the same host a page load can fail at once with
`net::ERR_NO_BUFFER_SPACE`. Every page load goes through `gotoRetrying` (same file), which tries that
one error again; a test that calls `page.goto` itself fails `src/lib/e2eRetryConnect.test.ts`.
**Browsers** — every flow runs in three projects: `chromium` (Desktop Chrome), `webkit` (Desktop
Safari) and `ipad` (iPad gen 7 landscape: 1080×810, touch, mobile) — except `a11y`, `member` and
`devices`, which run on chromium only (`CHROMIUM_ONLY`; why is in the `docs/gates.md` row). Install the engines once per
+2 -2
View File
@@ -4,7 +4,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { MEMBER_STORAGE, SEED_FILE } from "./helpers";
import { retryConnect } from "./retryConnect";
import { gotoRetrying, retryConnect } from "./retryConnect";
/**
* One-time setup for the characterization net (the `setup` project the tests depend on):
@@ -52,7 +52,7 @@ async function signIn(page: Page, email: string, path: string): Promise<void> {
// and player flows this net locks all open "/" expecting the feed; the opt-out is the one
// Settings → Appearance → Startup writes. Both flags are the account's local state, held for the
// account the browser records as their owner (`siftlode.owner`, see storage.ts claimLocalData).
await page.goto("/");
await gotoRetrying(page, "/");
await page.evaluate((id: number) => {
localStorage.setItem("siftlode.owner", String(id));
localStorage.setItem("siftlode.lang", "en");
+2 -1
View File
@@ -1,5 +1,6 @@
import { readFileSync } from "node:fs";
import { expect, type Page } from "@playwright/test";
import { gotoRetrying } from "./retryConnect";
/** The plain member's saved session (global.setup.ts signs it in); `member.spec.ts` and
* `devices.spec.ts` run from it. */
@@ -16,7 +17,7 @@ export function seeded(key: string): string[] {
/** Open the app and wait for the authenticated shell (the nav rail) to be ready. */
export async function openApp(page: Page, path = "/"): Promise<void> {
await page.goto(path);
await gotoRetrying(page, path);
await expect(page.getByTestId("nav-feed")).toBeVisible();
}
+3 -1
View File
@@ -66,11 +66,13 @@ async function carry(page: Page, grip: Locator, to: { x: number; y: number }) {
/** The same onto the rail. An unpinned one is a tab until the drag's own hover peeks it open. */
async function carryToRail(page: Page, grip: Locator, target: Locator) {
const tab = page.getByRole("button", { name: "Pin Playlists open" });
// The rail's lists answer after the rows: until then a pinned rail shows neither the list nor a tab.
await target.or(tab).first().waitFor();
if (await target.isVisible()) {
await carry(page, grip, await centre(target));
return;
}
const tab = page.getByRole("button", { name: "Pin Playlists open" });
await carry(page, grip, await centre(tab));
await expect(target).toBeVisible();
await page.mouse.move(...(Object.values(await centre(target)) as [number, number]), { steps: 6 });
+26 -11
View File
@@ -11,6 +11,19 @@ const METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "fetch
const NO_CONNECTION = /\bconnect ETIMEDOUT\b/;
const WAITS_MS = [500, 1000, 2000, 4000, 8000];
/** `attempt` again after each wait while it fails with `transient`; any other failure goes on. */
async function tryAgain<R>(attempt: () => Promise<R>, transient: RegExp): Promise<R> {
for (let i = 0; ; i++) {
try {
return await attempt();
} catch (e) {
const wait = WAITS_MS[i];
if (wait === undefined || !transient.test(String((e as Error)?.message))) throw e;
await new Promise((r) => setTimeout(r, wait));
}
}
}
/** `ctx` with its request methods trying a connect that timed out again (see above). */
export function retryConnect<T extends object>(ctx: T): T {
return new Proxy(ctx, {
@@ -18,17 +31,19 @@ export function retryConnect<T extends object>(ctx: T): T {
const v: unknown = Reflect.get(target, key, target);
if (typeof v !== "function") return v;
if (typeof key !== "string" || !METHODS.has(key)) return v.bind(target);
return async (...args: unknown[]) => {
for (let i = 0; ; i++) {
try {
return await v.apply(target, args);
} catch (e) {
const wait = WAITS_MS[i];
if (wait === undefined || !NO_CONNECTION.test(String((e as Error)?.message))) throw e;
await new Promise((r) => setTimeout(r, wait));
}
}
};
return (...args: unknown[]) =>
tryAgain(() => Promise.resolve(v.apply(target, args)), NO_CONNECTION);
},
});
}
// A page load has a failure of its own on the same machine: `net::ERR_NO_BUFFER_SPACE`, the socket
// call refused for the moment. It stopped a release's e2e at its first page (0.116.3, with nothing
// else running; 0.107.3 aborted on it too), and the unchanged rerun passed. A browser does not try
// a main document again by itself, and the request never left, so this does. The suite's every page
// load goes through here (`e2eRetryConnect.test.ts`).
const NO_BUFFER = /\bERR_NO_BUFFER_SPACE\b/;
export function gotoRetrying<R>(page: { goto(url: string): Promise<R> }, url: string): Promise<R> {
return tryAgain(() => page.goto(url), NO_BUFFER);
}
+3 -3
View File
@@ -1,7 +1,7 @@
import { test, type Page } from "@playwright/test";
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { retryConnect } from "./retryConnect";
import { gotoRetrying, retryConnect } from "./retryConnect";
/**
* The speed baseline: numbers, not assertions — read by `scripts/audit_metrics.py --speed`, which
@@ -78,7 +78,7 @@ test("speed baseline", async ({ browser, page: page0 }) => {
for (let i = 0; i < RUNS; i++) {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto("/");
await gotoRetrying(page, "/");
// Timed inside the page, every frame: an `expect` would read the clock on its own retry
// schedule (100 ms … 1 s steps), not when the card appeared.
const at = await page.waitForFunction(
@@ -108,7 +108,7 @@ test("speed baseline", async ({ browser, page: page0 }) => {
for (let i = 0; i < RUNS; i++) {
const ctx = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const page = await ctx.newPage();
await page.goto(`/watch/${token}`);
await gotoRetrying(page, `/watch/${token}`);
await page.waitForFunction(() => document.querySelector("video, audio"), null, {
polling: "raf",
timeout: 30_000,
+61
View File
@@ -0,0 +1,61 @@
import { test, expect, type Page } from "@playwright/test";
import { openApp } from "./helpers";
import { gotoRetrying } from "./retryConnect";
// A request can be lost on the way on a loaded machine or a poor line. A lost stylesheet leaves the
// whole page unstyled, and every test that finds its elements by testid still passes on it: only an
// axe run ever noticed (8 controls under the target size). So this looks at the styles themselves.
/** Lose the first request for every stylesheet of the page; the ones after it go through. */
async function loseEachStylesheetOnce(page: Page): Promise<string[]> {
const lost: string[] = [];
await page.route(/\/assets\/[^/]+\.css/, async (route) => {
const url = route.request().url();
if (lost.includes(url)) return route.continue();
lost.push(url);
return route.abort("failed");
});
return lost;
}
/** Every stylesheet link of the page holds rules (a lost one still has a `sheet`, whose rules throw
* on reading), and the page's own tokens are in force: `--bg` comes from the stylesheet alone, while
* `--font-scale` would prove nothing, the app also sets it from script. */
async function expectStyled(page: Page): Promise<void> {
await expect
.poll(() =>
page.evaluate(() => {
const hasRules = (l: HTMLLinkElement) => {
try {
return (l.sheet?.cssRules.length ?? 0) > 0;
} catch {
return false;
}
};
const links = [...document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')];
const root = getComputedStyle(document.documentElement);
return {
links: links.length > 0,
rules: links.every(hasRules),
tokens: root.getPropertyValue("--bg").trim() !== "",
};
}),
)
.toEqual({ links: true, rules: true, tokens: true });
}
test("the app gets back a stylesheet lost on the way", async ({ page }) => {
const lost = await loseEachStylesheetOnce(page);
await openApp(page);
expect(lost.length).toBeGreaterThan(0);
await expectStyled(page);
});
test("a public /watch link gets back a stylesheet lost on the way", async ({ page }) => {
const lost = await loseEachStylesheetOnce(page);
// No such link: the page says so, and it is the same entry with the same stylesheet.
await gotoRetrying(page, "/watch/no-such-token");
await expect(page.locator("#root")).not.toBeEmpty();
expect(lost.length).toBeGreaterThan(0);
await expectStyled(page);
});
+4 -2
View File
@@ -1,14 +1,16 @@
# Siftlode is mostly a private, login-gated app; only the public landing and legal
# pages are worth indexing. Allow crawling but keep bots out of the API surface.
# Share links (/watch/) are unguessable capability URLs: kept out of search indexes,
# Share links (/watch/, /file/, /folder/) are unguessable capability URLs: kept out of search indexes,
# but link-preview crawlers are allowed below so shared links unfurl in chat apps.
User-agent: *
Allow: /
Disallow: /api/
Disallow: /auth/
Disallow: /watch/
Disallow: /file/
Disallow: /folder/
# Link-preview / unfurl bots — allowed to fetch /watch/ so shared links render a rich
# Link-preview / unfurl bots — allowed to fetch share links so shared links render a rich
# card (title/channel/thumbnail). They don't build a search index, so this doesn't
# expose share links to search engines.
User-agent: facebookexternalhit
@@ -9,7 +9,13 @@ import { act, render, screen, fireEvent, cleanup } from "@testing-library/react"
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
return {
...actual,
useTranslation: () => ({
t: (k: string, o?: { action?: string; title?: string }) =>
o?.title ? `${o.action}: ${o.title}` : k,
}),
};
});
import { Carousel } from "./Carousel";
@@ -54,7 +60,10 @@ describe("Carousel", () => {
);
expect(screen.getByText("card-a")).toBeTruthy();
expect(screen.getByRole("heading", { name: "Recently added" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "common.seeAll" }));
// Named with its rail, the visible words first: a page of rails has a See all on each.
const seeAll = screen.getByRole("button", { name: "common.seeAll: Recently added" });
expect(seeAll.textContent).toBe("common.seeAll");
fireEvent.click(seeAll);
expect(onSeeAll).toHaveBeenCalledOnce();
});
+15 -1
View File
@@ -109,7 +109,21 @@ export function Carousel({
<div className="mb-3 flex items-baseline gap-2">
<h2 className="text-sm font-semibold">{title}</h2>
{onSeeAll && (
<Button variant="link-muted" onClick={onSeeAll} className="ml-auto gap-0.5 text-xs">
<Button
variant="link-muted"
onClick={onSeeAll}
// Named with its rail: a page of rails has a See all on each, and a screen reader's
// list of buttons (or a voice command) must tell them apart. The visible words lead.
aria-label={
label
? t("common.seeAllOf", {
action: seeAllLabel ?? t("common.seeAll"),
title: label,
})
: undefined
}
className="ml-auto gap-0.5 text-xs"
>
{seeAllLabel ?? t("common.seeAll")}
<ChevronRight className="h-3.5 w-3.5" />
</Button>
@@ -1,4 +1,33 @@
[
{
"version": "0.117.0",
"date": "2026-11-06",
"new": [
"A share link's address now says what it opens: /file/ for a file, /folder/ for a folder, /watch/ for a video. Links you already sent keep working.",
"The upload dialog shows which folder the file goes to, and you can pick another one or make a new folder there. Opening Share copies a link at once.",
"The Channels tag chips show how many followed channels each holds. Continue watching in Plex has a See all, and a Plex item's Info shows the file size."
],
"fixed": [
"An expired share link is marked as expired and is never copied. A crop of a video with wide pixels keeps exactly what its frame showed.",
"A Plex auto-skip waits 3 seconds into the marker before its countdown starts."
]
},
{
"version": "0.116.3",
"date": "2026-11-04",
"new": [],
"fixed": [
"If a stylesheet is lost on the way, the app now asks for it again, so a poor connection no longer leaves the page unstyled until a reload."
]
},
{
"version": "0.116.2",
"date": "2026-11-03",
"new": [],
"fixed": [
"A Plex video paused or muted in a background tab no longer loses its session after a minute, and if a session is lost anyway, playback comes back at once instead of after half a minute."
]
},
{
"version": "0.116.1",
"date": "2026-11-03",
@@ -1,4 +1,33 @@
[
{
"version": "0.117.0",
"date": "2026-11-06",
"new": [
"A megosztási link címe most megmondja, mit nyit meg: /file/ egy fájlt, /folder/ egy mappát, /watch/ egy videót. A már elküldött linkek továbbra is működnek.",
"A feltöltési ablak mutatja, melyik mappába kerül a fájl, és ott választhatsz másikat vagy új mappát hozhatsz létre. A Megosztás megnyitáskor rögtön kimásol egy linket.",
"A Csatornák címkéi mutatják, hány követett csatorna tartozik hozzájuk. A Plex Folytatás sávjának is van Összes gombja, és az elem adatlapja mutatja a fájl méretét."
],
"fixed": [
"A lejárt megosztási link lejártként látszik, és sosem kerül a vágólapra. A széles képpontú videó vágása pontosan azt tartja meg, amit a keret mutatott.",
"A Plex automatikus átugrása a marker után 3 másodpercet vár, mielőtt a visszaszámlálás indul."
]
},
{
"version": "0.116.3",
"date": "2026-11-04",
"new": [],
"fixed": [
"Ha egy stíluslap elvész útközben, az app most újra lekéri, így a rossz kapcsolat miatt nem marad formázatlan az oldal újratöltésig."
]
},
{
"version": "0.116.2",
"date": "2026-11-03",
"new": [],
"fixed": [
"A háttérfülön szüneteltetett vagy némított Plex-videó már nem veszíti el a munkamenetét egy perc után, és ha mégis elveszne, a lejátszás azonnal visszatér, nem fél perc múlva."
]
},
{
"version": "0.116.1",
"date": "2026-11-03",
@@ -45,6 +45,7 @@ const tag = (id: number, name: string): Tag => ({
category: "other",
system: false,
channel_count: 0,
subscribed_count: 0,
});
const docu = tag(1, "docu");
const history = tag(2, "history");
@@ -0,0 +1,69 @@
// @vitest-environment jsdom
//
// A tag's row in the manager says how many channels it is on, beside a hover list of them, over the
// Channels page whose chip names the same tag with a number: all three count the same channels —
// the followed ones the Subscriptions tab shows, hidden ones left out. Every link (the number a
// delete removes) is only the delete's confirm.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({
t: (key: string, o?: { count?: number }) => (o?.count != null ? `${key}:${o.count}` : key),
i18n: { language: "en" },
}),
}));
import TagManager from "./TagManager";
import { api, type ManagedChannel, type Tag } from "../../lib/api";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const tag = {
id: 5,
name: "Music",
color: null,
category: "other",
system: false,
channel_count: 4,
subscribed_count: 2,
} as Tag;
const ch = (id: string, hidden: boolean) =>
({ id, title: id, hidden, tag_ids: [5] }) as unknown as ManagedChannel;
describe("TagManager · a tag's count", () => {
it("counts the followed channels the Subscriptions tab shows, not every link", async () => {
vi.spyOn(api, "tags").mockResolvedValue([tag]);
vi.spyOn(api, "channels").mockResolvedValue([
ch("UCa", false),
ch("UCb", false),
ch("UChid", true),
]);
render(
<QueryClientProvider client={new QueryClient()}>
<TagManager onClose={() => {}} onFocusChannel={() => {}} />
</QueryClientProvider>,
);
expect(await screen.findByText("tagManager.channels:2")).toBeTruthy();
expect(screen.queryByText("tagManager.channels:4")).toBeNull();
fireEvent.mouseEnter(screen.getByText("tagManager.channels:2"));
expect(await screen.findByText("UCa")).toBeTruthy();
expect(screen.queryByText("UChid")).toBeNull();
});
});
+2 -2
View File
@@ -71,7 +71,7 @@ function TagRow({
: ""
}`}
>
{t("tagManager.channels", { count: tag.channel_count })}
{t("tagManager.channels", { count: tag.subscribed_count })}
</span>
<IconButton variant="danger" onClick={onDelete} label={t("tagManager.delete")}>
<Trash2 />
@@ -119,7 +119,7 @@ export default function TagManager({
const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system);
const channelsForTag = (tagId: number) =>
(channelsQuery.data ?? [])
.filter((c) => c.tag_ids.includes(tagId))
.filter((c) => !c.hidden && c.tag_ids.includes(tagId))
.map((c) => ({ id: c.id, title: c.title ?? c.id }));
const invalidate = () => {
+1
View File
@@ -28,6 +28,7 @@
"loadError": "Couldn't load this — check your connection and try again",
"deletedTag": "Deleted tag",
"seeAll": "See all",
"seeAllOf": "{{action}}: {{title}}",
"scrollLeft": "Scroll left",
"scrollRight": "Scroll right",
"search": "Search",
+5 -1
View File
@@ -32,6 +32,9 @@
"uploadProgress": "Upload progress",
"added": "File uploaded",
"addedConverting": "File uploaded — converting it so it plays on any phone",
"targetLabel": "Upload to",
"makingFolder": "Making “{{name}}”…",
"targetFailed": "The folders could not be loaded",
"failed": "Couldn't upload the file"
},
"status": {
@@ -90,7 +93,7 @@
"pickPlaceholder": "Search a user…",
"noUsers": "No other users yet.",
"linkSection": "Share a link",
"linkHint": "Anyone with the link can watch — no account needed.",
"linkHint": "Anyone with the link can open it — no account needed.",
"linkCopied": "Share link copied to clipboard",
"openNewTab": "Open in new tab",
"linkFailed": "Couldn’t create the share link",
@@ -111,6 +114,7 @@
"revokeConfirm": "This link will stop working immediately.",
"views_one": "{{count}} view",
"views_other": "{{count}} views",
"expiredOn": "expired {{date}}",
"expiresOn": "expires {{date}}",
"hasPassword": "password",
"downloadable": "downloadable",
+1
View File
@@ -28,6 +28,7 @@
"loadError": "Nem sikerült betölteni — ellenőrizd a kapcsolatot, és próbáld újra",
"deletedTag": "Törölt címke",
"seeAll": "Összes",
"seeAllOf": "{{action}}: {{title}}",
"scrollLeft": "Görgetés balra",
"scrollRight": "Görgetés jobbra",
"search": "Keresés",
+5 -1
View File
@@ -32,6 +32,9 @@
"uploadProgress": "Feltöltés folyamata",
"added": "Fájl feltöltve",
"addedConverting": "Fájl feltöltve — átalakítjuk, hogy minden telefonon lejátszható legyen",
"targetLabel": "Feltöltés ide",
"makingFolder": "„{{name}}” létrehozása…",
"targetFailed": "A mappákat nem sikerült betölteni",
"failed": "Nem sikerült feltölteni a fájlt"
},
"status": {
@@ -90,7 +93,7 @@
"pickPlaceholder": "Keress egy felhasználót…",
"noUsers": "Nincs más felhasználó.",
"linkSection": "Megosztás linkkel",
"linkHint": "A link birtokában bárki megnézheti — fiók nélkül is.",
"linkHint": "A link birtokában bárki megnyithatja — fiók nélkül is.",
"linkCopied": "Megosztási link a vágólapra másolva",
"openNewTab": "Megnyitás új lapon",
"linkFailed": "Nem sikerült a megosztási link létrehozása",
@@ -111,6 +114,7 @@
"revokeConfirm": "A link azonnal használhatatlanná válik.",
"views_one": "{{count}} megtekintés",
"views_other": "{{count}} megtekintés",
"expiredOn": "lejárt: {{date}}",
"expiresOn": "lejár: {{date}}",
"hasPassword": "jelszó",
"downloadable": "letölthető",
+4 -2
View File
@@ -70,7 +70,9 @@ export interface ClipRequest {
export interface EditSpec {
trim?: { start_s: number; end_s?: number };
segments?: { start_s: number; end_s: number }[];
crop?: { x: number; y: number; w: number; h: number };
// In the picture's size as the browser reports it, which `frame_w` x `frame_h` says: the server
// scales them to the file's own pixels (a video's stored and shown sizes can differ).
crop?: { x: number; y: number; w: number; h: number; frame_w?: number; frame_h?: number };
accurate?: boolean;
}
@@ -143,7 +145,7 @@ export interface ShareLink {
// Null when the server cannot show this link's address again (no encrypted copy its key reads): the
// link still opens for whoever has it; to share it anew, make a new link.
token: string | null;
url: string | null; // path like "/watch/<token>" — prepend window.location.origin to share
url: string | null; // path like "/watch/<token>" or "/file/<token>" — prepend window.location.origin to share
allow_download: boolean;
has_password: boolean;
expires_at: string | null;
+1
View File
@@ -154,6 +154,7 @@ export interface PlexItemDetail {
summary?: string | null;
year?: number | null;
duration_seconds: number | null;
size_bytes?: number | null; // the file's, null while unknown
playable: string; // direct | remux | transcode
transcode_hw?: boolean | null; // transcode items only: true = HW-capable, false = SW-only, null = n/a
codec_video?: string | null; // e.g. "hevc" / "h264" (info-chip subtitle)
+5
View File
@@ -7,7 +7,12 @@ export interface Tag {
color: string | null;
category: string;
system: boolean;
/** Every channel link it has, a channel the user no longer follows included (an unsubscribe keeps
* the link) — what deleting the tag removes. */
channel_count: number;
/** The user's own links on channels they follow and have not hidden: the rows the Channels page's
* filter shows (a hidden channel has its own tab). */
subscribed_count: number;
}
// One suggested user-tag for a channel, learned from the user's own manual-tag pattern.
@@ -18,6 +18,7 @@ import { api, HttpError } from "./api";
import { msg, notify } from "./notifications";
import { useChannelTagActions } from "./useChannelTagActions";
import { useChannelPriority } from "./useChannelPriority";
import { qk } from "./queryKeys";
const notifyMock = vi.mocked(notify);
beforeEach(() => notifyMock.mockClear());
@@ -44,6 +45,23 @@ describe("useChannelTagActions", () => {
});
});
// A tag counts the followed channels it is on: a toggle that lands changes that number.
it("refreshes the tags once a toggle lands", async () => {
vi.spyOn(api, "attachChannelTag").mockResolvedValue({ ok: true } as never);
const qc = new QueryClient();
const spy = vi.spyOn(qc, "invalidateQueries");
const { result } = renderHook(
() => useChannelTagActions({ optimisticToggle: () => {}, onError: () => {} }),
{
wrapper: ({ children }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
),
},
);
act(() => result.current.toggleTag("UC1", 7, false));
await waitFor(() => expect(spy.mock.calls.map(([f]) => f?.queryKey)).toContainEqual(qk.tags()));
});
it("names a refused create-and-attach", async () => {
vi.spyOn(api, "createTag").mockRejectedValue(new HttpError(429, "slow"));
const { result } = renderHook(
+16
View File
@@ -94,6 +94,22 @@ describe("quickShareLink", () => {
expect(createDownloadLink).not.toHaveBeenCalled();
});
// An expired link stays listed for its owner, but its recipient would land on "link gone".
it("passes over an expired link, and mints one when every link has expired", async () => {
const lastWeek = new Date(Date.now() - 7 * 86400_000).toISOString();
const nextWeek = new Date(Date.now() + 7 * 86400_000).toISOString();
downloadLinks.mockResolvedValue([
{ id: 5, url: "/watch/gone", expires_at: lastWeek },
{ id: 3, url: "/watch/live", expires_at: nextWeek },
]);
expect(await quickShareLink(qc(), { kind: "file", id: 1 })).toBe(`${origin}/watch/live`);
expect(createDownloadLink).not.toHaveBeenCalled();
downloadLinks.mockResolvedValue([{ id: 5, url: "/watch/gone", expires_at: lastWeek }]);
createDownloadLink.mockResolvedValue({ id: 9, url: "/watch/fresh" });
expect(await quickShareLink(qc(), { kind: "file", id: 2 })).toBe(`${origin}/watch/fresh`);
});
it("mints a new link when none it has can be shown, keeping the old ones listed", async () => {
const hidden = { id: 4, url: null };
downloadLinks.mockResolvedValue([hidden]);
+12 -3
View File
@@ -1,6 +1,6 @@
import type { QueryClient } from "@tanstack/react-query";
import { qk } from "./queryKeys";
import { api, type NewShareLink } from "./api";
import { api, type NewShareLink, type ShareLink } from "./api";
import { copyText } from "./clipboard";
import { msg, notify } from "./notifications";
import { notifyActionError } from "./actionErrors";
@@ -38,6 +38,16 @@ export function invalidateLinks(qc: QueryClient, target: LinkTarget) {
if (target.kind === "folder") void qc.invalidateQueries({ queryKey: qk.fileFolders() });
}
/** Has a link stopped opening? The list still holds an expired link (its owner may want to see or
* revoke it), but it is no link to hand out. */
export const linkExpired = (link: ShareLink, now = Date.now()) =>
link.expires_at !== null && Date.parse(link.expires_at) <= now;
/** The link to hand out of a list (newest first): the newest that still opens and whose address can
* be shown — an expired one sends the recipient to "link gone", one without an address is no use
* to copy. None: make one. */
export const liveLink = (links: ShareLink[]) => links.find((l) => l.url && !linkExpired(l));
// One-click share: the row's Share button turns "share this" into a single action.
// Ensure the file or folder has a public link (minting one on demand — omitting `allow_download` so
// the backend default makes it downloadable), copy it to the clipboard, and toast with an "open in
@@ -51,8 +61,7 @@ export async function quickShareLink(qc: QueryClient, target: LinkTarget): Promi
queryKey: linksKey(target),
queryFn: () => fetchLinks(target),
});
// The newest link whose address can still be shown; one that cannot is no use to copy.
path = existing.find((l) => l.url)?.url;
path = liveLink(existing)?.url;
if (!path) {
const made = await makeLink(target, {}); // omit allow_download → backend default (true)
qc.setQueryData(linksKey(target), [made, ...existing]);
+42 -1
View File
@@ -1,7 +1,8 @@
// The e2e suite's request wrapper (e2e/retryConnect.ts): a connect that never came about is tried
// again, as every TCP stack would; any other failure, or an answer, goes straight to the test.
import { readdirSync, readFileSync } from "node:fs";
import { describe, expect, it, vi } from "vitest";
import { retryConnect } from "../../e2e/retryConnect";
import { gotoRetrying, retryConnect } from "../../e2e/retryConnect";
const noConnection = () => new Error("apiRequestContext.get: connect ETIMEDOUT 127.0.0.1:8080");
@@ -39,3 +40,43 @@ describe("retryConnect", () => {
vi.useRealTimers();
});
});
// The text Playwright gave when this stopped a release's e2e at its first page load (2026-11-04).
const noBuffer = () => new Error("page.goto: net::ERR_NO_BUFFER_SPACE at http://127.0.0.1:8080/");
describe("gotoRetrying", () => {
it("tries a page load the machine had no buffer for again", async () => {
vi.useFakeTimers();
const goto = vi.fn().mockRejectedValueOnce(noBuffer()).mockResolvedValue("response");
const answer = gotoRetrying({ goto }, "/feed");
await vi.runAllTimersAsync();
await expect(answer).resolves.toBe("response");
expect(goto).toHaveBeenCalledTimes(2);
expect(goto).toHaveBeenLastCalledWith("/feed");
vi.useRealTimers();
});
it("passes any other failure straight on, untried", async () => {
const goto = vi.fn().mockRejectedValue(new Error("page.goto: net::ERR_CONNECTION_REFUSED"));
await expect(gotoRetrying({ goto }, "/")).rejects.toThrow("ERR_CONNECTION_REFUSED");
expect(goto).toHaveBeenCalledTimes(1);
});
it("gives up after its last wait, with the error", async () => {
vi.useFakeTimers();
const goto = vi.fn().mockRejectedValue(noBuffer());
const settled = expect(gotoRetrying({ goto }, "/")).rejects.toThrow("ERR_NO_BUFFER_SPACE");
await vi.runAllTimersAsync();
await settled;
expect(goto).toHaveBeenCalledTimes(6);
vi.useRealTimers();
});
it("is the only way the suite loads a page", () => {
const dir = new URL("../../e2e/", import.meta.url);
const direct = readdirSync(dir)
.filter((f) => f.endsWith(".ts") && f !== "retryConnect.ts")
.filter((f) => /\.(goto|reload)\(/.test(readFileSync(new URL(f, dir), "utf-8")));
expect(direct).toEqual([]);
});
});
+98 -1
View File
@@ -65,6 +65,27 @@ class FakeHls implements HlsPort {
lost404() {
this.lost(404);
}
/** A request answered "this session is gone". hls.js only reports it (not fatal) and retries for
* half a minute: with one video variant it has nowhere else to go. */
gone() {
this.handlers.onError({
fatal: false,
network: true,
status: 410,
type: "networkError",
details: "fragLoadError",
});
}
/** hls.js reports a failed request it will retry by itself. */
retrying(status: number) {
this.handlers.onError({
fatal: false,
network: true,
status,
type: "networkError",
details: "fragLoadError",
});
}
/** hls.js gave up on a playlist or fragment with this answer (null: no answer at all). */
lost(status: number | null) {
this.handlers.onError({
@@ -353,7 +374,7 @@ describe("a failed session request", () => {
});
});
describe("a session the server no longer has (404)", () => {
describe("something that never arrives (a fatal 404)", () => {
it("is reclaimed where the viewer is", async () => {
const { engine, asks, hlses, state } = setup();
engine.start("A", 600);
@@ -418,9 +439,85 @@ describe("a session the server no longer has (404)", () => {
});
});
describe("a session the server says is gone (410)", () => {
async function playing() {
const s = setup();
s.engine.start("A", 600);
await s.asks[0]!.resolve(hls("A", 600));
s.hlses[0]!.handlers.onManifestParsed();
s.media.fire("canplay");
s.state.position = 1299;
return s;
}
it("is reclaimed at the first answer, not after hls.js's half minute of retries", async () => {
const { asks, hlses } = await playing();
hlses[0]!.gone();
expect(asks[1]!.startAt).toBe(1299);
});
it("keeps the running picture until the new session answers", async () => {
const { engine, hlses } = await playing();
expect(engine.getSnapshot().ready).toBe(true);
hlses[0]!.gone();
expect(engine.getSnapshot().ready).toBe(true);
});
it("asks once, whatever the same instance still reports", async () => {
const { asks, hlses } = await playing();
hlses[0]!.gone();
hlses[0]!.gone();
hlses[0]!.lost(410);
expect(asks).toHaveLength(2);
});
it(`shares the reclaim budget, and says so after ${MAX_SESSION_RECLAIMS}`, async () => {
const { engine, asks, hlses } = setup();
engine.start("A", 600);
for (let i = 0; i <= MAX_SESSION_RECLAIMS; i++) {
await asks[i]!.resolve(hls("A", 600));
hlses[i]!.gone();
}
expect(asks).toHaveLength(MAX_SESSION_RECLAIMS + 1);
expect(engine.getSnapshot().error).toBe("generic");
});
it("keeps a seek made while it is out, inside what the old session still reports", async () => {
const { engine, asks, hlses, media } = await playing();
media.seekEnd = 800;
hlses[0]!.gone();
media.currentTime = 42;
engine.seek("A", 1250);
expect(media.currentTime).toBe(42); // the outgoing media stays where it is
await asks[1]!.resolve(hls("A", 1299));
expect(asks.map((a) => a.startAt)).toEqual([600, 1299, 1250]);
});
it("leaves a segment that is merely not there yet to hls.js's retries", async () => {
const { engine, asks, hlses } = await playing();
hlses[0]!.retrying(404);
expect(asks).toHaveLength(1);
expect(engine.getSnapshot().error).toBeNull();
});
});
describe("a server that is restarting (a deploy)", () => {
// The gateway answers 502 while the app restarts, and nothing at all may answer (status 0): the
// stream is not broken, the server is away. The buffer keeps playing meanwhile.
it("keeps a seek made while the retry is out", async () => {
const { engine, asks, hlses, state, fire, media } = setup();
engine.start("A", 600);
await asks[0]!.resolve(hls("A", 600));
hlses[0]!.handlers.onManifestParsed();
media.seekEnd = 800;
state.position = 1299;
hlses[0]!.lost(502);
expect(fire()).toBe(true);
engine.seek("A", 1250);
await asks[1]!.resolve(hls("A", 1299));
expect(asks.map((a) => a.startAt)).toEqual([600, 1299, 1250]);
});
it.each([502, 503, 504, 0, null])(
"is waited out on %s: no error, and the stream comes back where the viewer is",
async (status) => {
+53 -31
View File
@@ -14,7 +14,7 @@ import type { HlsLevelInfo } from "./plexStats";
// once: twenty presses of ← at key-repeat pace past the buffer were twenty requests, twenty ffmpeg
// spawns, and five seconds before the picture moved again.
// Reclaims of a 404'd HLS session, within `RECLAIM_WINDOW_MS`, before the player stops and says so.
// Reclaims of a lost HLS session, within `RECLAIM_WINDOW_MS`, before the player stops and says so.
// Two covers the real case (the session was reaped, once) without letting a server that cannot serve
// the stream keep the viewer on a spinner (see `onHlsError`).
export const MAX_SESSION_RECLAIMS = 2;
@@ -33,6 +33,8 @@ export const RECLAIM_WINDOW_MS = 120_000;
export const AWAY_WINDOW_MS = 180_000;
const AWAY_RETRY_MS = [2_000, 4_000, 8_000, 15_000];
const SERVER_AWAY = new Set([0, 502, 503, 504]);
// The server's answer for a session it no longer has; its 404 means a segment not there yet.
const SESSION_GONE = 410;
/** Why a session could not play, in the three buckets the player has a sentence for. */
type EngineError = "transcode" | "notFound" | "generic";
@@ -183,7 +185,7 @@ export class HlsEngine {
// the way back to the first item then sent a second request beside the one still out for it.
private readonly inFlight = new Map<string, Request>();
private queued: Request | null = null;
// How often a session the server answered 404 for may be reclaimed (see `onHlsError`).
// How often a session the server lost may be reclaimed (see `onHlsError`).
private reclaims = { n: 0, at: 0 };
// Since when the server has been away, and how often it was asked since (see `serverAway`).
private away: { since: number; tries: number } | null = null;
@@ -283,7 +285,7 @@ export class HlsEngine {
/** (Re)start `id` at `startAt` seconds. Another item's request goes out at once; one for the item
* already being asked for waits for that answer (see the header). `quiet`: the attached stream is
* still playing from its buffer (the server went away under it), so it stays the player's until
* still playing from its buffer (the server went away or lost its session), so it stays the player's until
* the answer replaces it — no "Loading…" over a running picture, its clock still true. */
start(id: string, startAt: number, play = true, quiet = false): void {
if (!this.deps.media()) return;
@@ -459,42 +461,61 @@ export class HlsEngine {
};
}
// A fatal HLS error (the session died, the file became unreadable) would otherwise leave the
// spinner up forever — it is said instead.
// A fatal HLS error (the file became unreadable) would otherwise leave the spinner up forever — it
// is said instead.
//
// Except a 404: the session is gone, not broken. Either the server reaped it (a pause longer than
// its idle limit, a deploy) — hls.js never retries a 4xx, so this used to end playback with
// "couldn't play" after the buffer ran out — or it was superseded before it ever played (two
// requests for one item: the server's LAST spawn wins). The server answers a superseded session
// exactly so the client asks again; the reclaim is that asking.
// Except a session the server no longer has (410): gone, not broken. Either the server reaped it
// or restarted, or it was superseded before it ever played (two requests for one item: the
// server's LAST spawn wins), and the reclaim is the asking again that answer wants. It does not
// wait for hls.js to give up. On a finished playlist, with one video variant and nothing to
// switch to, it retries even a 4xx, six times over about half a minute (measured: seven requests,
// then the reclaim), and in a throttled page once a minute (four minutes). On a growing one it
// takes the segment for a gap and moves on, so the error is never fatal. Either way the first
// answer is reported. Quiet, since the buffer is still playing: the picture runs on until the
// answer, which restarts from where it was asked, so what played meanwhile is replayed (accepted:
// a brief loading state marks it, and nothing the viewer has saved is lost).
//
// Bounded by the budget, not by whether the session got as far as playing: that belongs to ONE
// session, and every restart builds a fresh one, so it could never bound a loop. The server also answers
// 404 for "that segment is not there YET" — indistinguishable on the wire — so a transcode falling
// behind realtime restarted ffmpeg forever behind a spinner that never became an error.
// And except a fatal 404: hls.js gave up on something the server still does not have, and asking
// again is all that is left.
//
// Both are bounded by the budget, not by whether the session got as far as playing: that belongs to
// ONE session, and every restart builds a fresh one, so it could never bound a loop. A transcode
// falling behind realtime restarted ffmpeg forever behind a spinner that never became an error.
private onHlsError(req: Request, media: HTMLMediaElement, e: HlsFailure): void {
this.deps.onHlsError?.(e);
if (!e.fatal || !this.current(req)) return;
if (!this.current(req)) return;
if (e.network && e.status === SESSION_GONE) {
this.reclaim(req, media, this.snapshot.ready);
return;
}
if (!e.fatal) return;
if (e.network && SERVER_AWAY.has(e.status ?? 0) && this.serverAway(req)) return;
if (e.network && e.status === 404) {
const now = this.deps.now();
// A new window when the last loss was long enough ago. The anchor moves with EVERY loss: left
// at the window's start, the window tumbled instead of rolling, and any loss period longer
// than half the window reset the budget on every second loss (measured: 200 losses 70 s apart
// → 200 reclaims, never an error).
if (now - this.reclaims.at > RECLAIM_WINDOW_MS) this.reclaims.n = 0;
this.reclaims.at = now;
if (this.reclaims.n < MAX_SESSION_RECLAIMS) {
this.reclaims.n += 1;
// The server dropped it already; nothing to stop.
this.attachedSession = null;
this.start(req.id, this.playhead(), this.wantsPlay(media));
return;
}
this.reclaim(req, media, false);
return;
}
this.set({ error: "generic" });
}
/** Ask for `req`'s item again where the viewer is, while the budget lasts; then say it cannot play. */
private reclaim(req: Request, media: HTMLMediaElement, quiet: boolean): void {
const now = this.deps.now();
// A new window when the last loss was long enough ago. The anchor moves with EVERY loss: left
// at the window's start, the window tumbled instead of rolling, and any loss period longer
// than half the window reset the budget on every second loss (measured: 200 losses 70 s apart
// → 200 reclaims, never an error).
if (now - this.reclaims.at > RECLAIM_WINDOW_MS) this.reclaims.n = 0;
this.reclaims.at = now;
if (this.reclaims.n >= MAX_SESSION_RECLAIMS) {
this.set({ error: "generic" });
return;
}
this.reclaims.n += 1;
// The server dropped it already; nothing to stop.
this.attachedSession = null;
this.start(req.id, this.playhead(), this.wantsPlay(media), quiet);
}
/** The server is away (see `AWAY_WINDOW_MS`): ask for `req`'s item again where the viewer is,
* after a delay that grows with each try. False once it has been away too long — the caller then
* says it cannot play. A restart the viewer asks for meanwhile (a seek, another item) replaces the
@@ -548,9 +569,10 @@ export class HlsEngine {
const rel = T - this.sessionStart.current;
// Native only when the attached media IS the current session. While one is still being asked
// for, `sessionStart` and `seekable` describe the OLD one: a native seek there left the pending
// restart current, and its answer then jumped to the earlier target.
// restart current, and its answer then jumped to the earlier target. A quiet request leaves
// `live` alone, so `pending` is what says one is out.
const end = media.seekable.length ? media.seekable.end(0) : 0;
if (this.live && rel >= 0 && rel <= end + 0.5) {
if (this.live && !this.pending && rel >= 0 && rel <= end + 0.5) {
media.currentTime = rel; // keeps playing on its own
if (play) media.play().catch(() => {});
} else {
@@ -0,0 +1,16 @@
// @vitest-environment jsdom
//
// An auto-skip's countdown starts below zero: the wait before it, while the Skip button already
// shows. The bar it draws must read empty through the wait, not a negative width.
import { describe, expect, it } from "vitest";
import { skipCountdown } from "./plexPlayerHooks";
describe("skipCountdown", () => {
it("stays empty through the wait, runs over the delay, and stops at full", () => {
expect(skipCountdown(-3, 5)).toBe(0);
expect(skipCountdown(-0.1, 5)).toBe(0);
expect(skipCountdown(2.5, 5)).toBe(0.5);
expect(skipCountdown(7, 5)).toBe(1);
});
});
+22 -2
View File
@@ -434,6 +434,19 @@ export function usePlayerVolume(opts: {
return { setVol, nudgeVolume, toggleMute, onWheel };
}
// How long a marker plays before an auto-skip's countdown starts (seconds), at most: on a marker
// too short for both, the wait gives way, so the countdown still ends inside it (`useAutoSkip`).
const AUTO_SKIP_WAIT_S = 3;
// What the countdown keeps in hand before the marker's end: it ticks on wall time, the playhead on
// the media's, and the marker stops counting a second before its end.
const AUTO_SKIP_SLACK_S = 0.5;
/** How far an auto-skip's countdown has run (0→1) after `elapsed` playing seconds of it. Through
* the wait `elapsed` is below zero, and the bar it draws stays empty: a negative width is invalid
* CSS, which the browser drops and keeps whatever it drew before. */
export const skipCountdown = (elapsed: number, delay: number) =>
Math.max(0, Math.min(1, elapsed / delay));
// Auto-skip intro/credits. Entering a marker whose auto-skip is on runs a countdown (skipProgress
// 0→1 over autoSkipDelay s, shown on the Skip button); on completion it skips — to the marker end, or
// for the credits that end the item to the next item (binge). Which credits those are, and which the
@@ -481,6 +494,7 @@ export function useAutoSkip(opts: {
(m) => abs >= m.start_s && abs < m.end_s - 1,
);
const activeMarkerRef = useLatest(activeMarker);
const absRef = useLatest(abs);
// A boolean, not the list, goes into the countdown's deps: a refetch hands back an equal-but-new
// list and must not restart a countdown under way (see below); doSkip reads the list from a ref.
const activeAuto = activeMarker != null && markerSkip(activeMarker, markers ?? []).auto;
@@ -538,14 +552,19 @@ export function useAutoSkip(opts: {
const enabled = (m.type === "intro" ? autoSkipIntro : autoSkipCredits) && activeAuto;
if (!enabled || suspended || cancelledMarkerRef.current === key) return setSkipProgress(null);
if (autoSkipDelay <= 0) return doSkip(m); // immediate
let elapsed = 0;
// Below zero is the wait: time to see where the marker has landed and press the button, which
// shows from the start, before anything counts down to leaving. A delay of 0 asked for none.
// It is cut to what the marker has left over the countdown (a short intro, or one resumed near
// its end): a wait the countdown cannot finish after is an auto-skip that never fires.
const left = m.end_s - 1 - absRef.current - autoSkipDelay - AUTO_SKIP_SLACK_S;
let elapsed = -Math.max(0, Math.min(AUTO_SKIP_WAIT_S, left));
setSkipProgress(0);
const iv = window.setInterval(() => {
// Freeze the countdown while the video is paused — otherwise it would fire and auto-seek a
// deliberately-paused video. It resumes from where it stopped when playback resumes.
if (videoRef.current?.paused) return;
elapsed += 0.1;
const p = Math.min(1, elapsed / autoSkipDelay);
const p = skipCountdown(elapsed, autoSkipDelay);
setSkipProgress(p);
if (p >= 1) {
window.clearInterval(iv);
@@ -568,6 +587,7 @@ export function useAutoSkip(opts: {
doSkip,
videoRef,
activeMarkerRef,
absRef,
activeAuto,
]);
@@ -0,0 +1,105 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { clear, events } from "./log";
import { retryLostStylesheets } from "./stylesheetRetry";
const LOADED = { cssRules: [{}] };
// A lost stylesheet still has a `sheet`, and reading its rules throws (measured: Chromium, WebKit).
const LOST = {
get cssRules(): never {
throw new DOMException("Cannot access rules", "SecurityError");
},
};
function link(href: string, loaded: boolean, sheet: unknown = loaded ? LOADED : LOST) {
const el = document.createElement("link");
el.rel = "stylesheet";
el.href = href;
el.setAttribute("crossorigin", "");
Object.defineProperty(el, "sheet", { value: sheet, configurable: true });
document.head.append(el);
return el;
}
const links = () => [...document.head.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')];
const logged = () => events().map((e) => e.event);
describe("retryLostStylesheets", () => {
beforeEach(() => {
vi.useFakeTimers();
clear();
});
afterEach(() => {
vi.useRealTimers();
document.head.innerHTML = "";
});
it("leaves a loaded stylesheet alone", () => {
const fine = link("/a.css", true);
retryLostStylesheets();
vi.runAllTimers();
expect(links()).toEqual([fine]);
expect(logged()).toEqual([]);
});
it("asks again for a lost one, in its place, keeping its attributes", () => {
const first = link("/a.css", true);
const lost = link("/b.css", false);
const last = link("/c.css", true);
retryLostStylesheets();
vi.advanceTimersByTime(0);
const [a, b, c] = links();
expect(a).toBe(first);
expect(b).not.toBe(lost);
expect(b!.getAttribute("href")).toBe("/b.css");
expect(b!.hasAttribute("crossorigin")).toBe(true);
expect(c).toBe(last);
expect(logged()).toEqual(["app.stylesheet_lost"]);
});
it.each([
["no sheet at all", null],
["an empty sheet", { cssRules: [] }],
])("takes a link with %s for lost too", (_, sheet) => {
const lost = link("/a.css", false, sheet);
retryLostStylesheets();
vi.advanceTimersByTime(0);
expect(links()[0]).not.toBe(lost);
});
it("says so once the new request has loaded", () => {
link("/a.css", false);
retryLostStylesheets();
vi.advanceTimersByTime(0);
links()[0]!.dispatchEvent(new Event("load"));
vi.runAllTimers();
expect(logged()).toEqual(["app.stylesheet_lost", "app.stylesheet_recovered"]);
});
it("waits longer each time and then gives up, reporting it", () => {
link("/a.css", false);
retryLostStylesheets();
const fail = () => links()[0]!.dispatchEvent(new Event("error"));
vi.advanceTimersByTime(0);
const second = links()[0];
fail();
vi.advanceTimersByTime(999);
expect(links()[0]).toBe(second);
vi.advanceTimersByTime(1);
const third = links()[0];
expect(third).not.toBe(second);
fail();
vi.advanceTimersByTime(2999);
expect(links()[0]).toBe(third);
vi.advanceTimersByTime(1);
const fourth = links()[0];
expect(fourth).not.toBe(third);
fail();
vi.runAllTimers();
expect(links()[0]).toBe(fourth);
expect(logged()).toEqual(["app.stylesheet_lost", "app.stylesheet_gave_up"]);
});
});
+50
View File
@@ -0,0 +1,50 @@
// A stylesheet can be lost on the way (a poor line, a loaded machine), and a browser never asks again
// for one by itself: the page then stays unstyled until a reload, every control a bare browser button.
// A module script waits for the document's stylesheets (measured: a sheet held 3 s held the module
// too), so by the time this runs each link has either loaded or failed, and no event is left to wait
// for. A lost one is asked for again in its own place, so the cascade's order stays as it was built.
//
// All of these sheets are this site's own: reading the rules of a loaded one is allowed, so a throw
// says it failed (measured: Chromium, WebKit). Three tries, then the page stays as it is. A reload here
// could loop, and the unstyled page still works.
import { log } from "./log";
const RETRY_AFTER_MS = [0, 1000, 3000];
function holdsRules(link: HTMLLinkElement): boolean {
try {
return (link.sheet?.cssRules.length ?? 0) > 0;
} catch {
return false;
}
}
function askAgain(link: HTMLLinkElement, attempt: number): void {
const wait = RETRY_AFTER_MS[attempt];
if (wait === undefined) {
log.error("app.stylesheet_gave_up", "a stylesheet could not be loaded", { href: link.href });
return;
}
window.setTimeout(() => {
const next = link.cloneNode(false) as HTMLLinkElement;
next.addEventListener("load", () =>
log.info("app.stylesheet_recovered", "a lost stylesheet loaded on a new request", {
href: next.href,
attempt: attempt + 1,
}),
);
next.addEventListener("error", () => askAgain(next, attempt + 1));
link.replaceWith(next);
}, wait);
}
/** Run once, from a page's entry module. */
export function retryLostStylesheets(): void {
for (const link of document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')) {
if (holdsRules(link)) continue;
log.warn("app.stylesheet_lost", "a stylesheet was lost on the way; asking again", {
href: link.href,
});
askAgain(link, 0);
}
}
@@ -21,9 +21,23 @@ describe("a subscription change", () => {
qk.feedSummary(),
qk.discoveredChannels(),
qk.watchedChannels(),
qk.tags(),
]);
});
// A tag counts the followed channels it is on, not hidden: a hide changes the number as an
// unsubscribe does, and both places that hide a channel must refresh it.
it("a hide refreshes the tags wherever it is made", () => {
const root = new URL("../", import.meta.url);
const sites = readdirSync(root, { recursive: true, encoding: "utf8" })
.map((f) => f.split("\\").join("/"))
.filter((f) => f.endsWith(".tsx") && !f.includes(".test.") && !f.startsWith("design-lab/"))
.map((f) => [f, readFileSync(new URL(f, root), "utf8")] as const)
.filter(([, src]) => /api\.updateChannel\([^)]*hidden/.test(src));
expect(sites.length).toBeGreaterThanOrEqual(2);
for (const [f, src] of sites) expect(src, f).toContain("queryKey: qk.tags()");
});
it("is refreshed through that one function wherever it is made", () => {
const root = new URL("../", import.meta.url);
const sites = readdirSync(root, { recursive: true, encoding: "utf8" })
+3 -2
View File
@@ -3,8 +3,8 @@ import type { QueryClient } from "@tanstack/react-query";
import { qk } from "./queryKeys";
/** Everything a subscribe or an unsubscribe changes: the channel itself, the manager's list and its
* counts, the feed and its facets (the channel's videos come or go), and the two discovery lists (a
* subscribed channel leaves them). ONE set for every site that makes the change: each used to keep
* counts, the feed and its facets (the channel's videos come or go), the two discovery lists (a
* subscribed channel leaves them), and the tags (a tag counts the followed channels it is on). ONE set for every site that makes the change: each used to keep
* its own list, and each list missed something. */
export function invalidateSubscription(qc: QueryClient, channelId: string): void {
for (const queryKey of [
@@ -15,6 +15,7 @@ export function invalidateSubscription(qc: QueryClient, channelId: string): void
qk.feedSummary(),
qk.discoveredChannels(),
qk.watchedChannels(),
qk.tags(),
])
void qc.invalidateQueries({ queryKey });
}
+5 -1
View File
@@ -28,7 +28,11 @@ export function useChannelTagActions(opts: {
? api.detachChannelTag(channelId, tagId)
: api.attachChannelTag(channelId, tagId);
call
.then(() => opts.onSuccess?.(channelId))
.then(() => {
// A tag counts the followed channels it is on: the toggle changed that number.
void qc.invalidateQueries({ queryKey: qk.tags() });
opts.onSuccess?.(channelId);
})
.catch((err: unknown) => {
notifyActionError(err, msg("channels.tags.updateFailed"));
opts.onError(channelId);
+1
View File
@@ -25,6 +25,7 @@ const tag = (id: number, name: string, system: boolean): Tag => ({
color: null,
category: "topic",
channel_count: 1,
subscribed_count: 1,
});
function wrapper({ children }: { children: ReactNode }) {
+3
View File
@@ -17,12 +17,15 @@ import "flag-icons/css/flag-icons.min.css";
import { lazyWithPreload } from "./lib/lazyWithPreload";
import { installClientLog } from "./lib/logHooks";
import { installStaleBuildReload } from "./lib/staleBuild";
import { retryLostStylesheets } from "./lib/stylesheetRetry";
import { appCopyOf } from "./i18n/appCopy";
// First, before any render: a crash or a navigation during boot belongs in the log too.
installClientLog(appCopyOf, typedUrlKeys());
// A tab from before the last release reloads onto the new build instead of crashing on a lazy chunk.
installStaleBuildReload();
// A stylesheet lost on the way is asked for again rather than leaving the page unstyled.
retryLostStylesheets();
// Split by top-level route so each entry point is its own chunk: a legal page never downloads the
// authenticated app bundle, and the app never carries it either. The legal pages live outside the
@@ -11,7 +11,8 @@
// The fit hook is mocked so `visible` is drivable: in jsdom every box measures 0, so the real
// measurement can only ever produce one answer.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { useState } from "react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -37,7 +38,7 @@ const chips = [chip("alpha"), chip("beta"), chip("gamma")];
// The surface mounts with `visibility: hidden` until floating-ui has positioned it, which never
// happens in jsdom (every box measures 0) — so it is out of the accessibility tree and only a
// `hidden: true` query can see it. Presence is the whole question here, not visibility.
const menu = () => screen.queryByRole("menu", { hidden: true });
const menu = () => screen.queryByRole("dialog", { hidden: true });
const moreBtn = () => screen.queryByRole("button", { name: /channels.tags.moreCount/ });
afterEach(() => {
@@ -100,3 +101,114 @@ describe("TagFilterChips — the overflow menu", () => {
expect(menu()).toBeNull();
});
});
describe("TagFilterChips counts", () => {
it("draws a chip's count after its name, and none when it has none", () => {
fit.visible = 2;
render(
<TagFilterChips
chips={[{ ...chip("alpha"), count: 42 }, { ...chip("beta"), count: 0 }, chip("gamma")]}
/>,
);
const name = (label: string) =>
screen.getAllByRole("button").find((b) => b.textContent?.startsWith(label));
expect(name("alpha")?.textContent).toBe("alpha · 42");
expect(name("beta")?.textContent).toBe("beta · 0");
// In the chip's own colour: a dimmed count falls under the text contrast floor.
expect(name("alpha")?.querySelector('[class*="opacity"]')).toBeNull();
});
it("draws nothing after the name of a chip that has no count", () => {
fit.visible = 3;
render(<TagFilterChips chips={[{ ...chip("alpha"), count: 42 }, chip("gamma")]} />);
const gamma = screen.getAllByRole("button").find((b) => b.textContent?.startsWith("gamma"));
expect(gamma?.textContent).toBe("gamma");
expect(gamma?.querySelector("span")).toBeNull();
});
});
// The channel manager puts the picked chips first, so a toggle moves its chip: out of the "+N" fold,
// into it, or out of the list. The keyboard's focus goes with the chip, not to <body>.
describe("TagFilterChips focus on a toggle that moves the chip", () => {
function Row({ start }: { start: string[] }) {
const [picked, setPicked] = useState(start);
const all = ["alpha", "beta", "gamma"].map((k) => ({
...chip(k),
active: picked.includes(k),
onToggle: () => setPicked((p) => (p.includes(k) ? p.filter((x) => x !== k) : [...p, k])),
}));
return (
<TagFilterChips chips={[...all.filter((c) => c.active), ...all.filter((c) => !c.active)]} />
);
}
const press = (el: HTMLElement) => {
el.focus();
fireEvent.click(el);
};
const named = (label: string) =>
screen.getAllByRole("button", { hidden: true }).find((b) => b.textContent === label)!;
// Along the row needs no test of its own: a keyed move within one parent keeps its node, and
// React gives the focus back to it after the commit. Only a remount (row ↔ fold) or a chip that
// leaves the list loses it.
it("out of the fold: picked in the popover, it is focused on the row", async () => {
fit.visible = 1;
render(<Row start={[]} />);
fireEvent.click(moreBtn()!);
const inFold = named("beta");
press(inFold);
// The closing popover hands the focus back to "+N" a microtask later: past that too.
await act(async () => {});
const onRow = named("beta");
expect(onRow).not.toBe(inFold);
expect(document.activeElement).toBe(onRow);
// …and the fold is shut: the focus is not left outside a popover still open.
expect(menu()).toBeNull();
expect(moreBtn()).toHaveProperty("ariaExpanded", "false");
});
// A stored pick of a tag that no longer exists is an active chip that an un-pick removes for good.
function WithGhost() {
const [ghost, setGhost] = useState(true);
const rest = ["alpha", "beta"].map(chip);
return (
<TagFilterChips
chips={
ghost
? [{ ...chip("ghost"), active: true, onToggle: () => setGhost(false) }, ...rest]
: rest
}
/>
);
}
it.each([
["with room for all", 3],
["with a fold", 1],
])("a chip that leaves the list hands the focus to the one now in its place (%s)", (_, n) => {
fit.visible = n;
render(<WithGhost />);
press(named("ghost"));
expect(document.activeElement).toBe(named("alpha"));
});
it("into a closed fold: the focus goes to the +N that now holds it", () => {
fit.visible = 1;
render(<Row start={["gamma"]} />);
press(named("gamma"));
expect(named("alpha").closest('[role="dialog"]')).toBeNull(); // alpha leads the row now
expect(document.activeElement).toBe(moreBtn());
});
it("a mouse press that leaves the focus elsewhere takes nothing", () => {
fit.visible = 3;
render(<Row start={[]} />);
const outside = document.body.appendChild(document.createElement("input"));
outside.focus();
fireEvent.click(named("gamma"));
act(() => {});
expect(document.activeElement).toBe(outside);
outside.remove();
});
});
@@ -1,4 +1,4 @@
import { useRef, useState } from "react";
import { useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { fitChipsWithPill } from "../../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../../lib/useChipOverflowFit";
@@ -19,6 +19,8 @@ import { ChipLabelPending } from "../../components/ui/Skeleton";
export interface FilterChip {
key: string;
label: string;
/** How many it holds, drawn muted after the name. */
count?: number;
/** The name is still on its way: the chip draws a placeholder, not `label` (a raw id). */
pending?: boolean;
active: boolean;
@@ -28,12 +30,40 @@ export interface FilterChip {
// A chip's text — the visible chip and the hidden measuring mirror both draw it, so the mirror
// measures what shows (ActiveFilterChips' ChipText, for this row's chips).
function ChipText({ chip }: { chip: FilterChip }) {
return chip.pending ? <ChipLabelPending value={chip.label} /> : <>{chip.label}</>;
if (chip.pending) return <ChipLabelPending value={chip.label} />;
return (
<>
{chip.label}
{/* The count in the chip's own colour, set off by a dot rather than dimmed: a dimmed count
falls under the text contrast floor, on a resting chip and on a pressed one. */}
{chip.count != null && (
<>
{" "}
<span aria-hidden="true">·</span> <span className="tabular-nums">{chip.count}</span>
</>
)}
</>
);
}
function TagChip({ chip }: { chip: FilterChip }) {
function TagChip({
chip,
onToggled,
}: {
chip: FilterChip;
onToggled: (key: string, from: HTMLElement) => void;
}) {
return (
<Chip onClick={chip.onToggle} pressed={chip.active}>
<Chip
data-chip-key={chip.key}
onClick={(e) => {
// Before the toggle: a pressed chip may move (the caller's order can put picks first), and
// the focus must go where it went.
if (e.currentTarget === document.activeElement) onToggled(chip.key, e.currentTarget);
chip.onToggle();
}}
pressed={chip.active}
>
<ChipText chip={chip} />
</Chip>
);
@@ -53,7 +83,7 @@ export default function TagFilterChips({
// Re-fit when the set, the labels, OR the pressed state change (the pressed paint can change a chip's
// width via the `.pill` recipe, so an aria-pressed flip must re-measure too).
const key = chips
.map((c) => `${c.key}:${c.label}:${c.active ? 1 : 0}${c.pending ? "…" : ""}`)
.map((c) => `${c.key}:${c.label}:${c.count ?? ""}:${c.active ? 1 : 0}${c.pending ? "…" : ""}`)
.join("|");
const visible = useChipOverflowFit<number>({
@@ -90,12 +120,54 @@ export default function TagFilterChips({
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "menu",
// A fold of toggle buttons, not a menu of items: each chip is a pressed/unpressed button in the
// Tab order, which a role=menu with no menu items would hide from a screen reader.
mode: "dialog",
role: "dialog",
placement: "bottom-end",
});
const { setReference } = pop.refs;
const moreLabel = t("channels.tags.moreCount", { count: hidden });
// A toggle may move its chip between the row and the "+N" fold (two parents: the button is
// remounted and the focus falls to <body>), or take it out of the list altogether (a stored pick of
// a tag that no longer exists). A move along the row keeps its node, and React gives it the focus
// back. The focus follows the chip: onto it where it now stands, onto "+N" when it went into a
// closed fold, or onto the chip now in its old place when it is gone. A toggle in the open fold
// closes it, so the focus never sits outside a popover left open. Held for the frame, since the fit
// re-decides in layout effects after the toggle's render.
const follow = useRef<{ key: string; at: number; closed: boolean } | null>(null);
const foldRef = useRef<HTMLDivElement | null>(null);
const moreRef = useRef<HTMLButtonElement | null>(null);
const onToggled = (chipKey: string, from: HTMLElement) => {
const closed = !!foldRef.current?.contains(from);
follow.current = { key: chipKey, at: chips.findIndex((c) => c.key === chipKey), closed };
requestAnimationFrame(() => (follow.current = null));
if (closed) setOpen(false);
};
useLayoutEffect(() => {
const target = follow.current;
if (!target) return;
const inBox = (box: HTMLElement | null) => [
...(box?.querySelectorAll<HTMLElement>("[data-chip-key]") ?? []),
];
const row = inBox(rowRef.current);
const find = (box: HTMLElement[]) => box.find((b) => b.dataset.chipKey === target.key);
const gone = !chips.some((c) => c.key === target.key);
const el =
find(row) ??
find(inBox(foldRef.current)) ??
(gone ? (row[Math.min(target.at, row.length - 1)] ?? moreRef.current) : moreRef.current);
if (el && document.activeElement !== el) el.focus();
// The closing popover gives the focus back to "+N" in a microtask it queued as it unmounted
// (seen in Chrome): this one, queued after it, has the last word.
if (el && target.closed) queueMicrotask(() => el.isConnected && el.focus());
});
const setMore = (el: HTMLButtonElement | null) => {
moreRef.current = el;
setReference(el);
};
return (
<div className={`${chipLaneClass} flex items-center gap-1.5 min-w-0 ${className ?? ""}`}>
{/* Hidden measurer: all chips + a sample "+N" pill at natural widths, so the shared fit core can
@@ -124,10 +196,10 @@ export default function TagFilterChips({
<div ref={rowRef} className="flex items-center gap-1.5 min-w-0 flex-1 overflow-hidden">
{chips.slice(0, visible).map((c) => (
<TagChip key={c.key} chip={c} />
<TagChip key={c.key} chip={c} onToggled={onToggled} />
))}
{hidden > 0 && (
<Chip ref={setReference} aria-label={moreLabel} {...pop.getReferenceProps()}>
<Chip ref={setMore} aria-label={moreLabel} {...pop.getReferenceProps()}>
+{hidden}
</Chip>
)}
@@ -138,9 +210,11 @@ export default function TagFilterChips({
ariaLabel={moreLabel}
className={`${SURFACE.popover} flex max-w-[22rem] flex-wrap items-center gap-1.5 p-2 animate-[popIn_0.16s_ease]`}
>
{chips.slice(visible).map((c) => (
<TagChip key={c.key} chip={c} />
))}
<div ref={foldRef} className="contents">
{chips.slice(visible).map((c) => (
<TagChip key={c.key} chip={c} onToggled={onToggled} />
))}
</div>
</PopoverSurface>
</div>
);
@@ -186,3 +186,58 @@ describe("buildChannelColumns — Added", () => {
expect(screen.getByText(formatDate("2026-10-12T08:00:00+00:00", "en"))).toBeTruthy();
});
});
describe("tagFilterChips", () => {
// An unsubscribe keeps a channel's tag links: the chip counts the followed ones, the rows the
// filter shows, not every link the tag has.
it("counts a tag's followed channels, and only a tag's", () => {
const tag = {
id: 3,
name: "Tech",
color: null,
category: "topic",
system: false,
channel_count: 17,
subscribed_count: 15,
};
const chips = tagFilterChips({
userTags: [tag],
tags: { isPending: false } as never,
picked: ["99"],
noTagOn: false,
toggle: () => {},
toggleNoTag: () => {},
t: ((k: string) => k) as never,
});
expect(chips.find((c) => c.label === "Tech")?.count).toBe(15);
expect(chips.find((c) => c.key === "t99")?.count).toBeUndefined();
expect(chips[chips.length - 1]?.count).toBeUndefined();
});
// The row folds from its end into "+N", so a picked tag late in the list was folded away: at 1024 px
// "music" sat behind "+11" with nothing on the row saying a filter was on. The picks lead the row.
it("puts the picked tags first, keeping their order, then the rest in theirs", () => {
const tag = (id: number, name: string) => ({ id, name, system: false }) as Tag;
const row = (picked: string[], noTagOn = false) =>
tagFilterChips({
userTags: [tag(1, "ai"), tag(2, "docu"), tag(3, "music"), tag(4, "nature")],
tags: { isPending: false } as never,
picked,
noTagOn,
toggle: () => {},
toggleNoTag: () => {},
t: ((k: string) => k) as never,
}).map((c) => c.label + (c.active ? "*" : ""));
expect(row(["4", "3"])).toEqual(["music*", "nature*", "ai", "docu", "channels.tags.noTag"]);
expect(row(["99", "3"])).toEqual([
"99*",
"music*",
"ai",
"docu",
"nature",
"channels.tags.noTag",
]);
expect(row([], true)).toEqual(["channels.tags.noTag*", "ai", "docu", "music", "nature"]);
expect(row([])).toEqual(["ai", "docu", "music", "nature", "channels.tags.noTag"]);
});
});
@@ -51,11 +51,12 @@ export function tagsColumnTest(c: ManagedChannel, values: string[]): boolean {
}
/** The manager's tag-filter chip row (the Tags column filter's values): any picked id that is not one
* of the user's tags, then each user tag, then the "No tag" sentinel. A stored pick can outlive its
* of the user's tags, then the picked ones of the user's tags and "No tag", then the rest, each group
* in the tags' order with "No tag" last. The row folds from its end into "+N", so every pick leads it:
* a filter that is on stays in sight, never only inside the fold. A stored pick can outlive its
* tag (deleted here or on another device) and still narrows the table to nothing, so it stays a
* visible, removable chip — FIRST, so the row's "+N" overflow never folds the reason away —
* and so does every pick while the tag list loads or after it failed, named by lib/tagPicks' rule
* (a placeholder, then the name, "Deleted tag" or the id), not hidden. */
* visible, removable chip, and so does every pick while the tag list loads or after it failed, named
* by lib/tagPicks' rule (a placeholder, then the name, "Deleted tag" or the id), not hidden. */
export function tagFilterChips(o: {
userTags: Tag[];
tags: TagList;
@@ -67,17 +68,11 @@ export function tagFilterChips(o: {
}): FilterChip[] {
const mine = new Set(o.userTags.map((tg) => String(tg.id)));
const others = o.picked.filter((id) => id !== NO_TAG_SENTINEL && !mine.has(id));
return [
...others.map((id) => ({
key: `t${id}`,
label: tagPickLabel(Number(id), o.tags, o.t),
pending: o.tags.isPending,
active: true,
onToggle: () => o.toggle(Number(id)),
})),
const known: FilterChip[] = [
...o.userTags.map((tg) => ({
key: `t${tg.id}`,
label: tg.name,
count: tg.subscribed_count,
active: o.picked.includes(String(tg.id)),
onToggle: () => o.toggle(tg.id),
})),
@@ -88,6 +83,17 @@ export function tagFilterChips(o: {
onToggle: o.toggleNoTag,
},
];
return [
...others.map((id) => ({
key: `t${id}`,
label: tagPickLabel(Number(id), o.tags, o.t),
pending: o.tags.isPending,
active: true,
onToggle: () => o.toggle(Number(id)),
})),
...known.filter((c) => c.active),
...known.filter((c) => !c.active),
];
}
/** Everything the Channels-manager column set closes over. The manager owns the state + mutations;
@@ -50,8 +50,10 @@ export function useChannelActions({
const nameOf = (c: ManagedChannel) =>
c.title == null ? msg("channels.row.thisChannel") : raw(c.title);
// A hide also changes the tags' counts: a tag counts only the channels the Subscriptions tab shows.
const refresh = () => {
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.tags() });
qc.invalidateQueries({ queryKey: qk.myStatus() });
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.feedSummary() });
@@ -36,6 +36,7 @@ const tag = (id: number, name: string): Tag => ({
category: "other",
system: false,
channel_count: 0,
subscribed_count: 0,
});
const docu = tag(1, "docu");
const history = tag(2, "history");
@@ -194,6 +194,8 @@ export default function ChannelPage({
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.channels() });
// A tag counts only the channels the Subscriptions tab shows, which a hide leaves out.
qc.invalidateQueries({ queryKey: qk.tags() });
},
});
+3 -2
View File
@@ -6,6 +6,7 @@ import { navItemAttrs } from "../../lib/listNav";
import { JobThumb } from "../../features/downloads/downloadRow";
import { jobTitle } from "../../features/downloads/downloadTitle";
import { PhoneBadge, RetentionBadge, SourceBadge } from "./fileBadges";
import { fileMeta } from "./fileMeta";
import { MediaCard, MediaPlayGlyph } from "../../components/ui/MediaCard";
import {
MEDIA_BADGE,
@@ -46,7 +47,7 @@ export function FileCard({
}) {
const { t } = useTranslation();
const title = jobTitle(file);
const channel = file.display_uploader || file.asset?.uploader || null;
const meta = fileMeta(file);
const clip = file.job_kind === "edit" || file.job_kind === "clip";
const media = file.asset?.media_kind !== "file";
const thumb = <JobThumb job={file} box="h-full w-full" />;
@@ -102,7 +103,7 @@ export function FileCard({
<span className="flex flex-col gap-1">
<span className="flex min-w-0 items-center gap-1.5 text-xs text-muted">
<SourceBadge file={file} />
{channel && <span className="truncate">{channel}</span>}
{meta && <span className="truncate">{meta}</span>}
</span>
<span className="flex flex-wrap items-center gap-1 empty:hidden">
<PhoneBadge file={file} />
+2 -2
View File
@@ -41,6 +41,7 @@ import { jobTitle } from "../../features/downloads/downloadTitle";
import { FileCard } from "./FileCard";
import { PhoneBadge, RetentionBadge, SourceBadge } from "./fileBadges";
import { folderLabel, takesUploads } from "../../features/folders/fileFolders";
import { fileMeta } from "./fileMeta";
import { ALL_FILES, useFiles, useFilesActions, viewParam, type FilesView } from "./filesContext";
import { useFileActions } from "./useFileActions";
import { useFolderActions } from "./useFolderActions";
@@ -157,7 +158,6 @@ function NameCell({
onRenamed: (name: string | null, byKey: boolean) => void;
}) {
const { t } = useTranslation();
const channel = file.display_uploader || file.asset?.uploader || null;
const clip = file.job_kind === "edit" || file.job_kind === "clip";
if (renaming)
return (
@@ -180,7 +180,7 @@ function NameCell({
<span title={jobTitle(file)}>{title}</span>
)}
<span className="flex min-w-0 items-center gap-1.5 text-xs text-muted">
<span className="truncate">{channel ?? "—"}</span>
<span className="truncate">{fileMeta(file) || "—"}</span>
{clip && (
<Badge>
<Scissors />
@@ -24,7 +24,9 @@ vi.mock("../../components/ui/Modal", () => ({
),
}));
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => h.confirm }));
vi.mock("../../lib/clipboard", () => ({ copyText: h.copyText }));
const h = vi.hoisted(() => ({
copyText: vi.fn(async (_t: string) => true),
confirm: false,
revokeDownloadLink: vi.fn(),
links: null as unknown[] | null,
@@ -72,6 +74,9 @@ afterEach(cleanup);
beforeEach(() => {
h.links = null;
h.confirm = false;
h.createDownloadLink.mockReset();
h.copyText.mockReset();
h.copyText.mockResolvedValue(true);
});
const iconClass = (button: HTMLElement) => button.querySelector("svg")?.getAttribute("class") ?? "";
@@ -96,6 +101,157 @@ describe("ShareDialog · a link row's state colours", () => {
});
});
describe("ShareDialog · opened to share", () => {
const open = () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<ShareDialog job={{ id: 9 } as DownloadJob} onClose={() => {}} />
</QueryClientProvider>,
);
};
const row = (id: number, url: string | null, expires_at: string | null = null) => ({
id,
token: "t",
url,
allow_download: true,
has_password: false,
expires_at,
view_count: 0,
created_at: null,
});
const origin = window.location.origin;
const status = () => screen.getByRole("status").textContent;
const lastWeek = new Date(Date.now() - 7 * 86400_000).toISOString();
const nextWeek = new Date(Date.now() + 7 * 86400_000).toISOString();
it("copies the newest link it can show, and says so", async () => {
h.links = [row(1, null), row(2, "/file/b"), row(3, "/file/c")];
open();
await waitFor(() => expect(h.copyText).toHaveBeenCalledWith(`${origin}/file/b`));
expect(h.copyText).toHaveBeenCalledTimes(1);
await waitFor(() => expect(status()).toBe("downloads.share.linkCopied"));
});
// The list keeps an expired link for its owner to see and revoke; the recipient of one lands on
// "link gone".
it("copies the newest link that still opens, and says an expired one has expired", async () => {
h.links = [row(1, "/file/old", lastWeek), row(2, "/file/b", nextWeek)];
open();
await waitFor(() => expect(h.copyText).toHaveBeenCalledWith(`${origin}/file/b`));
expect(h.copyText).toHaveBeenCalledTimes(1);
expect(screen.getByText("downloads.share.expiredOn", { exact: false })).toBeTruthy();
expect(screen.getByText("downloads.share.expiresOn", { exact: false })).toBeTruthy();
});
it("makes a link when every one it has expired, and copies that", async () => {
h.links = [row(1, "/file/old", lastWeek)];
h.createDownloadLink.mockResolvedValue(row(4, "/file/new"));
open();
await waitFor(() => expect(h.copyText).toHaveBeenCalledWith(`${origin}/file/new`));
expect(h.copyText).toHaveBeenCalledTimes(1);
});
// An expired link opens nothing: it is no link to hand out, and reads as dead at a glance.
it("offers no Copy on an expired link, and marks it with a warning", async () => {
h.links = [row(1, "/file/old", lastWeek), row(2, "/file/b", nextWeek)];
open();
await waitFor(() => expect(h.copyText).toHaveBeenCalledWith(`${origin}/file/b`));
expect(screen.getAllByRole("button", { name: "downloads.share.copy" })).toHaveLength(1);
expect(screen.getByText("downloads.share.expiredOn", { exact: false }).className).toContain(
"text-warn",
);
expect(screen.getAllByRole("link", { name: "downloads.share.openNewTab" })).toHaveLength(2);
});
// Closed before the link it made came back, the dialog must not open next time on the list it
// had and make another.
it("makes one link, closed before it came back and opened again", async () => {
h.links = [];
let made: (l: unknown) => void = () => {};
h.createDownloadLink.mockReturnValue(new Promise((res) => (made = res)));
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const dialog = () => (
<QueryClientProvider client={qc}>
<ShareDialog job={{ id: 9 } as DownloadJob} onClose={() => {}} />
</QueryClientProvider>
);
const first = render(dialog());
await waitFor(() => expect(h.createDownloadLink).toHaveBeenCalledTimes(1));
first.unmount();
made(row(4, "/file/new"));
await new Promise((r) => setTimeout(r, 20));
h.links = [row(4, "/file/new")];
render(dialog());
await screen.findByDisplayValue(`${origin}/file/new`);
await new Promise((r) => setTimeout(r, 20));
expect(h.createDownloadLink).toHaveBeenCalledTimes(1);
});
it("leaves a New link form the user opened while the first link was on its way", async () => {
h.links = [];
let made: (l: unknown) => void = () => {};
h.createDownloadLink.mockReturnValue(new Promise((res) => (made = res)));
open();
fireEvent.click(await screen.findByText("downloads.share.newLink"));
fireEvent.change(screen.getByPlaceholderText("downloads.share.password"), {
target: { value: "s3cret" },
});
h.links = [row(4, "/file/new")];
made(row(4, "/file/new"));
await screen.findByDisplayValue(`${origin}/file/new`);
expect(screen.getByPlaceholderText<HTMLInputElement>("downloads.share.password").value).toBe(
"s3cret",
);
});
// The clipboard holds what was copied last: the link copied on opening stops saying so.
it("stops claiming the opening copy once another link is copied", async () => {
h.links = [row(2, "/file/b"), row(3, "/file/c")];
open();
await waitFor(() => expect(status()).toBe("downloads.share.linkCopied"));
expect(screen.getAllByText("downloads.share.linkCopied")).toHaveLength(2); // the row + the region
fireEvent.click(screen.getAllByRole("button", { name: "downloads.share.copy" })[1]!);
await waitFor(() => expect(h.copyText).toHaveBeenLastCalledWith(`${origin}/file/c`));
expect(status()).toBe("");
expect(screen.queryByText("downloads.share.linkCopied")).toBeNull();
});
it("copies the link it made when there was none", async () => {
h.links = [];
h.createDownloadLink.mockResolvedValue(row(4, "/file/new"));
open();
await waitFor(() => expect(h.copyText).toHaveBeenCalledWith(`${origin}/file/new`));
});
it("claims nothing when the browser refused the copy", async () => {
h.links = [row(2, "/file/b")];
h.copyText.mockResolvedValue(false);
open();
await waitFor(() => expect(h.copyText).toHaveBeenCalled());
await screen.findByRole("button", { name: "downloads.share.copy" });
expect(status()).toBe("");
expect(screen.queryByText("downloads.share.linkCopied")).toBeNull();
});
it("offers the link in a new tab, beside Copy", async () => {
h.links = [row(2, "/file/b")];
open();
const openLink = await screen.findByRole("link", { name: "downloads.share.openNewTab" });
expect(openLink.getAttribute("href")).toBe(`${origin}/file/b`);
expect(openLink.getAttribute("target")).toBe("_blank");
expect(openLink.getAttribute("rel")).toContain("noopener");
});
it("has no Open for a link whose address cannot be shown", async () => {
h.links = [row(1, null)];
open();
await screen.findByText("downloads.share.urlHidden");
expect(screen.queryByRole("link", { name: "downloads.share.openNewTab" })).toBeNull();
expect(h.copyText).not.toHaveBeenCalled();
});
});
describe("ShareDialog · a link whose address cannot be shown again", () => {
it("says so in place of the address, with nothing to copy", async () => {
const link = (id: number, url: string | null) => ({
+104 -18
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { qk } from "../../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react";
import { Check, Copy, ExternalLink, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react";
import Modal from "../../components/ui/Modal";
import { useConfirm } from "../../components/ui/confirmContext";
import { msg, notify, raw } from "../../lib/notifications";
@@ -12,15 +12,20 @@ import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../.
import {
fetchLinks,
invalidateLinks,
linkExpired,
linksKey,
liveLink,
makeLink,
type LinkTarget,
} from "../../lib/downloads";
import { Button } from "../../components/ui/Button";
import { IconButton } from "../../components/ui/IconButton";
import { IconLink } from "../../components/ui/LinkButton";
import { copyText } from "../../lib/clipboard";
import { Input, InnerInput } from "../../components/ui/Input";
import { CheckRow } from "../../components/ui/NativeControls";
import { SURFACE } from "../../components/ui/surfaces";
import { Badge } from "../../components/ui/Badge";
import UserPicker from "./UserPicker";
import { useCopyFeedback } from "../../components/ui/useCopyFeedback";
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
@@ -75,7 +80,19 @@ function UserShare({ job }: { job: DownloadJob }) {
// --- share by public link -------------------------------------------------------------------
function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }) {
function LinkRow({
link,
copiedOnOpen,
onCopied,
onChanged,
}: {
link: ShareLink;
/** The dialog put this link on the clipboard when it opened, and nothing was copied since. */
copiedOnOpen: boolean;
/** Its Copy was pressed: the clipboard holds this link now, not the one the dialog copied. */
onCopied: () => void;
onChanged: () => void;
}) {
const { t, i18n } = useTranslation();
const confirm = useConfirm();
const { copied, copy: copyValue } = useCopyFeedback();
@@ -84,7 +101,9 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
const fullUrl = link.url && window.location.origin + link.url;
const copy = () => {
if (fullUrl) void copyValue(fullUrl);
if (!fullUrl) return;
onCopied();
void copyValue(fullUrl);
};
const toggle = useMutation({
mutationFn: () => api.updateDownloadLink(link.id, { allow_download: !link.allow_download }),
@@ -108,10 +127,13 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
},
});
// An expired link opens nothing, so it is no link to hand out: no Copy, and it reads as dead at a
// glance (a warn badge, its address struck through). Open stays, to check it; Revoke, to clear it.
const expired = linkExpired(link);
const badges: string[] = [];
if (link.has_password) badges.push(t("downloads.share.hasPassword"));
if (link.allow_download) badges.push(t("downloads.share.downloadable"));
if (link.expires_at)
if (link.expires_at && !expired)
badges.push(
t("downloads.share.expiresOn", { date: formatDate(link.expires_at, i18n.language) }),
);
@@ -122,10 +144,33 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
<Link2 className="w-4 h-4 text-muted shrink-0" />
{fullUrl ? (
<>
<InnerInput className="text-xs text-muted truncate" readOnly value={fullUrl} />
<IconButton label={t("downloads.share.copy")} size="sm" variant="ghost" onClick={copy}>
{copied ? <Check className="w-4 h-4 text-success" /> : <Copy className="w-4 h-4" />}
</IconButton>
<InnerInput
className={`text-xs text-muted truncate ${expired ? "line-through" : ""}`}
readOnly
value={fullUrl}
/>
{!expired && (
<IconButton
label={t("downloads.share.copy")}
size="sm"
variant="ghost"
onClick={copy}
>
{copied || copiedOnOpen ? (
<Check className="w-4 h-4 text-success" />
) : (
<Copy className="w-4 h-4" />
)}
</IconButton>
)}
<IconLink
label={t("downloads.share.openNewTab")}
href={fullUrl}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="w-4 h-4" />
</IconLink>
</>
) : (
// The server keeps no copy of this link's address it can read: the link still opens for
@@ -175,10 +220,18 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
<Trash2 className="w-4 h-4 text-danger" />
</IconButton>
</div>
{/* Seen here, heard from the dialog's own live region (`LinkShare`): one mounted already
filled may never be announced. */}
{copiedOnOpen && <p className="text-xs text-success">{t("downloads.share.linkCopied")}</p>}
<div className="flex items-center gap-2 text-xs text-muted flex-wrap">
<span className="inline-flex items-center gap-1">
<Eye className="w-3 h-3" /> {t("downloads.share.views", { count: link.view_count })}
</span>
{expired && link.expires_at && (
<Badge tone="warn">
{t("downloads.share.expiredOn", { date: formatDate(link.expires_at, i18n.language) })}
</Badge>
)}
{badges.map((b) => (
<span
key={b}
@@ -243,9 +296,18 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
);
}
/** Puts a link on the clipboard for the dialog that just opened, and answers whether it got there:
* a browser may refuse a copy no click asked for, and then nothing may claim it. */
async function copiedLink(link: ShareLink | undefined): Promise<number | null> {
if (!link?.url) return null;
return (await copyText(window.location.origin + link.url)) ? link.id : null;
}
function LinkShare({ target }: { target: LinkTarget }) {
const { t } = useTranslation();
const qc = useQueryClient();
// The link the dialog copied when it opened: sharing is almost always why it was opened.
const [copiedOnOpen, setCopiedOnOpen] = useState<number | null>(null);
const links = useQuery({
queryKey: linksKey(target),
queryFn: () => fetchLinks(target),
@@ -265,19 +327,29 @@ function LinkShare({ target }: { target: LinkTarget }) {
expires_days: expiryDays,
password: password.trim() || undefined,
}),
onSuccess: () => {
// In the list at once, as the one-click Share puts it: closed before the refetch, the dialog
// would open next time on the cached list without it and make another.
onSuccess: (made) => {
qc.setQueryData<ShareLink[]>(linksKey(target), (old) => [made, ...(old ?? [])]);
invalidate();
setCreating(false);
// Back to the form's own default for the next link: downloadable.
setAllowDownload(true);
setExpiryDays(null);
setPassword("");
},
meta: { failTitle: "downloads.share.linkFailed" },
});
// The form's own success, not the mutation's: the link made on open goes through the same
// mutation, and must not close and clear a form the user opened while it was on its way.
const createFromForm = () =>
create.mutate(undefined, {
onSuccess: () => {
setCreating(false);
// Back to the form's own default for the next link: downloadable.
setAllowDownload(true);
setExpiryDays(null);
setPassword("");
},
});
// Open with a link already generated: sharing is almost always the goal, so mint a default
// (no password, no expiry, downloadable) link the moment the dialog finds none — as if the
// (no password, no expiry, downloadable) link the moment the dialog finds none that opens — as if the
// user had pressed "New link" → "Create". All further controls (password, download toggle, expiry,
// extra links, revoke) stay available on the created link and via the "New link" button.
const autoTried = useRef(false);
@@ -290,11 +362,19 @@ function LinkShare({ target }: { target: LinkTarget }) {
useEffect(() => {
if (!links.data || autoTried.current) return;
autoTried.current = true;
if (links.data.length === 0) createLink();
// The link to hand out is one that still opens (`liveLink`, as the one-click Share picks it):
// with only expired ones, or none whose address can be shown, a new one is made as for none.
const live = liveLink(links.data);
if (!live)
createLink(undefined, { onSuccess: (made) => void copiedLink(made).then(setCopiedOnOpen) });
else void copiedLink(live).then(setCopiedOnOpen);
}, [links.data, createLink]);
return (
<div>
<p role="status" className="sr-only">
{copiedOnOpen !== null ? t("downloads.share.linkCopied") : ""}
</p>
<div className="flex items-center gap-2 text-sm font-medium mb-1">
<Link2 className="w-4 h-4 text-muted" /> {t("downloads.share.linkSection")}
</div>
@@ -317,7 +397,13 @@ function LinkShare({ target }: { target: LinkTarget }) {
) : (
<div className="space-y-2">
{links.data.map((l) => (
<LinkRow key={l.id} link={l} onChanged={invalidate} />
<LinkRow
key={l.id}
link={l}
copiedOnOpen={copiedOnOpen === l.id}
onCopied={() => setCopiedOnOpen(null)}
onChanged={invalidate}
/>
))}
{links.data.length === 0 && !creating && !create.isPending && (
<p className="text-xs text-muted">{t("downloads.share.noLinks")}</p>
@@ -361,7 +447,7 @@ function LinkShare({ target }: { target: LinkTarget }) {
<Button
variant="primary"
size="sm"
onClick={() => create.mutate()}
onClick={createFromForm}
disabled={create.isPending}
>
{t("downloads.share.createLink")}
@@ -19,6 +19,8 @@ const h = vi.hoisted(() => ({
uploadStarted: vi.fn(),
uploadEnded: vi.fn(),
openFile: vi.fn(),
fileFolders: vi.fn(),
createFolder: vi.fn(),
}));
vi.mock("react-i18next", async (importOriginal) => {
@@ -29,7 +31,12 @@ vi.mock("react-i18next", async (importOriginal) => {
};
});
vi.mock("../../lib/api", () => ({
api: { uploadFile: h.uploadFile, filesUsage: h.filesUsage },
api: {
uploadFile: h.uploadFile,
filesUsage: h.filesUsage,
fileFolders: h.fileFolders,
createFolder: h.createFolder,
},
}));
vi.mock("../../lib/downloads", () => ({ invalidateDownloads: vi.fn() }));
// The rest of the module stays real: the dialog is a ui/Modal, which hosts its own toasts.
@@ -47,12 +54,29 @@ import UploadDialog from "./UploadDialog";
import { FilesActionsContext } from "./filesContext";
import { msg, type NotifText } from "../../lib/notifications";
const folder = (id: number, kind: string, source_key: string | null, name: string | null) => ({
id,
kind,
source_key,
name,
retention_days: null,
count: 0,
size_bytes: 0,
created_at: null,
has_link: false,
});
const FOLDERS = [
folder(1, "source", "upload", null),
folder(5, "own", null, "peat"),
folder(9, "source", "site:reddit.com", null),
];
const wrap = (
onClose = vi.fn(),
onClose: () => void = vi.fn(),
folderId: number | null = null,
initialFile: File | null = null,
) => {
const qc = new QueryClient();
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const files = {
setSearch: vi.fn(),
setView: vi.fn(),
@@ -78,9 +102,207 @@ beforeEach(() => {
h.notify.mockClear();
h.filesUsage.mockClear();
h.filesUsage.mockResolvedValue({ upload_max_bytes: 1_000_000 });
h.createFolder.mockReset();
h.fileFolders.mockReset();
h.fileFolders.mockResolvedValue(FOLDERS);
});
afterEach(cleanup);
describe("UploadDialog · where the file goes", () => {
const pick = () =>
fireEvent.change(fileInput(), { target: { files: [new File(["x"], "a.pdf")] } });
const target = () => screen.findByTestId("upload-target");
const sentTo = async () => {
fireEvent.click(screen.getByText("downloads.upload.submit"));
await waitFor(() => expect(h.uploadFile).toHaveBeenCalledTimes(1));
return (h.uploadFile.mock.calls[0]![1] as { folderId: number | null }).folderId;
};
it("says the folder it was opened on", async () => {
wrap(vi.fn(), 5);
expect((await target()).textContent).toContain("peat");
});
it("says Uploads when it was opened on no folder, and sends there", async () => {
wrap();
expect((await target()).textContent).toContain("files.source.upload");
pick();
expect(await sentTo()).toBeNull();
});
it("says Uploads before the user has a folder of that name yet", async () => {
h.fileFolders.mockResolvedValue([FOLDERS[1]]);
wrap();
expect((await target()).textContent).toContain("files.source.upload");
});
it("offers only the folders that take an upload", async () => {
wrap();
fireEvent.click(await target());
expect(await screen.findByTestId("upload-target-5")).toBeTruthy();
expect(screen.getByTestId("upload-target-1")).toBeTruthy();
expect(screen.queryByTestId("upload-target-9")).toBeNull(); // a site's folder claims an origin
});
it("sends to the folder picked", async () => {
wrap();
fireEvent.click(await target());
fireEvent.click(await screen.findByTestId("upload-target-5"));
pick();
expect(await sentTo()).toBe(5);
});
it("makes a new folder from the same list and sends there", async () => {
h.createFolder.mockResolvedValue({ ...FOLDERS[1], id: 7, name: "New one" });
wrap();
fireEvent.click(await target());
fireEvent.click(await screen.findByTestId("upload-target-new"));
const field = await screen.findByLabelText("files.folder.newName");
fireEvent.change(field, { target: { value: "New one" } });
fireEvent.keyDown(field, { key: "Enter" });
await waitFor(() => expect(h.createFolder).toHaveBeenCalledWith("New one"));
await waitFor(async () => expect((await target()).textContent).toContain("New one"));
pick();
expect(await sentTo()).toBe(7);
});
it("will not upload while a new folder is on its way, to the wrong place", async () => {
let made: (f: unknown) => void = () => {};
h.createFolder.mockReturnValue(new Promise((res) => (made = res)));
wrap();
pick();
fireEvent.click(await target());
fireEvent.click(await screen.findByTestId("upload-target-new"));
const field = await screen.findByLabelText("files.folder.newName");
const submit = screen.getByText("downloads.upload.submit").closest("button")!;
expect(submit.disabled || submit.getAttribute("aria-disabled") === "true").toBe(true);
fireEvent.change(field, { target: { value: "New one" } });
fireEvent.keyDown(field, { key: "Enter" });
await waitFor(() => expect(h.createFolder).toHaveBeenCalled());
fireEvent.click(submit);
expect(h.uploadFile).not.toHaveBeenCalled();
made({ ...FOLDERS[1], id: 7, name: "New one" });
await waitFor(() => expect(submit.disabled).toBe(false));
expect(await sentTo()).toBe(7);
});
// The name field saves on blur, and a press blurs it before the press acts: one that closes the
// dialog (Cancel, the ✕, the backdrop) makes no folder; one that leaves it open keeps the name.
const naming = async (onClose: () => void) => {
const r = wrap(onClose);
fireEvent.click(await target());
fireEvent.click(await screen.findByTestId("upload-target-new"));
const field = await screen.findByLabelText("files.folder.newName");
fireEvent.change(field, { target: { value: "Trips" } });
return { r, field };
};
const pressOn = (el: Element, field: HTMLElement, click = true) => {
fireEvent.pointerDown(el);
fireEvent.mouseDown(el);
fireEvent.blur(field);
fireEvent.pointerUp(el);
fireEvent.mouseUp(el);
if (click) fireEvent.click(el);
};
const settled = () => new Promise((r) => setTimeout(r, 20)); // a mutation calls its function a tick later
it.each([
["Cancel", () => screen.getByText("downloads.actions.cancel")],
["the close button", () => screen.getByRole("button", { name: "common.close" })],
["the backdrop", () => screen.getByRole("dialog")],
])("dismissing with %s while naming makes no folder", async (_n, dismiss) => {
let unmount = () => {};
const { r, field } = await naming(() => unmount());
unmount = r.unmount;
pressOn(dismiss(), field);
await settled();
expect(screen.queryByRole("dialog")).toBeNull();
expect(h.createFolder).not.toHaveBeenCalled();
});
// A right-button press on the backdrop blurs the field but closes nothing: the name stands.
it("a press that closes nothing makes the folder it left", async () => {
h.createFolder.mockResolvedValue({ ...FOLDERS[1], id: 7, name: "Trips" });
const { field } = await naming(vi.fn());
pressOn(screen.getByRole("dialog"), field, false);
await waitFor(() => expect(h.createFolder).toHaveBeenCalledWith("Trips"));
});
it("leaving the name for anything else in the dialog makes the folder", async () => {
h.createFolder.mockResolvedValue({ ...FOLDERS[1], id: 7, name: "Trips" });
const { field } = await naming(vi.fn());
pressOn(screen.getByText("downloads.upload.targetLabel"), field);
await waitFor(() => expect(h.createFolder).toHaveBeenCalledWith("Trips"));
});
it("leaving the name by Tab makes the folder at once", async () => {
h.createFolder.mockResolvedValue({ ...FOLDERS[1], id: 7, name: "Trips" });
const { field } = await naming(vi.fn());
fireEvent.blur(field);
await waitFor(() => expect(h.createFolder).toHaveBeenCalledWith("Trips"));
});
it("says a new folder is being made, then gives the keyboard back the list", async () => {
let made: (f: unknown) => void = () => {};
h.createFolder.mockReturnValue(new Promise((res) => (made = res)));
wrap();
fireEvent.click(await target());
fireEvent.click(await screen.findByTestId("upload-target-new"));
const field = await screen.findByLabelText("files.folder.newName");
fireEvent.change(field, { target: { value: "Trips" } });
fireEvent.keyDown(field, { key: "Enter" });
const making = await screen.findByText("downloads.upload.makingFolder");
// Said in a live region, and the keyboard is held on its place, not dropped to <body>.
expect(making.closest('[role="status"]')).toBeTruthy();
await waitFor(() => expect(document.activeElement).toBe(making.parentElement));
made({ ...FOLDERS[1], id: 7, name: "Trips" });
await waitFor(async () => expect(document.activeElement).toBe(await target()));
});
// A failed refetch keeps the list it had: the target the file goes to stays shown.
it("keeps the target when a later read of the folders fails", async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<FilesActionsContext.Provider
value={{
setSearch: vi.fn(),
setView: vi.fn(),
openFile: h.openFile,
consumeFocus: vi.fn(),
}}
>
<UploadDialog folderId={5} initialFile={null} onClose={vi.fn()} />
</FilesActionsContext.Provider>
</QueryClientProvider>,
);
expect((await target()).textContent).toContain("peat");
h.fileFolders.mockRejectedValue(new Error("down"));
await qc.refetchQueries({ queryKey: ["file-folders"] }).catch(() => {});
await waitFor(() => expect(qc.getQueryState(["file-folders"])?.status).toBe("error"));
expect((await target()).textContent).toContain("peat");
expect(screen.queryByText("downloads.upload.targetFailed")).toBeNull();
});
it("an empty name makes no folder and leaves the target", async () => {
wrap(vi.fn(), 5);
fireEvent.click(await target());
fireEvent.click(await screen.findByTestId("upload-target-new"));
fireEvent.keyDown(await screen.findByLabelText("files.folder.newName"), { key: "Enter" });
expect((await target()).textContent).toContain("peat");
expect(h.createFolder).not.toHaveBeenCalled();
});
it("says when the folders could not be read, and offers Retry", async () => {
h.fileFolders.mockRejectedValue(new Error("down"));
wrap();
expect(await screen.findByText("downloads.upload.targetFailed")).toBeTruthy();
h.fileFolders.mockResolvedValue(FOLDERS);
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
expect((await target()).textContent).toContain("files.source.upload");
});
});
describe("UploadDialog", () => {
it("shows the picked file's name and uploads it with the optional name", async () => {
const onClose = vi.fn();
+140 -5
View File
@@ -1,10 +1,10 @@
import { useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { UploadCloud, File as FileIcon } from "lucide-react";
import Modal, { ModalFooter } from "../../components/ui/Modal";
import { Button } from "../../components/ui/Button";
import { api } from "../../lib/api";
import { api, type FileFolder } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { isAborted } from "../../lib/api/core";
import { notifyActionError } from "../../lib/actionErrors";
@@ -17,6 +17,10 @@ import { formatBytes } from "../../lib/format";
import { DISPLAY_NAME_MAX } from "../../lib/api/downloads";
import { DropZone } from "../../components/ui/DropZone";
import { Input } from "../../components/ui/Input";
import FilterSelect from "../../components/ui/FilterSelect";
import { RenameField } from "../../components/ui/RenameField";
import { StateMessage } from "../../components/ui/QueryState";
import { folderLabel, takesUploads } from "../../features/folders/fileFolders";
// Local-file ingest. A general upload — ANY file type, no format list: media
// (video/audio) joins the library with the full function set, a generic file is download-only. The
@@ -30,6 +34,12 @@ const uploadedKey = (status: string) =>
status === "done" ? "downloads.upload.added" : "downloads.upload.addedConverting";
// The job the upload's own notice announces (a done one), for the queue not to say it again.
const announces = (job: { id: number; status: string }) => (job.status === "done" ? job.id : null);
// What stands in the target list for Uploads before the user has that folder (the first upload makes
// it), and for the row that makes a folder.
const UPLOADS = "uploads";
const NEW_FOLDER = "new";
const isUploads = (f: FileFolder) => f.kind === "source" && f.source_key === "upload";
export default function UploadDialog({
folderId = null,
initialFile = null,
@@ -65,6 +75,82 @@ export default function UploadDialog({
// same reason, and `formAlerts.dom.test.tsx` is the test that exists because of it.
const [pickSeq, setPickSeq] = useState(0);
const { openFile } = useFilesActions();
// Where it goes: the folder the dialog was opened on, until the user says otherwise. Null = Uploads.
const [target, setTarget] = useState(folderId);
const foldersQ = useQuery({ queryKey: qk.fileFolders(), queryFn: api.fileFolders });
const [naming, setNaming] = useState(false);
const targetBox = useRef<HTMLDivElement>(null);
const refocusTarget = useRef(false);
// The name field saves on blur, and a press blurs it before the press does anything: a press on
// Cancel, the ✕ or the backdrop that then closes the dialog is "never mind", not "make it". So a
// name left by a press is made once the press is over, and only if the dialog is still here — its
// close unmounts this and drops the wait. A name left without a press (Tab) is made at once.
const pressing = useRef(false);
const afterPress = useRef<(() => void) | null>(null);
useEffect(() => {
let wait = 0;
const down = () => {
pressing.current = true;
};
const up = () => {
pressing.current = false;
const run = afterPress.current;
afterPress.current = null;
// After the click the press ends in, where a dismissal closes the dialog.
if (run) wait = window.setTimeout(run);
};
document.addEventListener("pointerdown", down, true);
document.addEventListener("pointerup", up, true);
document.addEventListener("pointercancel", up, true);
return () => {
document.removeEventListener("pointerdown", down, true);
document.removeEventListener("pointerup", up, true);
document.removeEventListener("pointercancel", up, true);
window.clearTimeout(wait);
afterPress.current = null;
};
}, []);
const makeFolder = useMutation({
mutationFn: (folder: string) => api.createFolder(folder),
onSuccess: (made) => {
// In the list at once, so the target reads its name rather than waiting for the refetch.
qc.setQueryData<FileFolder[]>(qk.fileFolders(), (old) => [...(old ?? []), made]);
invalidateDownloads(qc);
setTarget(made.id);
},
meta: { failTitle: "errors.writeFailed.folder.create" },
});
// A new folder's name ended by a key leaves the keyboard's focus nowhere: back on the list once it
// is back, as the folder is made (or fails). Until then on its place, which says it is being made.
useEffect(() => {
if (naming || !refocusTarget.current) return;
const box = targetBox.current;
const list = box?.querySelector("button");
if (!list) return box?.focus();
refocusTarget.current = false;
list.focus();
}, [naming, makeFolder.isPending]);
// Not while a folder is being named or made: the file would go to the last target, not that one.
const targetSettling = naming || makeFolder.isPending;
const targets = (foldersQ.data ?? []).filter(takesUploads);
const uploadsRow = targets.find(isUploads);
const targetOptions = [
...(uploadsRow ? [] : [{ value: UPLOADS, label: t("files.source.upload") }]),
...[...targets]
.sort((a, b) => Number(isUploads(b)) - Number(isUploads(a)))
.map((f) => ({ value: String(f.id), label: folderLabel(f, t) })),
{ value: NEW_FOLDER, label: t("files.folder.new") },
];
const targetValue = String(target ?? uploadsRow?.id ?? UPLOADS);
const pickTarget = ([value]: string[]) => {
// After the menu has closed: it hands the focus back as it goes, which took it off a field already
// there, and a name field that loses the focus is done, with no name.
if (value === NEW_FOLDER) {
window.setTimeout(() => setNaming(true));
} else if (value === UPLOADS) setTarget(null);
else setTarget(Number(value));
};
// A done file is in Files, opened on it; one the worker still converts is in the tray until it is.
const openUploaded = (job: DownloadJob) => {
if (job.status === "done") openFile(job.id, job.folder_id ?? null);
@@ -86,7 +172,7 @@ export default function UploadDialog({
// button holds focus it swallows the activation itself, and while it does not it is natively
// disabled. Measured — deleting this term breaks nothing, which is exactly why it must not be
// described as the guard. It is here for the next caller of `submit`, not for the only one.
if (!file || busy || tooBig) return;
if (!file || busy || tooBig || targetSettling) return;
const ac = new AbortController();
abortRef.current = ac;
setPct(0);
@@ -95,7 +181,7 @@ export default function UploadDialog({
try {
const job = await api.uploadFile(file, {
displayName,
folderId,
folderId: target,
onProgress: setPct,
signal: ac.signal,
});
@@ -182,6 +268,55 @@ export default function UploadDialog({
maxLength={DISPLAY_NAME_MAX}
/>
<p className="block text-sm font-medium mt-4 mb-1">{t("downloads.upload.targetLabel")}</p>
{foldersQ.isError && !foldersQ.data ? (
// The file still goes where the dialog was opened on: only the list could not be read. A
// failed REFETCH keeps the list it had (react-query sets isError beside the data): the
// target the file goes to stays shown, as the rail does it.
<StateMessage tone="error" compact onRetry={() => void foldersQ.refetch()}>
{t("downloads.upload.targetFailed")}
</StateMessage>
) : naming ? (
<RenameField
initial=""
label={t("files.folder.newName")}
onDone={(folder, byKey) => {
refocusTarget.current = byKey;
setNaming(false);
const name = folder?.trim();
if (!name) return;
if (!byKey && pressing.current) afterPress.current = () => makeFolder.mutate(name);
else makeFolder.mutate(name);
}}
/>
) : (
// The select's own height held while the list loads, so it does not push the form down.
// It holds the keyboard's focus while a folder is made (tabIndex -1: never a Tab stop), and
// says it in a live region, as the list it replaces is gone until then.
<div ref={targetBox} tabIndex={-1} className="min-h-8 flex items-center outline-none">
<span role="status" className="text-sm text-muted">
{/* Upload waits for it (`targetSettling`): this says why. */}
{makeFolder.isPending &&
t("downloads.upload.makingFolder", { name: makeFolder.variables })}
</span>
{foldersQ.data && !busy && !makeFolder.isPending && (
<FilterSelect
hideLabel
label={t("downloads.upload.targetLabel")}
options={targetOptions}
selected={[targetValue]}
onChange={pickTarget}
testId="upload-target"
/>
)}
{busy && (
<span className="text-sm text-muted">
{targetOptions.find((o) => o.value === targetValue)?.label}
</span>
)}
</div>
)}
{busy && (
<div className="mt-5" aria-live="polite">
<div
@@ -223,7 +358,7 @@ export default function UploadDialog({
variant="primary"
size="sm"
onClick={submit}
disabled={!file || busy || tooBig}
disabled={!file || busy || tooBig || targetSettling}
aria-describedby={tooBig ? refusalId : undefined}
>
{t("downloads.upload.submit")}
@@ -0,0 +1,60 @@
// @vitest-environment jsdom
//
// A crop's numbers count the picture as the browser reports it, its shown size, which differs from the
// file's own on a video with wide pixels. The size goes along, or the server cuts the wrong part.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
import VideoEditor from "./VideoEditor";
import { api, type DownloadJob } from "../../lib/api";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
// Wide pixels: stored 960 across, shown 1920 — the browser reports the shown size, the asset the
// stored one, and the two must not be mistaken for each other.
const job = {
id: 7,
display_name: "Talk",
asset: { duration_s: 100, width: 960, height: 1080, title: "Talk" },
} as unknown as DownloadJob;
describe("VideoEditor crop", () => {
it("sends the size the crop was measured on with it", async () => {
vi.spyOn(HTMLVideoElement.prototype, "videoWidth", "get").mockReturnValue(1920);
vi.spyOn(HTMLVideoElement.prototype, "videoHeight", "get").mockReturnValue(1080);
const enqueue = vi
.spyOn(api, "enqueueEdit")
.mockResolvedValue({ id: 8 } as unknown as DownloadJob);
render(
<QueryClientProvider client={new QueryClient()}>
<VideoEditor job={job} onClose={() => {}} />
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "editor.cropOff" }));
fireEvent.click(screen.getByRole("button", { name: "editor.create" }));
await waitFor(() => expect(enqueue).toHaveBeenCalledTimes(1));
const crop = enqueue.mock.calls[0]?.[0].edit_spec.crop;
expect(crop).toMatchObject({ frame_w: 1920, frame_h: 1080, w: 1536, h: 864 });
});
});
+4 -2
View File
@@ -226,14 +226,16 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const willJoin = output === "join" && kept.length > 1;
const reencode = cropOn || accurate;
const cropPx = (): { x: number; y: number; w: number; h: number } | undefined => {
const cropPx = (): EditSpec["crop"] => {
const v = videoRef.current;
const vw = v?.videoWidth || vdim.w;
const vh = v?.videoHeight || vdim.h;
if (!cropOn) return undefined;
// crop fractions are relative to the picture (the overlay is anchored to it), so this is a
// straight fraction→source-px scale; see cropGeometry for the even-rounding + in-bounds clamp.
return cropToSourcePx(crop, vw, vh) ?? undefined;
const px = cropToSourcePx(crop, vw, vh);
// The size they count in goes along: it is the shown one, and the file's own can differ.
return px ? { ...px, frame_w: vw, frame_h: vh } : undefined;
};
const create = useMutation({
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { fileMeta } from "./fileMeta";
import type { LibraryFile } from "../../lib/api";
const file = (uploader: string | null, container: string | null) =>
({ display_uploader: uploader, asset: { uploader: null, container } }) as unknown as LibraryFile;
describe("fileMeta", () => {
it("names who made it and its format", () => {
expect(fileMeta(file("Someone", "mp4"))).toBe("Someone · MP4");
});
it("has the format alone when nobody is named", () => {
expect(fileMeta(file(null, "gz"))).toBe("GZ");
});
it("has the name alone when no format is known", () => {
expect(fileMeta(file("Someone", null))).toBe("Someone");
});
it("is empty when neither is known", () => {
expect(fileMeta(file(null, null))).toBe("");
});
it("reads the file's own uploader when none was typed", () => {
const f = { display_uploader: null, asset: { uploader: "Site", container: "webm" } };
expect(fileMeta(f as unknown as LibraryFile)).toBe("Site · WEBM");
});
});
+8
View File
@@ -0,0 +1,8 @@
import type { LibraryFile } from "../../lib/api";
/** A file's line under its name, in the table and on its card: who made it and its format. */
export function fileMeta(file: LibraryFile): string {
const who = file.display_uploader || file.asset?.uploader;
const format = file.asset?.container?.toUpperCase();
return [who, format].filter(Boolean).join(" · ");
}
@@ -0,0 +1,148 @@
// @vitest-environment jsdom
//
// Discover's See all opens the list its rail is the start of, sorted the way the rail is: Recently
// added newest first, Continue watching in progress by last watched. The rails only call back; what
// each opens is PlexBrowse's, against the Plex store's show and sort, which outlive the Discover /
// Browse toggle — so a sort picked earlier must not survive the press.
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
import { render, screen, cleanup, fireEvent, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("./plexSavedViews", async (importOriginal) => ({
...(await importOriginal<typeof import("./plexSavedViews")>()),
usePlexSavedViews: () => ({ views: [], displayView: null, isDirty: false, activeViewId: null }),
}));
// The rails themselves are PlexDiscover's own test: here only their two See all presses.
vi.mock("./PlexDiscover", () => ({
default: (p: { onSeeAllRecent: () => void; onSeeAllContinue: () => void }) => (
<div>
<button onClick={p.onSeeAllRecent}>recent</button>
<button onClick={p.onSeeAllContinue}>continue</button>
</div>
),
}));
import PlexBrowse from "./PlexBrowse";
import { api, EMPTY_PLEX_FILTERS } from "../../lib/api";
import { PageToolbarSlot } from "../../components/ui/pageToolbarSlot";
import {
PlexActionsContext,
PlexStateContext,
type PlexActions,
type PlexState,
} from "./plexContext";
import type { SortState } from "../../lib/columnSort";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
beforeEach(() => window.history.replaceState({}, "", "/"));
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const TITLE: SortState = { key: "title", dir: "asc" };
const slot = document.body.appendChild(document.createElement("div"));
// The store's show and sort, real enough for PlexBrowse's own effect to act on them.
function mount(start: { show: string; sort: SortState }) {
vi.spyOn(api, "plexLibrary").mockResolvedValue({ items: [], total: 0, offset: 0 } as never);
const now = { state: null as PlexState | null, actions: null as PlexActions | null };
function Store() {
const [show, setShow] = useState(start.show);
const [sort, setSort] = useState<SortState>(start.sort);
const state: PlexState = {
scope: "both",
show,
sort,
filters: EMPTY_PLEX_FILTERS,
q: "",
field: "all",
playlistOpen: null,
jumpTo: null,
} as PlexState;
const actions = {
setShow,
setSort,
setScope: () => {},
setFilters: () => {},
setQ: () => {},
setField: () => {},
setPlaylistOpen: () => {},
setJumpTo: () => {},
} as PlexActions;
now.state = state;
now.actions = actions;
return (
<PlexStateContext.Provider value={state}>
<PlexActionsContext.Provider value={actions}>
<PlexBrowse />
</PlexActionsContext.Provider>
</PlexStateContext.Provider>
);
}
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<PageToolbarSlot.Provider value={slot}>
<Store />
</PageToolbarSlot.Provider>
</QueryClientProvider>,
);
return now;
}
describe("PlexBrowse · Discover's See all", () => {
it("Recently added opens every title, newest first, over a sort picked before", async () => {
const store = mount({ show: "in_progress", sort: TITLE });
fireEvent.click(await screen.findByText("recent"));
expect(store.state!.show).toBe("all");
expect(store.state!.sort).toEqual({ key: "added", dir: "desc" });
});
// Entering in-progress keeps the sort from before it, to give back on the way out: leaving for
// Recently added must not hand it back over newest first.
it("Recently added from in progress does not give back the sort from before it", async () => {
const store = mount({ show: "all", sort: TITLE });
act(() => store.actions!.setShow("in_progress"));
expect(store.state!.sort).toEqual({ key: "lastwatched", dir: "desc" });
fireEvent.click(await screen.findByText("recent"));
expect(store.state!.show).toBe("all");
expect(store.state!.sort).toEqual({ key: "added", dir: "desc" });
});
it("Continue watching opens in progress, last watched first", async () => {
const store = mount({ show: "all", sort: TITLE });
fireEvent.click(await screen.findByText("continue"));
expect(store.state!.show).toBe("in_progress");
expect(store.state!.sort).toEqual({ key: "lastwatched", dir: "desc" });
});
// Already in progress, with another sort picked there: no entering edge brings the sort.
it("Continue watching sorts by last watched when in progress was already on", async () => {
const store = mount({ show: "all", sort: TITLE });
act(() => store.actions!.setShow("in_progress"));
act(() => store.actions!.setSort(TITLE));
fireEvent.click(await screen.findByText("continue"));
expect(store.state!.show).toBe("in_progress");
expect(store.state!.sort).toEqual({ key: "lastwatched", dir: "desc" });
// …and the sort from before in-progress still comes back on the way out.
act(() => store.actions!.setShow("all"));
expect(store.state!.sort).toEqual(TITLE);
});
});
+19 -1
View File
@@ -80,6 +80,9 @@ const LASTWATCHED_SORT: NonNullable<SortState> = Object.freeze({
dir: "desc" as const,
});
// What the Recently added rail's See all opens on.
const ADDED_SORT: NonNullable<SortState> = Object.freeze({ key: "added", dir: "desc" as const });
// Watch-state options for the toolbar pill (moved out of the filter rail — it's a top-level "what am
// I looking at" control now, like the feed's Watch-state pill, not a rail widget).
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
@@ -579,7 +582,22 @@ function PlexBrowseView({ sub }: { sub: ReturnType<typeof useHistorySubview<Sub>
onRestartCard={onRestartCard}
onInfoCard={onInfo}
onGenre={(g) => applyFilter({ genres: [g] })}
onSeeAllRecent={() => sub.open({ kind: "grid" })}
// Each opens the list its rail is the start of: the watch state goes with it, and
// entering in-progress brings the last-watched sort (the effect above).
onSeeAllRecent={() => {
// Newest first is the list's point, so no earlier sort comes back as it leaves in-progress.
preInProgressSort.current = null;
setSort(ADDED_SORT);
setShow("all");
sub.open({ kind: "grid" });
}}
onSeeAllContinue={() => {
// Already in progress (with a sort picked since), no edge brings the sort: set it here,
// keeping the sort from before in-progress for the way out.
if (show === "in_progress") setSort(LASTWATCHED_SORT);
else setShow("in_progress");
sub.open({ kind: "grid" });
}}
/>
</div>
);
@@ -105,6 +105,7 @@ describe("PlexDiscover", () => {
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
onSeeAllContinue={noop}
/>,
);
await screen.findByText("plex.discover.continueWatching");
@@ -144,6 +145,7 @@ describe("PlexDiscover", () => {
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
onSeeAllContinue={noop}
/>
</QueryClientProvider>,
);
@@ -179,12 +181,38 @@ describe("PlexDiscover", () => {
onInfoCard={noop}
onGenre={onGenre}
onSeeAllRecent={noop}
onSeeAllContinue={noop}
/>,
);
fireEvent.click(await screen.findByText("Drama"));
expect(onGenre).toHaveBeenCalledWith("Drama");
});
it("fires onSeeAllContinue from the Continue watching rail's see-all", async () => {
const onSeeAllContinue = vi.fn();
const onSeeAllRecent = vi.fn();
plexDiscover.mockResolvedValue({ ...fullPayload, recently_added: [] });
plexSurprise.mockResolvedValue({ scope: "both", items: [] });
wrap(
<PlexDiscover
scope="both"
onOpenCard={noop}
onRestartCard={() => {}}
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={onSeeAllRecent}
onSeeAllContinue={onSeeAllContinue}
/>,
);
fireEvent.click(
await screen.findByRole("button", {
name: "common.seeAllOf:plex.discover.continueWatching",
}),
);
expect(onSeeAllContinue).toHaveBeenCalledOnce();
expect(onSeeAllRecent).not.toHaveBeenCalled();
});
it("fires onSeeAllRecent from the recently-added rail's see-all", async () => {
plexDiscover.mockResolvedValue(fullPayload);
plexSurprise.mockResolvedValue({ scope: "both", items: [] });
@@ -197,9 +225,13 @@ describe("PlexDiscover", () => {
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={onSeeAllRecent}
onSeeAllContinue={noop}
/>,
);
fireEvent.click(await screen.findByRole("button", { name: "common.seeAll" }));
// Each See all is named with its rail: Continue watching, above it, has its own.
fireEvent.click(
await screen.findByRole("button", { name: "common.seeAllOf:plex.discover.recentlyAdded" }),
);
expect(onSeeAllRecent).toHaveBeenCalledOnce();
});
@@ -214,6 +246,7 @@ describe("PlexDiscover", () => {
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
onSeeAllContinue={noop}
/>,
);
await screen.findByText("Random Pick");
@@ -250,6 +283,7 @@ describe("PlexDiscover", () => {
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
onSeeAllContinue={noop}
/>
</QueryClientProvider>
);
@@ -297,6 +331,7 @@ describe("PlexDiscover", () => {
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
onSeeAllContinue={noop}
/>
</QueryClientProvider>,
);
@@ -338,6 +373,7 @@ describe("PlexDiscover", () => {
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
onSeeAllContinue={noop}
/>,
);
await screen.findByText("plex.discover.empty");
@@ -363,6 +399,7 @@ describe("PlexDiscover", () => {
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
onSeeAllContinue={noop}
/>,
);
fireEvent.click(await screen.findByLabelText("plex.markWatched"));
@@ -386,6 +423,7 @@ describe("PlexDiscover · keyboard", () => {
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
onSeeAllContinue={noop}
/>,
);
await screen.findAllByText("Random Pick");
@@ -101,6 +101,7 @@ export default function PlexDiscover({
onInfoCard,
onGenre,
onSeeAllRecent,
onSeeAllContinue,
}: {
scope: string;
onOpenCard: (c: PlexCard) => void;
@@ -108,6 +109,7 @@ export default function PlexDiscover({
onInfoCard: (c: PlexCard) => void;
onGenre: (genre: string) => void;
onSeeAllRecent: () => void;
onSeeAllContinue: () => void;
}) {
const { t, i18n } = useTranslation();
const qc = useQueryClient();
@@ -233,6 +235,7 @@ export default function PlexDiscover({
<CardRail
title={t("plex.discover.continueWatching")}
ariaLabel={t("plex.discover.continueWatching")}
onSeeAll={onSeeAllContinue}
cards={d.continue_watching}
{...railProps}
/>
@@ -426,3 +426,17 @@ describe("a collection strip's card", () => {
await vi.waitFor(() => expect(onOpenInfo).toHaveBeenCalledWith("m2"));
});
});
describe("PlexInfo size", () => {
const show = (over: Partial<PlexItemDetail>) =>
wrap(<PlexInfo onPlayItem={noop} detail={{ ...detail, ...over }} variant="page" />);
it("names the file's size beside the runtime", () => {
show({ size_bytes: 15_000_000_000 });
expect(screen.getByText(/^1[45](\.\d)? GB$/)).toBeTruthy();
});
it("names none when it is not known", () => {
show({ size_bytes: null });
expect(screen.getByText("Test Movie")).toBeTruthy();
expect(screen.queryByText(/ GB$/)).toBeNull();
});
});
+4 -1
View File
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
import { api, type PlexCard, type PlexFilters, type PlexItemDetail } from "../../lib/api";
import { formatCompact, formatDecimal, formatRuntime } from "../../lib/format";
import { formatBytes, formatCompact, formatDecimal, formatRuntime } from "../../lib/format";
import { notifyActionError } from "../../lib/actionErrors";
import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi";
import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside";
@@ -452,6 +452,9 @@ export default function PlexInfo({
{detail.duration_seconds ? (
<span className={mutedCls}>{formatRuntime(detail.duration_seconds, lang)}</span>
) : null}
{detail.size_bytes ? (
<span className={mutedCls}>{formatBytes(detail.size_bytes, lang)}</span>
) : null}
{detail.studio && (
<Filterable
tag
@@ -1121,6 +1121,80 @@ describe("PlexPlayer — clip mode", () => {
expect(screen.getByTestId("clip-panel")).toBeTruthy();
});
describe("the wait before an auto-skip's countdown", () => {
const withCredits = {
...item("a", 0, "b"),
markers: [{ type: "credits", start_s: 2900, end_s: 2995, final: true }],
} as unknown as PlexItemDetail;
const advance = (ms: number) =>
act(() => {
vi.advanceTimersByTime(ms);
});
// Into the marker with the clock already faked, so the countdown's interval is the fake one's.
const enterMarker = (paused: boolean) => {
vi.useFakeTimers();
const v = video();
v._paused = paused;
v._t = 2910;
act(() => void v.dispatchEvent(new Event("timeupdate")));
};
it("holds the countdown for 3 s after the marker starts, then runs it", async () => {
prefs({ autoSkipCredits: true, autoSkipDelay: 1 });
// Leaving a final marker writes "watched" as it skips, in the same tick: the next item's
// session comes only after promises this clock does not run.
const left = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
mount({ details: { a: withCredits, b: item("b") }, queue: ["a", "b"] });
await playingAt("/media/a", 2800);
enterMarker(false);
advance(3500); // past what the delay alone would have waited
expect(left).not.toHaveBeenCalled();
advance(1000);
expect(left).toHaveBeenCalledWith("a", "watched");
});
it("a delay of 0 is still immediate", async () => {
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
const { session } = mount({ details: { a: withCredits, b: item("b") }, queue: ["a", "b"] });
await playingAt("/media/a", 2910);
await waitFor(() => expect(lastStart(session)?.[0]).toBe("b"));
});
it("waits out the pause: a paused video does not use up the 3 s", async () => {
prefs({ autoSkipCredits: true, autoSkipDelay: 1 });
const left = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
mount({ details: { a: withCredits, b: item("b") }, queue: ["a", "b"] });
await playingAt("/media/a", 2800);
enterMarker(true);
advance(10_000);
expect(left).not.toHaveBeenCalled();
video()._paused = false;
advance(3500); // the whole wait is still ahead, then the 1 s countdown
expect(left).not.toHaveBeenCalled();
advance(1000);
expect(left).toHaveBeenCalledWith("a", "watched");
});
// An 8 s intro with the default 5 s countdown: 3 s of wait on top would run past its end, and the
// countdown stops there with nothing skipped.
it("shortens the wait on a marker too short for it, so the skip still fires", async () => {
prefs({ autoSkipCredits: true, autoSkipDelay: 5 });
const short = {
...withCredits,
markers: [{ type: "credits", start_s: 2909, end_s: 2917, final: true }],
} as unknown as PlexItemDetail;
const left = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
mount({ details: { a: short, b: item("b") }, queue: ["a", "b"] });
await playingAt("/media/a", 2800);
enterMarker(false); // at 2910: 6 s before the marker stops counting
advance(5000);
expect(left).not.toHaveBeenCalled(); // a wait is still kept: 0.5 s of it here
advance(700);
expect(left).toHaveBeenCalledWith("a", "watched");
});
});
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 = {
@@ -134,13 +134,13 @@ const lost404 = {
fatal: true,
type: Hls.ErrorTypes.NETWORK_ERROR,
details: "fragLoadError",
response: { code: 404, text: "No active playback session" },
response: { code: 404, text: "Not available yet" },
};
describe("an HLS session the server no longer has", () => {
describe("an HLS segment that never arrives (a fatal 404)", () => {
it("is restarted where the viewer is, instead of ending playback", async () => {
// The server reaps a session nobody fetches from (a long pause; a deploy). hls.js never retries
// a 4xx, so the next segment's 404 used to end playback with "couldn't play".
// Once hls.js has given up on it, the 404 used to end playback with "couldn't play". A session
// the server no longer has answers 410 instead: `hlsEngine.test.ts` holds that case.
const session = mount();
await waitFor(() => expect(hlsInstances).toHaveLength(1));
act(() => hlsInstances[0]!.emit(Hls.Events.MANIFEST_PARSED));
@@ -155,10 +155,9 @@ describe("an HLS session the server no longer has", () => {
it("gives up and says so when the reclaimed session keeps 404ing", async () => {
// `parsed` cannot bound this: it lives on ONE hls instance and every restart builds a fresh one,
// so a session that parses its manifest and then 404s re-arms the guard every cycle. The server
// also answers 404 for "that segment is not there YET" (a transcode falling behind realtime),
// which is indistinguishable on the wire — so the player respawned ffmpeg forever behind a
// spinner that never became an error.
// so a session that parses its manifest and then 404s re-arms the guard every cycle. A transcode
// falling behind realtime answers 404 for "that segment is not there YET" every time, so the
// player respawned ffmpeg forever behind a spinner that never became an error.
const session = mount();
const v = () => document.querySelector("video") as Media;
// The first session plus the two it is allowed to reclaim; the third 404 has nothing left.
+43
View File
@@ -24,6 +24,20 @@ afterEach(() => {
localStorage.clear();
});
describe("WatchPage share paths", () => {
it.each(["/watch/tok-p", "/file/tok-p", "/folder/tok-p", "/file/tok-p/extra"])(
"reads the token off %s",
async (path) => {
window.history.replaceState({}, "", path);
mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" });
render(<WatchPage />);
await waitFor(() =>
expect(fetch).toHaveBeenCalledWith("/api/public/watch/tok-p", expect.anything()),
);
},
);
});
describe("WatchPage background play", () => {
it("hands its <video> to the background-play hook, so Brave iOS keeps it playing", async () => {
window.history.replaceState({}, "", "/watch/tok-bg");
@@ -267,6 +281,35 @@ describe("WatchPage honest failures", () => {
return page;
}
it.each([
[{ kind: "file", video: true }, "This video is password-protected", "Watch"],
[{ kind: "file", video: false }, "This file is password-protected", "Open"],
[{ kind: "folder", video: false }, "This folder is password-protected", "Open"],
[{}, "This video is password-protected", "Watch"],
])("a password link that opens %o asks in its words", async (kind, title, button) => {
window.history.replaceState({}, "", "/file/tok-lock");
mockMeta({ needs_password: true, ...kind });
const page = render(<WatchPage />);
await page.findByText(title);
expect(page.getByRole("button", { name: button })).toBeTruthy();
});
it("a repeated Enter sends one try while it is on its way", async () => {
window.history.replaceState({}, "", "/watch/tok-enter");
const fetchMock = vi.fn().mockResolvedValueOnce(res(200, { needs_password: true }));
fetchMock.mockImplementation(() => new Promise(() => {}));
vi.stubGlobal("fetch", fetchMock);
const page = render(<WatchPage />);
const field = await page.findByPlaceholderText("Enter password");
fireEvent.change(field, { target: { value: "guess" } });
fireEvent.keyDown(field, { key: "Enter" });
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
fireEvent.keyDown(field, { key: "Enter" });
fireEvent.keyDown(field, { key: "Enter" });
await new Promise((r) => setTimeout(r, 20));
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("too many password tries says so, and keeps the form", async () => {
window.history.replaceState({}, "", "/watch/tok-429");
const page = await tryPassword(async () => res(429));
+22 -9
View File
@@ -28,7 +28,7 @@ import { SURFACE } from "../components/ui/surfaces";
import { IconLink, LinkButton } from "../components/ui/LinkButton";
import { T, lang } from "./watchStrings";
// Standalone, login-free page for a shared /watch/<token> link: one file, or a folder's list of them.
// Standalone, login-free page for a share link (/watch/, /file/, /folder/): one file, or a folder's list.
// Its own entry (watchMain.tsx, watch.html): no auth, no providers, none of the app's bundle. Uses
// plain fetch and a tiny self-contained i18n dict (`watchStrings`: the public viewer has no app
// language preference).
@@ -64,7 +64,7 @@ type Meta = FileMeta | FolderMeta;
// What the link answers before it is opened: a password link says only which kind it is.
type Answer =
| (Meta & { needs_password?: false })
| { needs_password: true; kind?: "file" | "folder" };
| { needs_password: true; kind?: "file" | "folder"; video?: boolean };
// Compact display of a URL: drop the protocol + trailing slash so it fits on one line.
function displayUrl(url: string): string {
@@ -83,16 +83,23 @@ async function notFoundState(r: Response): Promise<"preparing" | "gone"> {
return body?.detail?.reason === "video_preparing" ? "preparing" : "gone";
}
/** The token of a share path: /watch/ for a video, /file/ or /folder/ for the rest, any of them for
* any link (`main.SHARE_PREFIXES`). */
function shareToken(path: string): string {
return /^\/(?:watch|file|folder)\/([^/?#]+)/.exec(path)?.[1] ?? "";
}
export default function WatchPage() {
const token = window.location.pathname.split("/watch/")[1]?.split(/[/?#]/)[0] ?? "";
const token = shareToken(window.location.pathname);
// Seeded from the URL rather than corrected by the effect below: with no token there is nothing
// to load, and "loading" was committed and painted before the effect said so.
const [status, setStatus] = useState<
"loading" | "ready" | "password" | "gone" | "preparing" | "error"
>(token ? "loading" : "gone");
const [meta, setMeta] = useState<Meta | null>(null);
// A password link's kind, for its title, until it is unlocked.
const [locked, setLocked] = useState<"file" | "folder">("file");
// What a password link opens, for its prompt's words, until it is unlocked. A server from before
// it said `video` only had video links' words, so a file without it reads as one.
const [locked, setLocked] = useState<"video" | "file" | "folder">("video");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const errorId = useId();
@@ -118,7 +125,7 @@ export default function WatchPage() {
.then((m) => {
if (typeof m === "string") setStatus(m);
else if (m.needs_password) {
setLocked(m.kind ?? "file");
setLocked(m.kind === "folder" ? "folder" : m.video === false ? "file" : "video");
setStatus("password");
} else {
setMeta(m);
@@ -240,7 +247,11 @@ export default function WatchPage() {
<div className={`${SURFACE.panel} w-full max-w-sm p-6`}>
<div className="flex items-center gap-2 font-medium mb-4">
<Lock className="w-4 h-4 text-accent" />{" "}
{locked === "folder" ? T.passwordTitleFolder : T.passwordTitle}
{locked === "folder"
? T.passwordTitleFolder
: locked === "file"
? T.passwordTitleFile
: T.passwordTitle}
</div>
<Input
className="w-full"
@@ -248,7 +259,9 @@ export default function WatchPage() {
value={password}
autoFocus
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && password && unlock()}
// Not again while one is on its way, as the button: a held or repeated Enter would
// spend the link's tries (and its view count) once per press.
onKeyDown={(e) => e.key === "Enter" && password && !unlocking && unlock()}
placeholder={T.passwordPlaceholder}
aria-describedby={error ? errorId : undefined}
/>
@@ -266,7 +279,7 @@ export default function WatchPage() {
disabled={!password || unlocking}
className="mt-4"
>
{T.watch}
{locked === "video" ? T.watch : T.open}
</Button>
</div>
</div>
@@ -28,3 +28,12 @@ describe("the watch page's document language", () => {
expect(document.documentElement.lang).toBe(want);
});
});
// One button, two kinds of link: it says the same kind of word for both, as the app's own buttons
// do in Hungarian — a noun (Letöltés, Megnyitás), not a first-person verb on one of them.
describe("the password form's button", () => {
it("reads as a noun for a video and for anything else in Hungarian", async () => {
const { T } = await pageIn("hu-HU");
expect([T.watch, T.open]).toEqual(["Megtekintés", "Megnyitás"]);
});
});
+7 -1
View File
@@ -27,6 +27,8 @@ const STR = {
crashHint: "Reload the page to try again.",
reload: "Reload",
passwordTitleFolder: "This folder is password-protected",
passwordTitleFile: "This file is password-protected",
open: "Open",
folderEmpty: "This folder is empty.",
play: "Play",
files: (n: number) => (n === 1 ? "1 file" : `${n} files`),
@@ -37,7 +39,7 @@ const STR = {
goneHint: "Lehet, hogy lejárt, vagy a tulajdonos visszavonta.",
passwordTitle: "Ez a videó jelszóval védett",
passwordPlaceholder: "Add meg a jelszót",
watch: "Megnézem",
watch: "Megtekintés",
wrong: "Hibás jelszó.",
download: "Letöltés",
file: "Fájl",
@@ -55,6 +57,8 @@ const STR = {
crashHint: "Töltsd újra az oldalt, és próbáld újra.",
reload: "Újratöltés",
passwordTitleFolder: "Ez a mappa jelszóval védett",
passwordTitleFile: "Ez a fájl jelszóval védett",
open: "Megnyitás",
folderEmpty: "Ez a mappa üres.",
play: "Lejátszás",
files: (n: number) => `${n} fájl`,
@@ -83,6 +87,8 @@ const STR = {
crashHint: "Lade die Seite neu und versuche es erneut.",
reload: "Neu laden",
passwordTitleFolder: "Dieser Ordner ist passwortgeschützt",
passwordTitleFile: "Diese Datei ist passwortgeschützt",
open: "Öffnen",
folderEmpty: "Dieser Ordner ist leer.",
play: "Abspielen",
files: (n: number) => (n === 1 ? "1 Datei" : `${n} Dateien`),
+23
View File
@@ -99,6 +99,29 @@ function chain(seen: Map<string, string | null>, mod: string): string {
return path.join(" → ");
}
describe("the paths a share link opens on", () => {
// The server's list: each of the places below that has to know them follows it.
const main = readFileSync(join(SRC, "..", "..", "backend", "app", "main.py"), "utf8");
const prefixes = [
...(/SHARE_PREFIXES = \(([^)]*)\)/.exec(main)?.[1] ?? "").matchAll(/"(\w+)\/"/g),
].map((m) => m[1]);
it("are read off the server", () => {
expect(prefixes).toEqual(["watch", "file", "folder"]);
});
it("are all kept out of search indexes", () => {
const robots = readFileSync(join(SRC, "..", "public", "robots.txt"), "utf8");
for (const p of prefixes) expect(robots).toContain(`Disallow: /${p}/`);
});
it("all boot the player alone in `vite dev` and all give up their token", () => {
const vite = readFileSync(join(SRC, "..", "vite.config.ts"), "utf8");
const page = readFileSync(join(SRC, "watch", "WatchPage.tsx"), "utf8");
for (const src of [vite, page]) expect(src).toContain(`(?:${prefixes.join("|")})`);
});
});
describe("the /watch entry", () => {
it("loads nothing the app alone may load", () => {
const seen = reach(join(SRC, "watchMain.tsx"));
+3
View File
@@ -5,6 +5,7 @@ import WatchPage from "./watch/WatchPage";
import { T } from "./watch/watchStrings";
import "./index.css";
import { installStaleBuildReload } from "./lib/staleBuild";
import { retryLostStylesheets } from "./lib/stylesheetRetry";
// The entry of a public /watch/<token> link (watch.html). Whoever opens it got the link from a
// friend: signed out, often on a phone, often on mobile data — so it imports the player page and
@@ -14,6 +15,8 @@ import { installStaleBuildReload } from "./lib/staleBuild";
// A tab left open across a release reloads onto the new build instead of failing on a chunk.
installStaleBuildReload();
// A stylesheet lost on the way is asked for again rather than leaving the page unstyled.
retryLostStylesheets();
const crashCopy = () => ({ title: T.crashTitle, subtitle: T.crashHint, reload: T.reload });
+3 -3
View File
@@ -32,18 +32,18 @@ const proxy = {
"/healthz": proxyOpts,
};
// Two HTML entries: the app (index.html) and a public /watch/<token> link (watch.html), which loads
// Two HTML entries: the app (index.html) and a public share link (watch.html), which loads
// only the player page. In a build the backend picks the file by path (`main.py`); this does the
// same for `vite dev`, whose history fallback would otherwise hand /watch/… the app.
const watchEntry = (): Plugin => ({
name: "siftlode-watch-entry",
configureServer(server) {
server.middlewares.use((req, _res, next) => {
if (req.url?.startsWith("/watch/")) req.url = "/watch.html";
if (req.url && /^\/(?:watch|file|folder)\//.test(req.url)) req.url = "/watch.html";
next();
});
},
// The server answers every /watch/ link with the built watch.html, so a build without it would
// The server answers every share link with the built watch.html, so a build without it would
// ship a 500 for each one, and nothing downstream reads `dist` before prod does. Fail it here —
// after Vite's own HTML plugin has emitted the pages (`enforce: "post"`), or none is there yet.
enforce: "post",