Commit Graph
623 Commits
Author SHA1 Message Date
peter 45654fc7ec fix(player): F maximises without the Fullscreen API, so YouTube's own controls work
Root cause, measured in Chrome/Brave/Firefox alike (so: spec behaviour, not a
browser bug): a cross-origin embed reads its fullscreen state from ITS OWN
document. While we hold the browser's fullscreen lock — on our wrapper OR on the
iframe element — the YouTube player never learns it is fullscreen, so its own
fullscreen button stays in the "enter" state and clicking it changes nothing
visible. Every other YouTube control works, which is exactly what the user saw.

So we stop taking the lock: F now toggles a CSS layer (`player-stage--max`) that
fills the viewport. The page stays non-fullscreen, so the embed's own fullscreen
button behaves natively, and our own overlays and shortcuts keep working because
our DOM is what's on screen. Escape steps out of maximise before closing.

Verified end to end in real Chrome:
- F -> stage 1920x889, document.fullscreenElement null (no lock taken)
- YouTube's own fullscreen button -> real fullscreen (innerHeight 889 -> 1024,
  fullscreenElement becomes the IFRAME) and its icon flips to "exit"
- clicking it again -> back to the maximised view, our class still applied
- wheel volume while maximised: 100 -> 85, with the level flash visible

Drops the "YouTube fullscreen" toolbar button added earlier today: with the lock
gone, YouTube's own button does that job correctly.
2026-07-24 02:46:42 +02:00
peter d8d82e47d0 fix(player): give YouTube's own fullscreen a working path + round-6 findings
The reported bug: after F, the embed's own fullscreen button is dead. Root cause
measured in Chrome — the browser's fullscreen lock sits on OUR wrapper, so
YouTube's button would need a nested request from a cross-origin frame. Handing
the lock to the iframe fixes it, but `iframe.requestFullscreen()` only resolves
from a CLICK (from a key press the promise stays pending forever, and awaiting
it spends the activation so a fallback is denied too).

So: F keeps fullscreening our wrapper (unchanged, our overlays stay visible) and
a new toolbar button hands the lock to the iframe — verified in real Chrome:
document.fullscreenElement is the IFRAME at 1920x1024, i.e. a plain YouTube
fullscreen where its own controls work.

Round-6 review findings, all fixed:
- one _rate_limiter + one _cancel_poll shared by the yt-dlp hook and run_ffmpeg
  (was three hand-rolled throttles; cancel latency differed 2s vs 1s)
- stepVolume takes {delta, fallback} — two adjacent number args were silently
  transposable
- volume gestures before the player is ready are honoured instead of dropped
- normalizeVolume's fallback is typed unknown (its guard was unreachable) and
  applyVolume takes a number again
- the throttle tests inject a clock instead of patching global time.monotonic
2026-07-24 02:16:58 +02:00
peter c4942eb590 fix(r5): address the fifth /code-review high round — 6 findings
- run_ffmpeg stops the process from ONE exit path (`except BaseException`), so
  an exception out of on_progress (a DB write) can no longer orphan a live
  ffmpeg after a 10s join on its pipes; _stop is now a no-op on a dead process
- both run_ffmpeg callbacks are throttled to ~1s (they were a DB round-trip per
  output line: ~10^5 queries on a long re-encode), mirroring the download hook
- volume arithmetic extracted to lib/playerVolume with unit tests; a corrupt
  level now falls back to the LAST GOOD one instead of full blast, and the
  sanitising happens where the stored value enters
- RSS is back to two except clauses (the shared tail already sits after the try)
- new tests: callback-exception cleanup, the two throttles; the fake Popen grew
  poll() and its wait count is now asserted

Mutation-checked: each new group goes red when its fix is reverted.
2026-07-24 01:50:50 +02:00
peter fc82c8b47a fix(r5): address the fourth /code-review high round — 6 findings
- Feed memoizes onClose (like Playlists already did): the inline closure re-ran
  PlayerModal's keydown effect on every Feed render, which since the debounce
  landed also flushed the volume write and re-stole focus
- the player reads its volume with readAccountMerged again — a partial stored
  object made the level undefined -> NaN -> a persisted null; applyVolume also
  refuses a non-finite level outright
- that read moved into a lazy useState initializer (was running per render)
- the two RSS except branches share one tail, so a counter can't drift again
- run_ffmpeg tests cover the SIGTERM->SIGKILL escalation and the lingering-
  process path; dropped an assertion that only restated the fixture
- storage.ts documents the actual { volume } shape (0 IS muted)
2026-07-24 00:45:42 +02:00
peter c0db80d333 fix(r5): address the third /code-review high round — 7 findings
- the sweep guard anchors the rating_key to digits: the round-2 widening had
  degenerated to "anything ending in _<digits>" (backup_2024, release_10)
- RSS counts apply-side failures too, so a read-only database no longer reports
  a wholly failed poll as "ok — new=0, failed=0"
- the player reads/writes its volume directly (readAccount/writeAccount): the
  persisted-object hook rendered nothing and cost a second write per settle
- new tests: the sweep guard's accept/reject table, and run_ffmpeg's threading
  (drain, cancel while silent, stall watchdog, stderr tail on a nonzero exit)
- the breaker sentinel is checked one way (isinstance) at all four sites
- run_rss_poll is typed dict[str, int]

All three new test groups were mutation-checked: reverting each fix turns them
red (or, for the cancel/stall pair, hangs — which is the bug itself).
2026-07-24 00:17:09 +02:00
peter 53ff61653d fix(r5): address the second /code-review high round — 7 findings
- the volume keys no longer defer to a scrollable card while in fullscreen
  (mirrored into a ref: the keydown handler is bound once and state would be stale)
- the debounced volume flush writes straight to storage; persisting inside a
  setState updater is not guaranteed to run for an unmounting component
- the HLS sweep also matches the original {key}_{start} directory naming, which
  is what the oldest leftovers on disk actually use
- run_rss_poll returns {new, failed} so an egress outage stops rendering as
  "ok — 0" in the scheduler card and the audit log; /api/sync/rss reports it too
- drop the unused poll_rss_channel (it silently gained a new exception)
- _Breaker.run is generic, so a call site keeps its item-id type
- test helper instead of the generator-throw idiom
2026-07-23 23:59:58 +02:00
peter 2a7f49c981 fix(r5): address the /code-review high round — 10 findings
- HLS sweep only deletes directories matching the session-name shape; the root
  is admin-configurable and may be a shared path
- playlist unwrap picks the first entry that actually downloaded, not entries[0]
- concat quoting keeps backslashes literal (ffmpeg treats them so inside '')
- ArrowUp/Down defer while the focused element can still scroll that way
- the staging fallback skips yt-dlp working files (.part-Frag/.ytdl/.temp/.fNNN)
- the boot HLS sweep runs off the event loop
- volume persistence is debounced (was a setItem per wheel notch) and a spin no
  longer loses notches to the iframe's lagging getVolume
- _Breaker.run owns the whole open/call/record protocol; four call sites shrink
- should_prune extracted and unit-tested, plus RSS error-vs-empty tests
- the worker exits non-zero when the schema never lands
2026-07-23 23:25:06 +02:00
peter 5130584d0a fix(player): R5 S3 — fullscreen controls, volume memory, Ctrl+wheel zoom
- the interaction overlay yields fully in fullscreen: YouTube only reveals its
  control bar while the iframe itself sees the mouse, so after pressing F the
  native fullscreen button was unreachable
- remember the volume per account (a YT.Player is created per video and always
  starts at 100), plus ArrowUp/ArrowDown volume keys that work in fullscreen
- Ctrl/Cmd+wheel falls through to the browser's zoom instead of the volume
2026-07-23 22:56:42 +02:00
peter 08049d2aa5 release: 0.53.0 — R4 auth funnel & abuse hardening 2026-07-23 04:27:20 +02:00
peter 10da69c1b9 fix(downloads): confirm before clearing a link password; fix placeholder
Review follow-up on the share-link password UI:
- Removing a link's password makes it publicly watchable, a protection
  downgrade; gate it behind a danger-confirm like the neighbouring Revoke,
  spelling out that anyone with the URL can then watch without a password.
- When setting a first password the input reused the create-form "Password
  (optional)" placeholder, but it's required here (Save stays disabled until
  non-empty); use the "Set a password" wording instead.
2026-07-23 04:15:39 +02:00
peter c8cbbb6189 feat(downloads): change/clear a share link's password from the UI
C-3.7 made a password rotation invalidate outstanding grants, but ShareDialog
only let you set a password at link creation — there was no way to rotate or
clear it afterward, so the protection wasn't reachable. Add a lock control to
each existing link row: set / change / remove the password via the existing
update_link endpoint (which bumps password_version and revokes old grants).
2026-07-23 04:03:11 +02:00
peter dce191f1c0 fix(messages): re-review — degrade gracefully when partner has no key
The decrypt-pass refactor hoisted partnerPub() above the loop and outside the
try, so a keyless partner (one who reset and hasn't re-set-up) or a fetch error
rejected the whole pass unhandled (setPlain never ran). Fetch the key lazily
inside the try and once, so an empty thread never fetches and a fetch failure
degrades to "can't decrypt" as before. Document the send-path staleness (a first
proactive send after a partner's key rotation uses the stale cached key) as a
known limitation alongside refreshPartnerPub.
2026-07-23 03:21:44 +02:00
peter 26e6562921 fix(messages): address S2 review — partner key refresh + reactive me
- On a decrypt failure, refresh the partner's cached public key (and drop the
  stale derived conversation key) once per pass and retry. A passphrase reset
  rotates a keypair, so a partner who cached the old public key would otherwise
  encrypt undecryptable messages until a reload; the first failed decrypt now
  self-heals reads AND future sends. (pubCache was assumed set-once.)
- KeyGate reads has_password via a reactive useQuery(['me'], enabled:false)
  passive observer instead of a one-shot getQueryData, so the reset password
  field stays correct even if me resolves after mount.
- Document the remaining multi-device staleness (a reset on another device
  leaves this one 'ready' with the dead key) as a known limitation in useKeyState.
2026-07-23 03:11:35 +02:00
peter 1119eb0218 feat(messages): R4 S2 — forgotten-passphrase reset for E2EE
The set-once key guard made a lost passphrase a permanent wall (409 on re-setup,
KeyGate offered only unlock/setup). Add an escape hatch:

- POST /api/messages/keys/reset drops the user's MessageKey so a new passphrase
  can be set. Because every conversation key is derived from the keypair,
  replacing it orphans all stored ciphertext in both directions, so the now-dead
  `user` messages are deleted too (clean slate; system messages untouched).
  Password accounts must re-verify (a hijacked session must not silently reset
  messaging and read future messages); Google-only accounts rely on the session
  + the client danger-confirm. Rate-limited per user.
- KeyGate gains a "Forgot passphrase?" path in unlock mode → a danger reset view
  (spells out the permanent history loss; a current-password field for password
  accounts) → resets and returns to a fresh setup.
- e2ee.resetLocal wipes this device's key + derived conversation keys + the
  IndexedDB copy; api.resetMessageKey; HU/EN strings.
2026-07-23 02:57:27 +02:00
peter bacef11e33 fix(auth): address second-round review findings
- OAuth-cancel feedback now keys on the live session, not link_uid: ANY
  signed-in cancel (add-another-account via /auth/login, link, or upgrade)
  redirects to /?oauth=cancelled and gets a neutral "Google sign-in was
  cancelled" toast — the add-account path set no marker and was silent, and
  the shared ?link= message wrongly called an upgrade "account linking"
- drop 127.0.0.1 from the dev OAuth host allowlist: only the localhost
  callbacks are registered in the Google console, so a 127.0.0.1 origin now
  falls back to the fixed callback instead of a redirect_uri_mismatch
- Welcome: extract the shared goResend handler (banner CTA + sign-in link) and
  merge the two adjacent signin-only blocks
2026-07-23 01:24:34 +02:00
peter c837b59373 fix(auth): address R4 S1 code-review findings
- link/upgrade OAuth cancel now redirects to /?link=cancelled (a channel App.tsx
  surfaces as a toast) instead of the pre-auth /?login=oauth_cancelled banner a
  signed-in user never sees; a plain sign-in cancel is unchanged
- the login banner (suspended / oauth_cancelled) is now state, cleared on the
  first deliberate action like the verify banner — no more stale lingering
- add a "Didn't get the verification email?" resend entry on the sign-in screen
  so a never-arrived verification mail isn't a dead end (was only reachable from
  the expired-link banner)
- unit-test _oauth_redirect_uri (dev-host derive, unknown-host fallback, prod
  ignores the Host header — the injection guard)
- collapse the 5-deep title/button ternaries into Record<Mode,string> maps
2026-07-23 01:04:09 +02:00
peter b4d2ad75e0 feat(dev): OAuth redirect returns to the origin that started it
A Google login started on the Vite dev server (:5173) always came back to the
fixed :8080 callback, because login/link/upgrade passed the static
settings.oauth_redirect_url. Derive the redirect_uri from the request origin
instead, but only in local dev and only for a known dev-host allowlist —
production still uses the fixed configured URL and never trusts the Host header
(host-injection guard). Set the Vite proxy to changeOrigin:false so it forwards
the real Host:localhost:5173 (it was rewriting it to the 127.0.0.1:8080 target).
Both callbacks must be registered in the Google console.
2026-07-23 00:48:20 +02:00
peter b761a448a8 fix(auth): dismiss the verify banner once the user acts
The verifyInvalid banner (a one-time entry notice) lingered on later screens
after the user clicked its "Send a new link" CTA and returned to sign-in.
Clear the verify state in reset(), so any deliberate action — mode switch,
CTA, or submit — dismisses it.
2026-07-23 00:34:11 +02:00
peter a851fb213b feat(auth): R4 S1 — fix auth dead ends
- password reset also sets email_verified (the mail proves the mailbox),
  rescuing an account that never clicked the verify link; is_active (admin
  approval) is deliberately left untouched
- add POST /auth/verify/resend (rate-limited, off-response-path, anti-enum)
  + a "resend" mode/CTA on the verifyInvalid banner, so an expired/lost
  verification link is no longer a dead end (re-register was a silent no-op)
- OAuth cancel/deny now redirects to /?login=oauth_cancelled with a friendly
  banner instead of a raw 400 JSON page; pop the oauth_link markers BEFORE the
  token exchange so an aborted link flow can't poison the next clean sign-in
2026-07-23 00:15:26 +02:00
peter e663247791 chore: prettier-format release notes 2026-07-22 04:52:37 +02:00
peter 745ba10f2e release: 0.52.0 — R3 honest states (loading/error across the app) 2026-07-22 04:51:32 +02:00
peter 4871bf4548 fix(ui): R3 review — give the Stats API-usage block its own loading/error state
The 'usage' query rendered both its loading and its error state as 'No usage' (the same false-empty
R3 targets), while the sibling 'status' query got a proper state. Split the fallback into
error(+retry) / loading / empty.
2026-07-22 04:49:40 +02:00
peter 1c6b7a9a5a feat(ui): R3 S3 — surface swallowed errors
- notifyYouTubeActionError: on any non-403 error, show the server's own reason (err.detail, e.g.
  "Not enough quota left today") instead of the caller's generic fallback, which was discarded
  (U-3.2.8). 403 still offers the Connect affordance.
- App boot error now offers a Retry (StateMessage) instead of a dead "Something went wrong" (U-4.2).
- deletePlaylist prefers the server's reason over a blanket "couldn't delete on YouTube" that
  misleads when the failure wasn't the YouTube side.
- Already covered in earlier sprints (verified): the live-search "Load more" already surfaces the
  quota error inline via err.detail, and ChannelPage subscribe/unsubscribe already route through
  notifyYouTubeActionError.
2026-07-22 04:43:02 +02:00
peter b5573b62d1 feat(ui): shared LoadingState — a centered accent spinner instead of plain loading text
Replaces the plain "Loading…" text in every R3 loading branch with a single shared LoadingState
(a spinning lucide Loader2 in the accent colour + label, centered, role=status/aria-live) — one
component so all loads read the same and can be swapped for skeletons later in one place. Applied
across Feed, Channels, ChannelDiscovery, AuditLog, NotificationsPanel, Messages, Scheduler, Stats,
PlexBrowse (grid + subviews), PlexPlaylistView, ConfigPanel, DownloadCenter, AdminUsers, ChatThread.
Left the small "load more" footer and lazy Suspense fallbacks as-is.
2026-07-22 04:35:02 +02:00
peter bf96318dea feat(ui): R3 S2 — loading rows for blank-flash pages, error paths for forever-loading ones
- Blank-flash (showed an empty state on first load before data): DownloadCenter (queue + library
  tabs), AdminUsers (roles + invites), ChatThread, PlaylistsRail now show a loading indicator
  (and an error+retry) instead of a false-empty flash — guarded on `&& !data` so live data isn't
  disturbed on refetch.
- Forever-loading (`isLoading || !data` loops forever on a failed fetch): ConfigPanel, the three
  PlexBrowse subviews (playlist/show/season), and Stats' admin dashboard get an `isError && !data`
  error+retry path before the loading branch.
- Playlists' detail pane already had an error path (isError+retry); its list lives in PlaylistsRail
  (now covered), so no change needed there.
2026-07-22 04:24:38 +02:00
peter a393fd2fb1 fix(ui): R3 S1 review fixes — don't blank data on transient refetch errors
- The honest-state branch now guards on `isError && !data`, so a background/window-focus refetch
  failure keeps already-visible content instead of replacing the list with a full error screen
  (regression the bare `isError` introduced). Applied to AuditLog, Channels, ChannelDiscovery,
  PlexBrowse, PlexPlaylistView, Messages(people), Feed — matching the guard already used in
  Messages(conversations)/NotificationsPanel/Scheduler.
- Drop the unused DataTable loading/error/onRetry props (every table page uses the outer
  StateMessage branch, not the props) — reverts DataTable to its simple empty block.
- StateMessage announces errors with role=alert/aria-live=assertive (was polite for all tones).
- Remove the now-unused common.empty string (en+hu), left over from the removed QueryState render-prop.
2026-07-22 04:14:46 +02:00
peter d9a08892a5 feat(ui): R3 S1 — honest error states across the pages
The error state didn't exist as a concept: a failed main query rendered as an "empty" list
("No channels" while the request actually died), so a backend restart read as "everything is
empty". Add the convention that an empty state may NEVER show while a query is erroring.

- New components/QueryState.tsx: shared StateMessage (loading / error+Retry, house style, with
  role=status/aria-live — the app had zero live regions).
- DataTable gains loading/error/onRetry props (suppresses empty-text on error).
- isError branch (before the empty branch) added across the false-empty pages: Feed (retry),
  Channels, ChannelDiscovery, AuditLog, NotificationsPanel, Messages (conversations + people),
  Scheduler, Stats, PlexBrowse grid, PlexPlaylistView. ChannelPage's main content is Feed, now
  covered. common.loadError/empty strings (en+hu).
2026-07-22 03:58:51 +02:00
peter 56111ceadf release: 0.51.1 — shell-safe names, re-download title refresh, GC orphan sweep 2026-07-22 03:18:36 +02:00
peter bce6175983 release: 0.51.0 — downloads correctness & UX (iOS playability, 720p, re-download, ASCII names) 2026-07-22 02:52:01 +02:00
peter e46f215ada feat(downloads): ASCII-slug names, universal browser playability, force re-download
Mini-epic "Downloads correctness & UX" (S1–S3), on top of the iOS-AV1 + 720p fixes.

S1 — naming & titles:
- ASCII-fold on-disk dir/file names AND the served (Content-Disposition) filename: no
  spaces (underscore-joined), no accents (á→a, ő→o, ß→ss), pure ASCII — portable and free
  of the `?` mojibake accented names show in non-UTF-8 tools. (Forward-only; existing files
  clean up via re-download.)
- Strip a leading social engagement prefix ("1.6M views · 66K reactions | …") from titles.

S2 — universal browser playability (generalizes the iOS re-encode):
- ensure_browser_playable guarantees the stored file is H.264 + AAC/MP3 in mp4 — the only
  combo every browser decodes. Re-encode just the offending stream(s) (video for AV1/VP9/HEVC,
  audio for Opus/etc), or remux when only the container is wrong. Gated on the mp4 compat
  profile (an explicit vcodec / non-mp4 custom profile opts out).

S3 — force re-download (preserves share links):
- POST /downloads/{job_id}/redownload keeps the job row so its public DownloadLink survives
  and resolves to the freshly-downloaded file; deletes the old file and re-fetches under the
  current rules. A failed re-download leaves the job in error (the intended broken-link case).
- Library "Re-download" action (icon + confirm), api method, hu/en strings.

Tests: naming/title-strip (test_naming), browser_transcode_flags matrix; existing
download_filename expectation updated for the underscore policy.
2026-07-22 02:04:27 +02:00
peter 8fd0049dce fix(downloads): make shared files iOS-playable + default to 720p
Shared /watch links showed a "broken play button" on iPad (any iOS
browser — all forced onto WebKit) while playing fine on desktop Chrome.
Root cause: the stored mp4's video codec was AV1 (confirmed via ffprobe),
which WebKit can't decode. The existing H.264 guard was only a
format_sort *preference*, so a non-YouTube source (Facebook) that ships
AV1 as its best video-only stream slipped through.

- B1: for the mp4 compat profile, prefer H.264 at the SELECTION level
  with a progressive fallback (bestvideo[vcodec^=avc1]+bestaudio /
  best[vcodec^=avc1] / anything) so an avc1 progressive is chosen over
  an AV1-only video-only stream.
- B2: re-encode the rare AV1/VP9-only result to H.264+AAC (+faststart)
  in the worker before storing, gated on the compat profile; corrects
  the asset codec metadata to match.
- Bump _SIG_VERSION 2->3 so cached AV1 assets re-derive.
- Default download preset is now 720p (migration 0058 re-seeds builtins
  720p-first; ProfileEditor BLANK 1080->720).
- Tests for the selector, compat opt-outs, and the transcode predicate.
2026-07-22 01:19:31 +02:00
peter 51781819db release: 0.50.0 — R1 quick-win sweep
Ships R1 (S1 backend safety, S2 frontend interaction, S3 i18n/a11y) plus the
code-review follow-ups (filename byte-cap, demo deep_requested guard, release-notes
highlight fallback). Mixed epic, so a real user-facing note: download-overwrite +
long-title fixes, Escape layering, Plex light-theme menus, toast timer, unsubscribe
error, modal-close i18n, toast aria-live; keyboard + hardening under chores.
2026-07-22 00:31:47 +02:00
peter a3c8341c76 fix(release-notes): ring the newest entry when the highlight version is unknown
/code-review (S1, finding 3): C-3.19 switched the banner from CURRENT_VERSION (always
RELEASE_NOTES[0]) to FRONTEND_VERSION (VITE_APP_VERSION), which is "dev" in the Vite
dev server and can drift from the notes — so the modal highlighted nothing. The modal
(which already imports RELEASE_NOTES, keeping App decoupled) now falls back to the
newest note when the passed version isn't listed.
2026-07-22 00:00:44 +02:00
peter e832b401ac fix(a11y): don't force atomic re-announce of the whole toast stack
/code-review caught that role="status" on the toast container implies
aria-atomic="true", so every new toast made a screen reader re-read the entire
stack instead of just the new one — the opposite of the U-F fix's intent. Use a bare
aria-live="polite" (defaults atomic=false, announcing only the added toast); error
toasts keep role="alert". Also simplified the conditional role to a plain ternary.
2026-07-21 23:36:27 +02:00
peter 6ed08383e7 fix(errors,a11y): review round 1 — drop redundant onErrors, stabilize the live region
Review found my C-3.23 additions double-notified: the global error modal (api.ts req)
already surfaces 400/409/422/500 with the backend's specific message, so a caller
toast on those codes is a second, weaker surface. Only 403/404 are caller-handled and
silent — and only a YouTube WRITE action has a meaningful 403 (missing scope).

So the correct set is just the ChannelPage unsubscribe handler (a YouTube write, 403 →
"connect", matching subscribe). Reverted the redundant onErrors on block (a local DB
op, not YouTube), SettingsPanel Plex watch toggle/reimport, and the DownloadCenter job
action — the global modal already speaks for them. Removed the three now-unused keys.

Toaster: moved the polite live region onto the stable, always-mounted container (a
polite region must pre-exist to announce later insertions); error/fatal toasts keep
role="alert", which announces on insertion regardless.
2026-07-21 23:29:51 +02:00
peter d3e466774c fix(errors): surface four mutations that failed silently (C-3.23)
House policy: user actions must report failure. Added onError to:
- ChannelPage block + unsubscribe → notifyYouTubeActionError (a 403 = missing YT
  scope shows the connect message; else the translated fallback);
- SettingsPanel PlexWatchSync toggle + reimport → a plain error notify (these are
  Plex, not YouTube, so notifyYouTubeActionError's "connect YouTube" would mislead);
- DownloadCenter job action (pause/resume/cancel/delete) → a plain error notify.
New keys (HU+EN): channels.notify.blockFailed, settings.plexSync.failed,
downloads.actionFailed.
2026-07-21 23:13:53 +02:00
peter b769439229 a11y(toaster): announce toasts to screen readers (U-F)
The toast is the app's main feedback channel but had no live region, so a screen
reader never spoke it. Each toast now carries role + aria-live by severity:
error/fatal interrupt (alert/assertive), everything else waits its turn
(status/polite).
2026-07-21 23:13:53 +02:00
peter 58b9691467 i18n(a11y): translate the modal + Welcome close buttons and the feed fallback (C-3.22)
House policy is HU+EN for every user-facing string. Modal's shared close button was a
hardcoded title="Close" (in every modal) — now t("common.close") + an aria-label.
Welcome's lightbox close aria-label and Feed's "this video" notification fallback are
translated too (new feed.thisVideo, HU+EN). The other Close hardcodes the review
listed were already fixed since.
2026-07-21 23:13:53 +02:00
peter 4f7153bb7d fix(toast,overlay): review round 1 — close toast-timer edge, document Escape limit
Review found two low-severity items:
- toast: armDismiss now always clears the prior timer (and arms only when duration is
  truthy), so a coalesced repeat that upgrades a toast to requiresInteraction no longer
  lets the original timer fire and dismiss it. Was a pre-existing gap the C-3.25 change
  hadn't closed.
- overlay: documented the known limitation of useDismiss's blanket stopPropagation — a
  PlexPlayer menu open during an auto-skip countdown swallows the Escape that would also
  cancel the skip. Proper fix needs R8's shared layer-registry (topmost-only dispatch),
  not a broad stopPropagation; accepted until then.
2026-07-21 22:37:42 +02:00
peter ed4b5e6413 fix(toast): clear a toast's auto-dismiss timer before re-arming it (C-3.25)
A coalesced repeat re-surfaced the toast and armed a new dismiss timer without
clearing the old one, so it vanished at the ORIGINAL deadline (6s not the expected
11s). Timers are now tracked in a Map<id> and cleared before re-arming (and on
manual dismiss).
2026-07-21 22:29:40 +02:00
peter 94dfa6a34b fix(player): Escape coordination, focused-Space, light-theme menus (U-C, U-3.1.4, U-3.3.12)
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.
2026-07-21 22:29:40 +02:00
peter f590be2bef fix(a11y): keyboard-reorder for panel groups (U-3.1.5)
PanelGroups registered only a PointerSensor, so its focusable, "reorderable" group
grips did nothing from the keyboard. Added KeyboardSensor + sortableKeyboardCoordinates,
matching the other sortable lists (Playlists/PlexPlaylistView already had it).
2026-07-21 22:29:39 +02:00
peter 3d5662df22 perf(frontend): keep the changelog out of the eager App chunk (C-3.19)
App.tsx imported CURRENT_VERSION from releaseNotes, which pulled its ~900-line
changelog into the eager App chunk and defeated the lazy ReleaseNotes split. App now
uses FRONTEND_VERSION (the built VITE_APP_VERSION, equal to RELEASE_NOTES[0].version
by release convention) from lib/version, and CURRENT_VERSION is removed. Verified in
the built bundle: the changelog text now lives only in the ReleaseNotes lazy chunk.
2026-07-21 21:47:45 +02:00
peter 9445e4d71a release: 0.49.0 — R2 guardrails & test net
Internal-only epic (gates, tests, compiler strictness); no user-facing change, so
the release note is chores-only. Bundles: the siftlode check/publish gate
(tsc/eslint/prettier/knip/vitest/pytest) with a hard pre-publish gate + e2e
freshness guard, ESLint flat config + one-time Prettier pass, the backend pytest
harness (isolated test image), frontend vitest growth, the e2e scroll-restore fix,
and noUncheckedIndexedAccess (+ the zero-fallout compiler flags).
2026-07-21 04:30:51 +02:00
peter e006cc879f refactor(frontend): satisfy noUncheckedIndexedAccess (S3b)
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).
2026-07-21 04:11:19 +02:00
peter 3fe0897fd3 build(frontend): enable the zero-fallout compiler flags (S3a)
Three strictness flags that the codebase already satisfies (0 errors each):
- noFallthroughCasesInSwitch on src + e2e — a missing `break` is now a type error.
- noUnusedLocals + noUnusedParameters on the e2e project, so it matches src and node
  and dead spec scaffolding can't accumulate (it was the one project without them).
- allowJs + checkJs on the node project, pulling tailwind.config.js and
  postcss.config.js under the type checker (the last hole in C-4.5) — a real type
  error in either is now caught (verified: `content: 123` fails; Config stays
  permissive on plugin keys by design).

noUncheckedIndexedAccess is NOT here — it has 98 call-site fixes and lands as its own
reviewable sub-sprint (S3b).
2026-07-21 03:47:55 +02:00
peter bdb0185db8 test(frontend): unit-test columnSort and linkify
22 vitest cases for two pure helpers that had none:
- columnSort: numeric-vs-locale comparison, asc/desc, no-mutation, the null/junk
  coercion in parseSortState, and the unsorted→asc→desc→unsorted click cycle.
- linkify: http/www/bare-domain URLs, the version-number non-match, the
  "<platform>: @handle" mention (handle linked, prefix kept as text), alias→canonical
  base URL, bare @handle/#hashtag left plain, and multi-link ordering. Nodes are
  inspected structurally, so the existing node-env vitest needs no DOM.

useCardPager (a hook) is deferred to when component/hook test infra lands with the
backend pytest sprint.
2026-07-21 03:13:26 +02:00
peter 3c119b66c7 test(e2e): click a visible channel card in the scroll-restore test
The "Back restores the feed scroll position" spec failed against the production
build while the app was in fact correct. Root cause, found by instrumenting
PageScroller and comparing the baked :8080 build to live :5173:

`getByTestId("video-card-channel").first()` grabs the first card in the DOM, which
after scrolling 3000px is in the virtualizer's overscan ABOVE the viewport.
Playwright scrolls it into view to click it, moving the feed from 2879 to ~1017–1253
BEFORE navigating; PageScroller's save-on-scroll records that moved position, so Back
faithfully restores the moved position — under the test's own tolerance. A real user
clicks a card they can see, which does not move the feed.

New helper clickVisibleChannelLink() clicks a card whose bounding box is inside the
viewport. Restore now lands at ~2804 (was ~1017). 17/17 pass; stable over 3 reruns.
No app change — the scroll-restore logic was already correct.
2026-07-21 03:10:19 +02:00
peter e257e2aca0 style(frontend): Prettier config + one-time repo-wide format pass
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.
2026-07-21 02:26:40 +02:00
peter 04ecd8b3a7 build(frontend): add ESLint flat config as a gate lane
ESLint 9 flat config, pinned. Narrow by design: the load-bearing rule is
react-hooks (rules-of-hooks + exhaustive-deps) — 33 disable comments existed for a
rule nothing ran. tsc already owns unused vars / undefined / types, so those are
turned off here to report each finding once. no-explicit-any is off: all 19 live in
the api.ts god-module that R7 rewrites, and it flips the rule back on then.

reportUnusedDisableDirectives is an error, which immediately caught two inert
`eslint-disable` comments (App.tsx, AddToPlaylist.tsx) whose effects have stable
deps — removed. no-unused-expressions allows the side-effect ternary idiom the
codebase already uses (`v.paused ? v.play() : v.pause()`).

Errors: 0. Remaining 38 are warnings (28 react-refresh DX, 10 exhaustive-deps) —
pre-existing, visible in the lint output, tracked to their epics.
2026-07-21 02:25:12 +02:00