A user can raise their diagnostics level to Részletes (DEBUG) or Teljes (TRACE) for 24 hours from a
new Settings tab, after a consent box that says what is recorded, that the server keeps it and for
how long (the instance's own retention), and that it lapses. Alap is the base and needs no consent.
- users.diagnostics_level/_until (migration 0087), written only by PUT /api/me/diagnostics, which
computes the expiry itself; the demo account cannot raise it. /api/me reports the level in force.
- The server logs an elevated user's requests at that level and stores every line of them
(logs.LevelGate on the stdout handler and the sink; the root is lowered only while an elevation
is active). Teljes adds the redacted query string to the request line.
- POST /api/diagnostics/events stores the browser's log as source=web rows: account from the
session, body capped while streaming, events/attrs capped, re-redacted, events under the
consented level dropped, timestamps clamped, 30 batches a minute, 409 once the level lapsed.
- lib/diagnostics.ts applies the level to the tab and streams only what happens after consent, in
batches, never its own request lines, with a keepalive flush on pagehide; stops on lapse (its
own timer), on withdrawal and on 409.
- Detail hooks at DEBUG: which control was clicked (test id, icon, app copy in English — never user
text, input values or a link's query), visibility/bfcache/resize, filter changes
(lib/filterLog.ts; typed search only at TRACE), player events (lib/mediaLog.ts; media errors are
warnings at every level). Offline/online at the base level.
- "Saját napló" viewer: live, newest first, level groups with counts, search, row details, copy
the filtered events as JSON lines, clear.
- Privacy policy and Terms exist in Hungarian and English (a contract test holds both languages to
the same sections); the policy now describes the server logs and diagnostic logging, self-service
deletion and password sign-in.
Fixed on the way:
- A sync auth dependency ran logs.set_user on a copied context, so the account never reached a
handler's own log lines (measured); a per-request mutable scope carries it now.
- The Settings tab rail left a phone no room (clipped labels, buttons over text): below `sm` it is
a scrolling row above the content.
- PUT /api/me/preferences took a bare dict body; it is a Pydantic model now.
On iPad Brave, a shared video stopped the moment the browser left the
foreground, even with Brave's "Enable Background Audio" on, while an mp3
kept playing. Brave iOS keeps a web <video> going by re-playing one the OS
paused (brave-core ios/browser/web/media/resources/media_backgrounding.ts),
but it only hooks a <video> present at document start or added as a
MutationObserver addedNode that IS a video. React inserts a player's
<video> inside its freshly mounted wrapper in one go, so Brave never saw it.
lib/browserBackgroundPlay re-inserts a mounted <video> at its own position,
once per element: it becomes an added node, the DOM React manages does not
change, and playback is not interrupted. The watch page, the downloads
library player and the Plex player hand their <video> to it. Measured on the
owner's iPad (Brave) with a prototype of the same idea: the video kept
playing in another app and with the screen locked. The YouTube modal is not
ours to change: its <video> lives in YouTube's cross-origin iframe, and
nothing of ours pauses it on backgrounding.
The prototype's two audio-element variants (a muted video following an
<audio>, and an <audio> taking over on hide) went silent in the background on
the iPad and are removed.
The watch page gets a Repeat toggle, on by default and remembered per
browser (LS.watchRepeat; the page has no account): it sets `loop` on the
<video> or the audio-only <audio>.
E11 S5 — the HU/EN sweep and the keyboard-only pass over every module.
- The keyboard cheat sheet printed "Space" and "Menu" on a Hungarian sheet: a keycap that carries a
WORD (not a legend every layout prints alike, like Esc, Ctrl or Enter) is now named by the caller in
the reader's language (`formatChord(chord, words)`, `keys.cap.*`: Szóköz / Menü).
- The Plex player's ⌫/Esc line said "Stop & back to feed", but the player goes back to wherever it
was opened from (Home, a Plex page): "Stop & go back" / "Leállítás és vissza".
- Messages: the conversation list is a keyboard list (NavGrid + navItemAttrs, the name button is the
open element; the pop-out button keeps its own Enter). D1 covers every list whose rows open one
thing; this one was missed in S4. Notification cards have no single open action and stay out, like
the download queue.
Sweep, measured: every static `t("…")` key used in the source exists in both languages (the only
misses are template prefixes); the HU values equal to EN are proper nouns and loanwords (Admin, Plex,
Trakt, Shorts). Palette in Hungarian: "téma" makes "Téma: Midnight" the active row ahead of the channel
"Topik - mindig témában", live region "7 találat".
Keyboard-only pass in Chrome, Hungarian UI — 1024×768: every `g` chord lands on its module, `j` rings
the first item (solid 2px accent) on Home, Feed, Channels, Playlists, Plex and Downloads, `s` focuses
the search on Feed, Channels, Playlists and Plex, no page scrolls sideways. 3440×1440: `j` → → ↓
walks Home, Feed (8 columns: 0 → 1 → 9), Channels, Playlists, Plex and Downloads, no sideways scroll.
The help sheet lists every line translated; inside the Plex player "Szóköz / K" and "⌫ / Esc
Leállítás és vissza". Messages: a real `j` rings the row, Enter opens the thread.
Two ways round 6's own fixes did not bound what their comments said they bounded.
The reclaim budget's window anchor was written only where the window RESET, so it marked the window's
start rather than the last loss — a tumbling window, not the rolling one the constant is named for.
Any loss cadence slower than half the window satisfied the reset on every second loss, so the budget
never reached its bound: measured over 200 loss cycles, 61/70/90/119s apart all gave 200 reclaims and
never an error, while 5s and 30s apart correctly gave two and then the honest answer. The failing
band is exactly the case the ERROR handler names — a transcode that falls behind realtime — and both
committed tests spaced their losses 0s and 10min apart, i.e. outside it. The anchor now moves with
every loss.
And `finish`'s refusal handler lowered the play-through latch, which is the only gate on the 10s
interval, the page-hide beacon and `settle`. All three post `absRef`, which on the play-to-the-end
path IS the end of the media, and the backend reads a position within `_FINISH_MARGIN_S` of the
duration as a finish — so within ten seconds of the toast saying the mark had failed, the item was
marked watched, its position cleared and a scrobble sent to Plex. Round 6 closed the explicit
rollback write and left the other three open; the test it added set `upNext: false`, which tears the
effect down before any of them can fire. The refusal now takes back the CLAIM rather than the latch:
a second flag stops `settle` re-asserting watched, and playing again clears both.
`FINISH_MARGIN_S` duplicated the backend's constant in a second language with nothing relating them,
and its comment had the asymmetry backwards — if the BACKEND's margin grows, this guard starts
admitting a rollback the backend reads as a finish, i.e. re-opens the defect above. Pinned by a
contract test that reads the Python, and the comment corrected.
`MAX_SESSION_RECLAIMS` was cleared by the first fragment of any session, and a
reclaimed session essentially always delivers one — `start_session` does not
return until its first segment exists. So the budget only ever bit where a
session parsed its manifest and then died before a single fragment, which is not
either case its own comment names. Measured: 40 loss cycles produced 41 sessions
and never an error. A transcode falling behind realtime, or two viewers of one
rating_key taking turns, respawned ffmpeg indefinitely — the round-5 behaviour,
still there behind the guard added to stop it.
It counts within a rolling window now. Two losses inside two minutes is a
session that keeps being lost; one an hour apart is not. And the window is reset
per item, because the player stays mounted across a queue advance — otherwise a
budget spent on one episode would deny the next its first legitimate reclaim.
Separately: `finish`'s rollback re-performed the write it was taking back. On
the play-to-the-end path the position it restores IS the end of the media, and
the backend reads a position within its finish margin as a finish — so the
branch named "take the finish back" set watched, cleared the position and
scrobbled to Plex through a second route, while the caller's error toast was
already on screen saying the mark had failed. There is no resume point at the
end of the media to restore; the cache drop is the whole of the rollback there.
The credits-skip caller captures a mid-episode position and still restores it,
which is the case that needs it.
7 tests, mutation-checked: the FRAG_LOADED reset, the missing window, and the
unguarded rollback each red on the test that names them.
Deep-review round 5, over round 4's own fixes.
- Round 4 added a `finish` latch so a played-through item records no checkpoint at the credits.
On a NATURAL play-through it was raised a beat too late: per the HTML spec the browser sets
`paused` and fires 'pause' BEFORE 'ended', so the pause checkpoint went out while the latch was
still down and raced the watched mark — both requests read "not watched yet" from their own
snapshot, so Plex counted one watch twice, and where Plex's reported duration runs more than the
finish margin past the real media the checkpoint wrote a resume position AFTER the scrobble. The
pause that ends the media no longer flushes. The regression test that was meant to cover this
modelled the end as a bare 'ended' event — a browser that does not exist — and is now the real
order.
- The watched mark was fired and forgotten (`.catch(() => {})`) while the latch patched the cached
detail to watched / position 0 regardless, and round 4's once-per-id start guard meant the
corrected detail arriving later was never used: an episode whose mark failed reopened at 0:00
with a Watched badge the server disagreed with. `finish` now takes the in-flight write and takes
itself back if the server refuses it — the real position is reported, the lying cache entry is
dropped, and the failure is said out loud instead of swallowed. Both callers (the natural end
and the credits auto-skip) hand it over.
- The reclaim of a 404'd HLS session had no bound. `parsed` cannot provide one — it belongs to one
hls instance and every restart builds a fresh one — and the server answers 404 both for "the
session is gone" and for "that segment is not there yet", so a transcode falling behind realtime,
or two viewers of one item taking turns, respawned ffmpeg forever behind a spinner that never
became an error. Two consecutive reclaims, reset by the first fragment that actually loads, then
the honest failure.
Five regression tests, each mutation-checked red on the assertion it is named after.
Deep-review round 4, the overlays.
- Toast hosts were ranked by mount time, and PlayerModal swaps its host on every fullscreen change:
leaving true fullscreen with the download dialog open put the player's host back on top, so toasts
were drawn under that dialog's scrim and a click on one closed the dialog. Each overlay takes a
rank once, when it opens (`useOverlayOrder`), and hands it to every host it renders.
- A dialog open while fullscreen is left keeps a stable portal container (moved, not re-created):
React tore the portal down when `fsRoot` changed, resetting a half-filled form and dropping focus
to <body>. Focus is put back after the move.
- The onboarding wizard is a layer, not a raw document listener: one Escape used to dismiss the
error dialog over it AND end onboarding, skipping the write-scope step.
- `navigateTo` (a toast's "View", clicked inside the player's own host) goes over the overlays it
unmounts, so the player's Back-to-close cleanup no longer pops the page it just opened — the same
fix Shift+W already had. The access-requests toast action too.
- The live region arms itself a task after it takes over inherited toasts: React flushes effects in
the same task as that render, so the "silent inheritance" arming was a no-op.
- The overlay-host guard matches class tokens in any order (PlexPlayer's root was never checked),
globs `ui/` too (ui/Modal was not checked either), and requires the rank.
Deep-review round 4, the Plex player.
- A 404 on an HLS session that WAS playing means the server reaped it (a long pause, a deploy);
hls.js never retries a 4xx, so playback ended with "couldn't play". A new session at the playhead
now takes its place (the frontend half of the paused-viewer reap fix). A session that 404s before
its manifest is still a plain failure, so it cannot restart itself in a loop.
- Leaving an item that ended, or that the credits auto-skip left, sent a final checkpoint at the
credits (racing the watched mark, and pushing a resume point to Plex after the scrobble) and
patched "new, at 49:55" into the cache — Prev reopened it at its last seconds. `finish` settles it
as watched at 0, said before the pause whose own checkpoint could land first.
- An audio switch, an A/V offset change and every seek control read `abs`, which is 0 until
playback ticks: a resume at 20:00 restarted from 0:00. They use the shared `playhead`.
- A seek while a restart is pending always restarts (the old media's `seekable` described the old
session, and the pending answer overrode the newer seek).
- A superseded direct-play session's loadedmetadata/canplay are generation-gated and detached (they
seeked the next item to the previous one's resume point).
- The session starts once per item: a reconnect refetch, the info panel's watched toggle or a
language switch no longer restarts the film at the last checkpoint.
- Tests for the session generation's failure paths and the item-change bump, which had none.
Deep-review round 3 of bug hunt 2026-09.
- loadSession checks, after its await, that it is still the newest call and the player is still
mounted. A late answer used to replace what was playing (Next twice quickly: the slower transcode
landed last and played the middle item under the last one's title, recording its progress there)
or attach a stream to the detached <video> after Back, never destroyed.
- The <video>'s clock is trusted only once the new session has answered. go()'s pause() fires a
timeupdate AFTER the reset to 0 (measured in Chrome), so the previous item's position was sent as
the next item's subtitle position and saved as its progress (final, pushed to Plex).
- Next settles the item being left (final checkpoint + reopen-cache patch) before switching: the
progress cleanup ran with the position already reset and skipped, so every Next dropped the last
stretch watched.
- A seek-restart puts its target into the position state too; the ref alone was re-synced back to
the pre-seek spot by the restart's own render.
- The unmount teardown captures the <video> at mount: React detaches refs before an unmount's
cleanups, so the stop/detach never ran.
- The tracks panel exposes the selected audio/subtitle track via aria-pressed.
Verified in real Chrome on localdev (Westworld S1E6 → Next): the item left was settled at 783 s, the
next asked for subtitles at its own start (0), and no progress was written for it.
The player's "still alive" flag — every play() is gated on it, so a late one cannot start a detached
<video> after Back — was only ever LOWERED, in an effect's cleanup. React may run a cleanup and mount
again (StrictMode does exactly that in development), and then the flag stayed false for the life of
the player: it never started by itself, and a seek back past the loaded region, which restarts the
session, left it paused. Found on localdev (Westworld S01E06: playing → seek back → the picture
settles, paused). Production does not double-mount, which is why only dev showed it — and why
several earlier localdev measurements "stayed paused".
The effect now raises the flag in its setup. A new test renders the real player under StrictMode and
checks it plays once the media is ready; red without the fix. Verified on localdev: it starts by
itself, and keeps playing through a backward session-restart seek.
The app's Toaster lives in the content column, which is a stacking context (its `@container` implies
layout containment), so both full-screen players — portalled to <body> at the same z-index — painted
over every toast. Round 1's "a failed Remove is reported" fix was therefore still silent inside the
player: its toast sat under an 80% black, blurred scrim (elementFromPoint returned the scrim), and a
click on it closed the player.
An overlay now mounts its own `<Toaster overlay />`, and exactly one host shows the toasts: the most
recently mounted overlay, else the app's. Same component, same look — and inside the aria-modal
dialog, where a screen reader can still reach it. A click on a toast inside an overlay stays there.
Both players get one (the Plex player's also works in native fullscreen, where nothing outside the
fullscreen element is drawn). Verified on localdev: the removal failure reads above the scrim,
bottom-left, and clicking it leaves the player open.
- The <track>'s `error` never reached `onSubTrackError`: it does not bubble, and React 18 attaches no
listener of its own to a <track> (measured in Chrome and jsdom — `onError` 0 calls, `onErrorCapture`
1). Round 1's remount-and-give-up was dead code behind it. The hook now hands the player
`onErrorCapture`, and the test fires a real non-bubbling `error` at a rendered <track>.
- Once reachable, the retry burned its whole budget within milliseconds of an outage (a remount
fetches at once) and the give-up outlived turning subtitles off and on. Retries now wait (2 s,
doubling), running out is reported at once, and picking the track again starts a fresh budget.
- Until the first timeupdate the poll sent `sessionK` — still 0 while the session request was out,
and the PREVIOUS item's start after a move to the next episode. It now sends where the session was
asked to start, and the track choice belongs to the item it was made for, so the next item is not
asked about the old ordinal at the old position.
- A failing poll over a stale `partial` answer was silent: react-query keeps the last good data, the
track stayed "showable", and the notice only rendered for an unmounted track. The hook now decides
what the player says (`subNotice`), independently of whether a window is mounted.
Deep-review R2, plus one defect found while verifying it on localdev.
- The status poll re-armed every second after a FAILED request: `data` stays empty on an error,
so it read as "still preparing" for as long as the player was open — and the request was not
quiet, so a persistent 500 re-opened the global error dialog every second. It now backs off to
10 s (a blip still heals on its own) and is quiet; the player already shows the failure inline.
- A <track> whose own VTT fetch failed never fetched again: with the server's answer unchanged its
key was unchanged, so React kept the dead element — silently blank subtitles. The key (now built
in the hook, where it is tested) carries a failed-load count; after two remounts the track is
reported as unavailable instead.
- Found in UAT: until the first timeupdate `abs` is 0, so after a resume every poll said at=0 and
the first window was cut around the head of the film (a resume at 20:00 polled at=0 for as long
as it stayed paused). The poll reports the session's start until playback ticks. And the window's
dedup identity is the track plus a window-length bucket of its start — keyed on the track alone
(R1), the right window queued behind that wrong one.
The poll's start/stop rules finally have tests (every earlier one resolved a single fixed answer).
Verified on localdev against Westworld S1E6 resumed at 20:00: the polls carried at=1197, one
subtitle line on screen at 21:02, the poll stopped at `ready`; the real file's window (56 cues) +
full pass (608) merged to 608 — the old merge would have served 664. 11 mutations, all red.
Bug hunt 2026-09, items 8 and 12 — the two things the Plex player was measurably
lying about.
Item 8: picking a subtitle track showed nothing for a long time, and the reported
workaround was to stop/start until it suddenly worked. Measured on prod against a
17.06 GB MKV with two embedded SubRip tracks:
full pass, one track 154 s
full pass, both tracks in ONE run 111 s
-c:s copy (no WebVTT conversion) 143 s
from 1:00:00 to the end of file 66 s
a 630 s window at 1:00:00, both 8 s
So the conversion was never the cost: ffmpeg streams every byte of the container
between its seek point and its stop point, and the array delivers ~140 MB/s. The
only lever is reading less. Three changes follow from that:
* a window around the playhead is extracted and published FIRST (seconds), and
the full pass completes behind it;
* every embedded text track rides that same single read — picking Hungarian used
to cost a full pass and then switching to English cost another one;
* none of it happens inside a request any more. The endpoint answers 202 while a
background worker extracts, the player polls /status (carrying its position, so
the window follows the viewer), and one ffmpeg run finally has a timeout.
-copyts is deliberately absent from a windowed pass: with it ffmpeg ignores -t and
reads the whole container anyway (measured — a [0, 600 s] window still took the
full 145 s), so a window comes back on a zero-based clock and is shifted to
absolute time here. Cached tracks written by older builds have no coverage sidecar
and count as complete, so nothing is re-extracted after the upgrade.
The <track> is no longer mounted before the server has something to put in it; a
"Preparing subtitles: …" notice stands in, and a track that can never be shown says
why instead of leaving the viewer to conclude the picker does nothing.
Item 12: "Bitrate" read a constant 211 kbps on every remux, because it was the
master playlist's BANDWIDTH — which ffmpeg derives from the one stream whose rate
it knows, the -b:a 192k AAC track, plus 10%. It is a measurement now: bytes per
second of MEDIA from hls.js FRAG_LOADED, accumulated per fragment type and summed
(a multi-audio session's video and audio renditions cover the same media seconds,
so pooling them would report half the truth), and an honest dash until the first
fragment lands. The panel also finally names the audio and subtitle track in play.
Verified in the browser against the real Plex library, and every new test
mutation-checked.
Round 3 reviewed round 2's diff. Trend across the loop: 32 findings over
the whole epic, 15 over round 1's fixes, 11 over round 2's. What it caught
is mostly the same shape as before — a guard narrowed until it was wrong
in the other direction — plus one item this branch had now got wrong
three different ways.
Backspace ownership over the player, third and final answer. Round 1
deferred to nothing (the cheat sheet took the key and the player stopped
underneath it). Round 1's fix deferred to `anyLayerAbove` (the player's
own gear/tracks popovers push layers too, so it stopped closing them).
Round 2's fix named the sheet and palette (the app-wide ErrorDialog is
neither, so the player tore down under it). The rule that actually holds
is not a list of overlays: the player's own panels answer first, because
handleBack's cascade is what closes them, and anything else layered above
takes the key.
The rail's inline rename is settled by measurement rather than argument.
Two rounds reasoned about whether a React passive effect lands before or
after floating-ui's return-focus microtask and reached opposite
conclusions; a jsdom harness built to decide it reproduced no failure for
ANY shape, because focus never enters the menu there — so it was deleted
rather than kept as a test that discriminates nothing. Checked in a real
browser instead: the field opens and holds focus. The shipped fix works,
and the comment now says what was measured rather than what was inferred.
The same check found that round 1's OTHER rail fix never worked at all.
Escape does run its own handler (typing then Escape leaves the name
unchanged, so it cancels rather than blur-commits) and the row button is
focusable when focused from outside — yet focus still lands on <body>.
Three explanations, none of which survived. So the refocus machinery is
reverted to the shape that shipped before this epic and the gap is
documented where the next person will hit it, with the evidence and an
instruction to instrument before theorising. A fourth guess would have
been worse than a known bug.
Also: the two comments in Playlists.tsx that still claimed the disabled
menu rows explain themselves — untrue since round 2 reverted `inert`, and
exactly the comment-vs-code drift CLAUDE.md calls the source of the worst
bugs here. And the move-failure toast printed HttpError.detail verbatim,
which for the reachable 404 is the backend's bare English "Unknown
playlist"; only a structured detail has been through localizeDetail, so
only a structured detail reaches the toast now.
Round 2 reviewed round 1's own fix diff, which is where a fix round does
its damage. It found 15 issues in 792 lines — a much higher density than
round 1 found in the whole epic — and most were regressions round 1
introduced. So this follows the rule rather than fighting it: revert the
fixes whose mechanism was wrong, narrow the ones whose guard was too
broad, and stop reaching for a third variant.
REVERTED, because the mechanism does not work:
- The `inert` menu item. Round 1 swapped a native `disabled` for
`aria-disabled` so a row's "why is this off" tooltip could be reached.
floating-ui's list navigation skips `aria-disabled="true"` exactly as it
skips `disabled` (isListIndexDisabled) — and AnchoredPopover's own
comment, written before this branch, already said so. The swap fixed
nothing and made the mouse worse: without native `disabled` the row took
pointer events, the full active highlight and DOM focus, then no-opped
on click. The underlying finding is logged where the prop was, unfixed
and honest: the reason has to go somewhere a disabled row is not.
- `useContextMenu` owning the focus restore. Turning floating-ui's
returnFocus off platform-wide traded one bug for two: Playlists spreads
triggerProps on a bare <tr>, so restoring to it is a spec no-op and
Escape stranded focus on <body>; and with dismissOnScroll now on, the
restore's plain focus() scrolled the row back into view, undoing the
scroll that closed the menu. Patching that needs three more guards on
one fix. The original bug — the rail's rename field dying on arrival —
is fixed at the field instead: it focuses from an effect, which is
scheduled after the microtask that was blurring it.
NARROWED, because the fix was right and the guard was not:
- PlexPlayer's Backspace deferred to `anyLayerAbove`, but the player's own
gear and tracks menus register layers via useDismiss — so Backspace
stopped closing them and did nothing at all. Only the cheat sheet and
the palette own that key, so it names them.
- `historyDepth = 0` also desynced the counter from overlays the
navigation does NOT unmount (About and ReleaseNotes live outside the
page column), leaving one Back to both navigate and close them. The
entries are marked buried and consumed silently by reconcile instead.
- The move's error toast fired for every status, so a transport fault
surfaced twice on top of req's own dialog. Scoped to 403/404.
- The palette's moved-pointer guard could not judge the FIRST move of a
session and went stale across a close. `e.movementX/Y` asks the browser
directly and needs no state.
- `useKeyScope("contextMenu")` runs once per hook, and the Playlists page
mounts two — so the cheat sheet rendered the chords twice under a
duplicated React key. The scope stack dedupes.
- The `enabled:hover:` sweep missed the half that lives in CSS, so a
disabled glass button still lifted. `.glass-hover:not(:disabled):hover`.
- ShareDialog's migrated buttons passed a colour className that the
variant's own colour class outranks under clsx, so Revoke hovered
neutral and a password-protected link looked unprotected. The colour
goes on the icon, as the Copy button beside them already did.
- `matchesShortcut` took an untyped id and ids are only unique within a
scope, so `back` would have answered for the wrong player. Keyed on
scope + id, both literal.
And the systemic one: round 2 reverted eight of round 1's behaviour fixes
as a single mutation and all 763 tests still passed. Round 1 tested the
three fixes that were easy to test and none of the rest. So the guards
with the widest blast radius are pinned now, each mutation-checked to
fail for its own reason: the global keymap's repeat/allowInInput/disabled
handling (a new dom test), the cheat sheet bound to the real
useContextMenu rather than a scope stub, the scope dedupe, the history
burial, and the Button accessible-name union — whose @ts-expect-error
fixtures turn a widened type into a red typecheck lane.
The chipLane contract test that round 1 rewrote to count per lane counted
the import line as a lane, giving every file one free mirror — so the
exact hole it was rewritten to close was still open. Mutation-checked
both ways this time.
The epic-end review found 32 verified issues. This is the first fix round:
the three highs, the user-facing mediums, and the tests that were passing
for the wrong reason. Docs and the remaining low-severity tail are next.
The boundary rule was the worst of them. Both `no-restricted-imports`
regexes were written in plain "..." literals, where `\.` is an unknown
escape that collapses to the any-char `.` — so the rule read like a
boundary and matched far less than one. `../../lib/api` (the barrel every
feature component imports) and `../../components/Feed` (the same file
`../Feed` names) both sailed through a green gate. String.raw, a third
pattern for the second spelling, and a vitest over ESLint.lintText so a
rule that silently matches nothing can never look like a clean codebase
again.
closeThenNavigate waited for exactly one reclaim popstate. With a second
Back-closable layer still mounted — a palette opened over the player —
the navigation unmounted that layer too, and its reconcile backed over
the entry the navigation had just pushed: "go to Channels" closed the
player and landed on the feed. It now waits for the LAST programmatic
back, then adopts the entries the navigation buried, and falls back after
250ms rather than hanging if no popstate is ever coming.
The rail's inline rename died on arrival. With no domReference, floating-
ui's returnFocus falls through to a module-global stack and restores some
unrelated element from an earlier popover — blurring the field into its
own commit-and-close, from the second popover of a session onward. The
context menu now owns the restore: back to the row, but only if the row
is still there and nothing else has claimed focus.
Also:
- Backspace under the cheat sheet stopped Plex playback and dropped to
the grid; `back` now defers to any layer above, like `escape` beside it.
- A move invalidated only the source list, so the target kept serving
pre-move contents for 30s while the rail already counted N+1.
- Every move error a user can reach is a 404, which `req` leaves to the
caller — so the row snapped back in silence and the shipped moveErrors
strings were unreachable.
- swallowClick armed on long-press but was disarmed only by the click it
was armed for; any press that produced no click ate the next tap on the
whole list.
- The context menu stayed parked over whatever row scrolled under it.
- overflow-clip on the chip lanes ate the focus ring's top and bottom.
- "Why is this off" rode on disabled rows, which show no tooltip and take
no focus: a new `inert` item keeps the reason readable.
- Shift+F10 was advertised app-wide but wired only on Playlists; it has
its own scope now, and useContextMenu matches through the shared parser
instead of its own literals.
- Mod+K could not close the palette, `h` machine-gunned on auto-repeat,
and both were bound above the branch that mounts their overlays.
- Palette options were in the tab order under aria-activedescendant, and
a scroll-synthesised mousemove stole the keyboard selection.
- The OSD live region was created together with its own text.
- Ending a rename stranded focus on <body>.
- The Button variants' hover halves are `enabled:` — the one migrated
site that had guarded its own lost the guard on the way in.
- HU builtinLocked named the list "Watch later" in a Hungarian sentence.
Tests: the chip-lane contract counted per FILE, so a second hand-rolled
lane in an already-compliant file was invisible; the closeThenNavigate
ordering test only passed on a popstate left behind by its neighbours and
failed alone, so it moved to its own file.
The Plex player had a shortcuts overlay nobody else did, so the YouTube
player's Space/F/arrows and the Plex browser's Backspace were documented
nowhere. ui/KeyboardHelp is that sheet for every surface, opened by a
global `h` - not the conventional `?`, which is a Shift chord on a
Hungarian layout, while `h` is the key this app's reader already has in
their fingers from the player.
It renders the shared table filtered to the scopes that are LIVE: a
surface registers its scope with useKeyScope while mounted, and only
while its keys really do something - PlexBrowse deactivates its own
scope inside the player, where its Backspace no-ops, so the sheet cannot
promise a "Back one level" the handler declines to perform. Same rule
for the `when` rows: ⇧←/⇧→ are listed only inside a series, an answer
the player publishes because the sheet cannot ask.
No new overlay primitive, after checking: Modal already portals into
document.fullscreenElement ?? document.body (so this works over a
fullscreen player), registers an Escape layer, traps focus and handles
browser Back. Third time this epic that the thing was already built.
Shift+F10 is in the table as `handledLocally` - documented and sharing
the chord parser, but matched by useContextMenu on the focused row.
dispatch neither routes it nor demands a handler for it, so the
exhaustiveness check stays honest about what it actually covers.
Also: A/S have cycled the Plex audio and subtitle tracks since E5, but
silently - the only way to see where you landed was to open the tracks
menu, the exact round-trip the shortcut exists to avoid. cycleAudio and
cycleSubtitle now return the track they moved to, and the player flashes
it over the video.
Six surfaces each owned a private keydown listener with its own key
literals and its own idea of "am I typing?", and they had drifted:
PlexPlayer matched `case "a"` without checking modifiers, so Ctrl+A
(select all) cycled the audio track, and its typing guard missed
<select> and contentEditable where the other two surfaces covered both.
lib/keymap.ts is a TABLE, not a dispatcher. Each surface keeps its own
listener and closures - PlexPlayer's refs are not worth re-plumbing -
but the chords, the modifier rules and the typing guard live in one
place, and dispatch() binds a scope's rows to handlers through a mapped
type, so a row without a handler (or a handler without a row) is a tsc
error rather than a comment someone forgets.
Matching is now exact on modifiers, which is what kills the Ctrl+A bug
and lets Shift+arrow be its own row beside the bare arrow. Escape opts
out of the typing guard (allowInInput) so it stays the way out of a
player's own subtitle-offset field.
The player's shortcut sheet renders helpLines() instead of a hand-copied
array beside the handler - the two used to be edited separately, which
is how a sheet ends up documenting a key that no longer exists. Labels
move to the new `keys` i18n namespace (EN + HU), and a test asserts
every row is named in both.
Our HLS is a growing EVENT playlist with no ENDLIST, which hls.js reads as
LIVE. Its default startPosition (-1) then starts at the live edge and hugs it
(no forward buffer → rebuffer spinners), and on a resume/seek session the
segments' absolute PTS (-copyts, offset by the server's -ss) don't line up
with the live-edge timeline → 0 fragments load → permanent stall. The
seek-restart model makes fragment 0 the requested content position, so start
VOD-style at 0 and buffer forward. Fixes the transcode playback stall/rebuffer
found in 0.78.2 UAT.
Upgrade mode-3's 10-bit/HDR branch from "route to software libx264" (BUG 1's
behaviour) to a full-GPU pipeline: VAAPI decode → OpenCL tonemap (hable,
HDR10/HLG → bt709 SDR nv12) → h264_vaapi. Gen 9.5 (UHD 630) can't tonemap in
the VAAPI VPP (tonemap_vaapi fails), so OpenCL is the only HW route — proven on
the prod iGPU at ~4.2x realtime, ~1 CPU core.
- stream.py: `_select_encode` returns (hw, tonemap); a 10-bit/HDR HW-decodable
source picks the tonemap graph when the OpenCL runtime is present, else drops
to software. `_opencl_present` gates on a registered ICD. `_venc_opts` /
`_hwaccel_in_opts` gain the tonemap variant (named vaapi+opencl devices,
`-filter_hw_device ocl`, hwmap→tonemap_opencl→hwmap reverse). `_want_hw` kept
as the hw-half wrapper. The runtime HW→SW fallback still backstops a session
that emits no segment.
- Dockerfile: add the Intel NEO OpenCL runtime (intel-opencl-icd). It was removed
from Debian trixie, so it's pulled from bookworm (22.43 still supports Gen 9.5;
the .deb runs on the trixie base — verified live). Harmless without /dev/dri.
- route + FE: thread `tonemap` through the session response; the stats overlay
mode row reads "transcode · GPU · HDR→SDR" for the tonemap path.
- tests: tonemap command branch, the (hw, tonemap) selection matrix (auto/on ×
8-bit/10-bit × opencl), start_session tonemap wiring, and the fmtMode tag.
Eight verified findings from the sprint deep-review:
- Transcode cap: _enforce_cap now counts only transcode sessions, so a
live remux (stream-copy) stream can no longer be LRU-evicted mid-play —
matching the config comment + the HU/EN Config hint that promise remux
is uncapped.
- Player Back: the browser/mouse Back interceptor (BackClose) now also
mounts for the stats overlay and up-next card, so Back closes them first
instead of ejecting the whole player (keyboard/Esc already did).
- Honest cold-start: loadSession clears sessionIsTranscode up front, so a
transcode->direct/remux switch no longer flashes a stale "Preparing
stream..." during the session request.
- Stats overlay a11y: the close (X) buttons in the stats, help and info
overlays now read as "Close"/"Bezaras" (new plex.player.close key), not
"Back"; dropped aria-live from the per-second stat grid so a screen
reader is not flooded with telemetry.
Tests: route-level transcode routing (transcode item -> HLS re-encode
session, remux stays copy, direct opens none); start_session threading the
preset/crf/max_height knobs + hw/preexec into the ffmpeg argv; UpNextCard
countdown auto-advance, Play-now, Cancel and the S?-E? heading fallback.
Also brings UpNextCard.tsx / plexStats.ts / plexStats.test.ts to prettier.
Surface the transcode session's runtime facts in the player. The session
response's transcode/hw flags now thread through: the stats overlay's mode row
reads "transcode · GPU" (VAAPI) or "transcode · CPU" (libx264) — the at-a-glance
confirmation of which S1 path engaged — and the initial-load overlay says
"Preparing stream…" instead of "Loading…" for a transcode session, since its
first segment is genuinely slower than a remux and shouldn't read as a hang.
fmtMode (pure, unit-tested) appends the encoder only for a transcode with a known
encoder. i18n HU/EN for the new preparing label. Browser-UAT'd on the XviD
fixture: mode row reads "transcode · CPU" live (software, no render node here).
At episode end, when a next item exists and the pref is on, show an up-next
card (poster + title + ~10s countdown + Cancel) instead of the old instant jump,
so binge stays in the viewer's control (D6-A). Countdown or Play now advances;
Cancel/✕ dismisses and stays on the finished frame. Lives only on the
play-to-the-end path — credits-auto-skip advances earlier and never reaches the
card, so the two reconcile by construction (no double-advance). New pref
'upNext' (default on) with a Playback-tab toggle; the card closes on item change
and joins the Back/Escape cascade. i18n HU/EN.
Add a toggleable stats overlay to the Plex player: active mode
(direct/remux/transcode), transport, resolution, codec, bitrate, HLS level,
dropped frames, buffer-ahead, session offset and position — sampled once a
second off the live videoRef + hlsRef (no data layer). Non-modal corner panel,
aria-live polite, dismissable; toggled with 'd', a gauge control-bar button, or
its X. Number-crunching lives in a pure, unit-tested plexStats module. Doubles
as the S1 transcode-verification surface. i18n HU/EN.
Add discoverable rewind-10 / forward-10 controls to the Plex player control
bar, flanking play/pause (D5-A). New Skip10 glyph: an outline circular arrow
with a centred '10', deliberately distinct from the episode-level
SkipBack/SkipForward and from Restart's plain RotateCcw. Reuses seekTo; mirrors
the keyboard ←/→ (seekStep=10). i18n HU/EN aria-labels.
Deep-review of the quickfix batch surfaced two data/preference-loss bugs plus
smaller items; all fixed with tests.
- watch_sync _pull_apply: don't let the Plex-authoritative pull overwrite a DIRTY
local row (a change whose immediate push failed) — that stranded the value
forever since _repush_dirty only retries dirty rows. Leave it dirty for re-push.
- PlexProvider / ChannelsProvider: a partial shared link opened in a fresh tab
persisted mount-time DEFAULTS over the account's stored scope/sort/filters
(base read before the account was pinned). Persist/keep only URL-present dims;
re-hydrate the omitted ones from storage.
- playback resume write-back: guard against out-of-order delivery (a superseded
older push now drops) and evict the throttle map so it can't grow unbounded.
- PlexPlayer: announce mid-playback buffering to assistive tech (role=status +
sr-only label); new plex.player.buffering (HU+EN).
- columnSort: hoist the shared key:dir sort codec used by both URL-state modules.
- Tests: dirty-guard + flag-settle + per-key throttle scoping + superseded-push
(backend); hold-repeat/suppress-click/unmount + partial-link no-clobber +
refetch-doesn't-clobber-undo (frontend). Backend pytest 475, FE vitest 519.
The Plex player stalled ~2s a few seconds into every auto-advanced next
episode. Root cause: each auto-advance spins up a cold ffmpeg HLS remux
session, and for a fresh start (start_s==0) start_session returned
immediately with zero buffer — hls.js drained the first segment, then
rebuffered waiting for the still-warming ffmpeg to write the next one.
Once ffmpeg gets ahead (stream-copy is faster than realtime) it never
stalls again.
- stream.py: before reporting a session ready, wait for the first
_WARMUP_SEGMENTS (3) video segments to exist — the seg0 wait is now
unconditional (was start_s>0 only) and warm-up runs for all starts.
Best-effort and bounded: stop at the first segment not ready in time.
- PlexPlayer.tsx: a loading/buffering spinner. The existing overlay only
covered the INITIAL load (!ready); add a `buffering` state driven by the
video's waiting/stalled/seeking (set) and playing/canplay/seeked/pause
(clear) events so a mid-playback stall or in-flight seek reads as
"loading" instead of a frozen frame, and surface a fatal loadError even
once playback has started. Spinner uses the shared Loader2.
Merges roadmap E6·item1 (spinner while loading/seeking) into this fix,
since the server-side warm-up deliberately holds ready a beat longer.
Tests: 3 new in test_plex_stream.py (warm-up waits for the first few
segments; stops at the first missing one; no warm-up when seg0 never
arrives). Gate: backend pytest 460 (Docker lane), FE tsc/lint/knip/
prettier + vitest 483.
UX-8: one Escape must close only the topmost layer, never both an open
popover AND the player beneath it. The popover's Escape listener is on
`document` and fires before the player's on `window`; closing the popover
flushes its layer off the shared stack synchronously, so the player's
`anyLayerAbove()` lookup read "nothing above" and closed too. Add a
consumed-event flag to layerStack (`markEscapeHandled`/`escapeHandled`):
the acting topmost layer stamps the shared KeyboardEvent, and both players
check the stamp before the (now-mutated) stack. Immune to listener order
and mid-dispatch stack mutation. Cover with layerStack.dom.test.ts.
UX-7: the E4 `.reveal` sweep already covered the card action rows; two
residual hover-only traps remained — invisible-but-tappable/Tab-focusable
on touch. Add a group-agnostic `.reveal-hover` recipe (display:none under
`(hover: none)`) paired with each site's own hover/focus-within reveal:
- VideoCard play/Continue/Restart overlay: also reveal on
group-focus-within (keyboard), and drop it on touch — the card tap
already plays/resumes, so no permanent scrim over every mobile thumbnail.
- Carousel prev/next arrows: gone on touch, where the strip swipes.
Found by the HU-overlay E2E: PlexPlayer fetched qk.plexItem(id) WITHOUT lang,
so the in-player title/summary/rich detail stayed English even in HU mode while
the grid showed Hungarian (e.g. the player header read "127 Hours" over a grid
of "127 óra"). The S6 FE pass threaded lang through the grid, facets, info-page
and show queries but missed the player's own detail fetch. Now keyed by
plexLang() like the rest, so the player matches the active language.
FE gate: tsc / knip / lint clean; queryKeys tests pass. Verified live — the
player header now reads "127 óra" in HU mode.
Volume-consolidation (conservative — no behaviour or prefs-format change):
- playerVolume.ts is now scale-generic (a VolumeScale parameterises range /
default / integer rounding). YT_SCALE (0-100 ints) and PLEX_SCALE (0..1
float) bind the same tested core; the YouTube wrappers keep the original API
so the existing tests are unchanged.
- The Plex usePlayerVolume hook drops its bespoke clamp01 arithmetic for the
shared normalizeVolumeIn / stepVolumeIn at PLEX_SCALE. Side effect (a latent
bug fix): a corrupt persisted volume now falls back to the default instead of
a bare clamp silently muting it. The separate .muted flag stays Plex-only.
- clamp01 stays for the seek-bar fraction (its non-volume use).
- Tests: the generic core is covered at both scales (float no-round, corrupt ->
default, step/clamp) — the Plex-side arithmetic finally has unit coverage.
PlexPlayerSettings extraction (verbatim JSX move, behaviour-equivalent):
- The ~240-line gear/tuning panel (sync / playback / subtitle / clock tabs)
moves out of PlexPlayer into PlexPlayerSettings.tsx (a pure prefs+patchPrefs
view owning no state). PlexPlayer shrinks 1402 -> 1173 lines; Slider/Toggle
imports it no longer uses are dropped.
Gate green (tsc / eslint-0 / prettier / knip / vitest 83 / pytest 234). Dev
server smoke: app builds + loads clean, no console errors. The interactive
settings panel needs a playing Plex video (unreachable on localdev) -> owed to
the user's UAT.
Two behaviour-equivalent cleanups from the review:
- Drop the redundant per-render `audioRef.current = audioOrd` mirror in
PlexPlayer: the only two setAudioOrd callers (usePlexTracks.changeAudio
+ the detail effect) already set audioRef synchronously, so the mirror
never carried a value they hadn't written.
- Narrow the time-tracking effect deps from [id, patchPrefs] to [id];
patchPrefs was a vestigial, unused dependency (eslint confirms [id] is
the complete set).
The third finding (usePlayerVolume duplicating playerVolume.ts) is the
tracked R12 volume-consolidation sprint, not a review fix.
S4a-4a of the PlexPlayer decomposition. Move audio/subtitle track
selection + subtitle rendering into usePlexTracks: the selected-ordinal
state, change/cycle audio+subtitle, the debounced sub-offset, the audio
A/V-sync offset, and the two subtitle effects (force-showing the mounted
track + positioning cues at the user's subPos). The shared media refs
(video/hls/audio/aoff/abs/multiAudio) stay component-owned and are passed
in, since loadSession + the detail effect write audioRef too; absRef is
hoisted above the hook call for that. No behaviour change; verified by
self-E2E (subtitle on/off via 'S' mounts/removes the <track>, mode
'showing', VTT 804 cues all positioned to line=subPos, subLang persists;
playback uninterrupted). Audio switch is single-track here so cycleAudio
correctly no-ops.
S4a-3b of the PlexPlayer decomposition. Move the intro/credits auto-skip
machinery (activeMarker detection, the skipProgress countdown, doSkip,
cancelAutoSkip + the running-interval and reset-on-id effects) into
useAutoSkip. The hook returns skipProgressRef / cancelAutoSkipRef — the
forward-declaration bridge the earlier keyboard/back handlers read to
cancel a running countdown; those two stable refs are now listed in the
backIntent/keyboard dep arrays (they were provably-stable local refs
before, params now). No behaviour change; verified by self-E2E (credits
marker detected -> countdown fill renders + advances while playing ->
auto-skip fires -> item marked Watched + player closes).
S4a-3a of the PlexPlayer decomposition. Move two byte-equivalent
behaviours into lib/plexPlayerHooks.ts: usePlexProgress (the 10s
interval flush + pause/pagehide beacon + reopen cache-patch checkpoint;
absRef stays in the component since seek/skip/ended mutate it too) and
usePlayerVolume (HTML5 <video> 0..1 volume + independent mute, persisted
+ re-applied per session). usePlayerVolume is the seam the later R12
volume-consolidation sprint will rework to share playerVolume.ts's
tested pure core with the YouTube player. No behaviour change; verified
by self-E2E (volume-bar sets + persists video.volume, 'm' toggles mute
keeping the level, pause fires plexProgress 200).
S4a-2 of the PlexPlayer decomposition. Move three self-contained,
byte-equivalent behaviours into lib/plexPlayerHooks.ts: useWallClock
(15s top-bar clock), useFullscreen (wrapper fs toggle + reactive flag),
and useControlsVisibility (auto-hiding control bar + wake). The ref
mirrors the hooks read (barAutoHide/menuOpen/video-paused) stay in the
component and are passed in, so the timer sees fresh state. No behaviour
change; verified by self-E2E (real HLS playback: clock renders, controls
auto-hide after 3s while playing, wake restores + stays while paused).
S4a-1 of the PlexPlayer decomposition. Move the module-level pure
helpers (prefs types/defaults, track-language resolution, subtitle
shadow CSS, wall-clock formatting, HLS_SUB_LAG, fmt) to
lib/plexPlayerPrefs.ts and the presentational building blocks
(Ctrl/Slider/Toggle/PanelHead) to plexPlayerControls.tsx. No hooks or
behaviour touched; PlexPlayer.tsx 1889->1661.
1A ChannelPage "Full history" button no longer wraps (whitespace-nowrap).
1B Extract the manager's press-and-hold priority stepper into a shared
PriorityStepper and add it to the channel detail header (subscribed, non-demo);
backend channel_detail now returns the per-subscription `priority`.
2 Unify YouTube + Plex media cards onto one look/behaviour: drop the whole-card
hover LIFT (VideoCard) — which also fixes the clipped-top bug — and instead the
inner thumbnail/poster ZOOMS on hover (both); give the flat Plex posters the same
glass-card 3D depth + accent hover border as the feed cards (shadow-md is a
transparent no-op here, so it's glass-card that provides the depth); clamp Plex
card titles to a fixed 2-line height so 1- vs 2-line titles align.
3 Nav rail is now always a slim icon-only column, flush to the left edge; each icon
reveals a floating name label on hover (Tooltip gains `always` + `openDelay`, and
a right side). Removed the pin/unpin toggle, the expand-on-hover mode and the
navCollapsed pref plumbing.
4 Scope the side-panel peek-open HOVER to the tab handle + the card (not the whole
full-height slot next to the rail), so approaching the nav no longer pops the
filter open; focus stays container-wide. useHoverFocusWithin gains a hover-out
grace period + split hover/focus handler groups.
5 Plex art backdrop fills the whole viewport (a fixed inset-0 layer behind the
transparent app) instead of only the inset <main> box.
6 Plex season binge continues across seasons: the season view's play queue now
spans the whole show (allKeys), so a season's last episode rolls into the next
season's first (seasonKeys stays for "add season to playlist").
7 When a Plex video/episode ends with nothing queued next, the player returns to
the previous page (onEnded → onClose) instead of stranding a black screen.
The ~124 px-frozen text-[Npx] labels didn't track the Settings font-scale slider
while their rem-sized neighbours did. Add text-2xs (0.6875rem) / text-3xs
(0.625rem) font-size-ONLY tokens (no paired line-height -> matches the arbitrary
they replace, zero vertical-rhythm shift) and codemod every text-[Npx] to a rem
class (rare 8/9/12/13px -> rem arbitraries).
Also: --hdr-h 68px -> 4.25rem so header content clears the rem-sized pills at
scale 1.3 instead of sliding under; NavSidebar/SidePanel slot widths + their
icon/badge px sizes go rem so the rails keep pace with their (now-scaling)
labels; a w-24/h-[54px] thumb -> h-13.5 to preserve 16:9 at scale. Media
thumbnails, page-width caps and fixed container widths deliberately stay px.
Verified via getComputedStyle at scale 1.06/1.3: text-2xs 11.66->14.3px, header
clearance 72->88px, nav slot 246->302px — all scale proportionally.
Add theme-scoped --danger/--warn/--success (+ -fg) tokens wired into the v4
@theme inline, replacing ~110 raw red/amber/emerald/green palette refs across
34 files. Fixes light-theme contrast failures (fixed Tailwind shades fell to
~1.7-3:1 on white). Status colors are scheme-independent — recognised by hue,
shifting only by theme to hold contrast (bright on dark bg, darkened on light).
Solid fills use the paired -fg text; @source inline guarantees the full -fg set
exists. IMDb rating gold kept as a deliberate decorative non-status exception.
Root-fixes the opacity-modifier seed bug behind R10 S0: the theme colors were
bare `var(--x)` hex with no <alpha-value>, so `bg-accent/40`, `ring-accent/40`,
`border-border/60`, `text-muted/70`, … (~99 sites across 45 files) emitted no
CSS rule at all — latent no-ops. v4 implements the opacity modifier via
`color-mix`, so the modifier now works with the existing hex vars, no per-scheme
token duplication needed.
- `npx @tailwindcss/upgrade`: `@tailwind` directives → `@import "tailwindcss"`,
JS config → CSS `@theme` (colors, --font-sans, the z-index tokens preserved),
classname renames (rounded/shadow/blur scale, outline-none→outline-hidden,
ring→ring-2) across 56 files; PostCSS plugin → @tailwindcss/postcss,
autoprefixer removed (v4 prefixes for you).
- `@theme` → `@theme inline` (load-bearing): the color vars are redefined at
runtime under html[data-scheme]/[data-theme]; inline resolves them at the use
site so per-scheme opacity tints track the active scheme (the shadcn v4 pattern).
- Revert one codemod false-positive: it rewrote the English word "rounded" →
"rounded-sm" inside a releaseNotes.ts prose sentence.
Gate green: tsc(app+node) / eslint 0-err / prettier / vitest 79 / knip.
Verified in-browser: bg-accent/15 now computes a scheme-tracking color at 0.15
alpha across all 6 schemes × 2 themes; the hand-rolled glass border/shadow/
radius/backdrop-filter render unchanged under v4.
Replace the three mutually-unaware Escape systems — Modal's private modalStack,
the popovers' document listeners with a blanket stopPropagation, and the two
players' modalCount() checks — with one shared lib/layerStack.ts. Every open
dismissable layer registers in open order; a layer acts on Escape only when it
is topmost, and reads anyLayerAbove() to decide whether to defer. Coordination
is now a stack lookup, not event-propagation order, so it no longer depends on
which listener happens to fire first.
- Modal + useDismiss register via the stack; useDismiss drops the blanket
stopPropagation (its documented side effect: it swallowed a player's own
Escape while any popover was open).
- Both players register as a layer and defer via anyLayerAbove() instead of
modalCount(); PlayerModal checks it before its maximise/fullscreen steps.
A player MENU is a layer too, so it closes first and the next Escape reaches
the auto-skip-cancel — fixing the documented PlexPlayer bug.
- AnchoredPopover routes non-hover Escape through the stack (floating-ui's own
escapeKey off there) and registers as a layer, so a player/modal underneath
defers once S4 migrates the in-player menus. FloatingTree still owns
popover-vs-popover ordering; the stack adds the cross-type ordering.
Removes all 13 remaining `any`s and flips the ESLint rule from off to error so
new ones can't creep back:
- api.ts prefs/data JSON bags: Record<string, any> -> Record<string, unknown>,
narrowed at consumers (App language guard; NotificationsPanel shapes the
notification `data` per type behind its existing typeof guards)
- catch (e: any) -> catch (e) + `e instanceof HttpError` narrowing
(SettingsPanel, SetupWizard, PlexPlayer)
- the YouTube IFrame API boundary: new lib/youtube.ts types (YTPlayer/
YTPlayerEvent/YTNamespace + a Window augmentation) replace the 6 player anys;
tsc surfaced the exact method surface to declare
- NotificationsPanel `t` props: any-options -> i18next TFunction
Gate green: typecheck (app+node+e2e), eslint 0 errors (rule now enforced),
prettier, 74 vitest.
Extend lib/queryKeys.ts to the remaining ~34 key roots and migrate every
remaining literal (queryKey: [...] AND the positional useLiveQuery keys) in
36 files. All ~200 React Query keys across the app now route through the
factory; zero raw key literals remain outside queryKeys.ts.
The heterogeneous composite plex keys (plex-library/facets/collections/
playlists carry a filters object, a ratingKeys array, or a union/group
discriminator) take a variadic pass-through — the factory centralizes the
ROOT string; single-id keys stay precisely typed. Nullable keys (thread,
playlist, ytSearch) accept null so a null segment is preserved.
Pure substitution — byte-identical key arrays, so TanStack prefix matching
is unchanged. Gate green: typecheck (app+node+e2e), eslint (0 err),
prettier, 68 vitest. Runtime-verified on a fresh dev server: Feed + Plex
modules load, feed/facets/tags/my-status/plex-library/plex-facets/
plex-collections/plex-playlists all 200, full UI renders.
The worst one was, again, the previous round's own fix.
- Round 7 stopped awaiting the HLS sweep so startup could serve immediately — which
also made it run CONCURRENTLY with playback, and `sweep_orphans` snapshotted the
live-session set BEFORE listing the root and rmtree'd outside the lock. Since
`start_session` reuses a directory path for the same (key, offset, tag), a stale
name can become a live session at any moment. Two guards now: nothing whose mtime
is newer than this process's start is ever deleted, and the live check + rmtree
happen together under the lock, one directory at a time.
- The sweep task is awaited after cancel, so shutdown can't leave it pending.
- `_rate_limiter` used a truthiness test on its timestamp, so a `now()` of exactly
0.0 read as "never ran" — unreachable in production, but `now` exists to be
injected and a test clock starting at 0 is the obvious choice.
- The RSS total-outage error now carries the first underlying error: "all N channels
failed" is also what ONE dead feed id looks like on a small instance, and a red
card the user can't act on is its own kind of dishonest.
- The WebKit fullscreen handling moved into `lib/fullscreen` and PlexPlayer uses it
too — it had the same unprefixed-only reads in three places, including an Escape
branch that would have closed the whole player instead of leaving fullscreen.
- The `--max` CSS comment claimed `inset: 0` did the sizing; Tailwind's `w-full` is
still on the element and wins. Comment now states the real mechanism.
Verified in real Chrome (the pane can't composite or do native fullscreen):
- F fills the 1920x889 viewport; the exit pill renders top-right on the letterbox
band and overlaps NO YouTube control — the collision this round suspected does not
reproduce in this embed (its own buttons sit bottom-left / bottom-right).
- Four real ArrowDown presses while maximised: persisted volume 100 -> 80, with the
level flash visible on screen.
- A real click on the exit pill un-maximises and returns focus to the card.
- lib/fullscreen driven from a real click: enter (innerHeight 889 -> 1024), the
change listener fires exactly once per transition, exit returns to 889.
Backend suite 150 -> 158; the new sweep tests are mutation-checked, and they read
their cutoff from the filesystem rather than a clock (the container VM and the
bind-mounted host disagree by enough to make a clock-based assertion flaky).
Three player/overlay interaction fixes (PlexPlayer changes share a file, hence one
commit):
- U-C (Escape double-close): useDismiss's Escape now stopPropagation()s (its document
listener bubbles before a player's window listener, so one Escape closes only the
popover, not the popover AND the player). Modal exports modalCount(); both players'
Escape defers when a Modal is open above them, so Escape closes the dialog, not the
player under it and then the dialog.
- U-3.1.4: PlexPlayer's Space now defers to a focused BUTTON/A (e.g. "Skip intro")
instead of toggling playback — matching PlayerModal.
- U-3.3.12: the audio/subtitle menus used theme-adaptive glass-menu + forced
text-white (white-on-white in light theme); they now use the player's own explicit
dark panel (border-white/15 + bg-neutral-900/95), readable in both themes.
Enable noUncheckedIndexedAccess and fix all 98 call sites it surfaced across 18
files. Every fix is behaviour-preserving — the flag flagged reads TypeScript
couldn't prove in-bounds, all of which were already guarded by a length/index check,
a findIndex result, or a non-empty invariant:
- read-once so a ternary's narrowing sticks (Feed overrides, linkify mention);
- non-null assertion AFTER an existing guard (PlayerModal/VideoEditor queue+cut-list
indexing, modules step, useUndoable stacks) with a comment stating the invariant;
- nullish fallback where undefined is a real possibility (GlassTuner slider ?? def,
ConfigPanel active group ?? [], descriptionLinks id ?? null);
- optional chaining where the entry genuinely can be absent (PlexBrowse IO entry);
- a non-empty tuple type for the RELATIVE_UNITS constant (encodes "always has [0]").
The single highest-leverage fix: PlayerModal's `active` became `queue?.[index] ??
video` (always Video), clearing 34 of the 45 errors in that file at once. Verified:
tsc/eslint/prettier clean, 56 vitest + 34 pytest + 17/17 e2e green (the e2e suite
exercises the feed/player/channel components touched here).
Prettier 3 (pinned), printWidth 100, double quotes — chosen to match the existing
hand-formatting and minimise reflow. This commit is ONLY the mechanical format pass
(`prettier --write`) plus the config/ignore/scripts, isolated so it never pollutes a
feature diff. Verified behaviour-neutral: typecheck, eslint (0 errors), and vitest
all green before and after. See .git-blame-ignore-revs — `git blame` skips this sha.
Bug 5: in browser fullscreen the video stayed small and centred instead
of filling the screen. Two causes:
1. The wrapper carries 80vw/80vh + transform:scale(1.25) for the windowed
HTPC look; in native FS that scaled layer never filled. Size the
wrapper to 100vw/100vh with NO transform while fs is on.
2. The real culprit: the <video> was `w-auto h-auto max-h-full max-w-full`,
which caps at the stream's intrinsic resolution (720p -> 1280x720) and
never scales UP. The windowed scale(1.25) hid this. Switch to
`w-full h-full object-contain` so it fills the wrapper by aspect.
Verified visually in real Chrome native fullscreen: video rect 1920x1024
(fills), was 1280x720 centred.
Replace ~48 magic z-index values (z-10..z-[9999] + 2 inline zIndex) with named
tailwind zIndex tokens (base/menu/chrome/paneltab/panel/rail/overlay/popover/
tooltip/tuner). Values are UNCHANGED — a rename, not a renumber — so layering is
identical; the names give one source of truth for the stack.