Commit Graph
524 Commits
Author SHA1 Message Date
peter 62a3d1034b fix(popover): keep the last-hovered row highlighted when the pointer leaves
The surface's onMouseLeave nulled hoverIndex, so moving the pointer off a menu snapped the highlight
back to activeIndex — the first item in a menu with a free-text input (ChannelTags), where the field
swallows arrow keys so there is no list-nav holding a keyboard position. The keyboard and typeahead
already reclaim the highlight by nulling hoverIndex in onNavigate/onMatch, so the mouseleave reset was
redundant; dropping it keeps the last hover. 0.60.3.
2026-08-08 23:33:39 +02:00
peter 205f18a2e1 fix(a11y): address R9 code-review findings (glass shadow, focus dedup)
- #1: remove the 9 dead `shadow-*` from glass surfaces instead of letting them override the tuned glass
  box-shadow. The @layer components move made a JSX shadow-* win AND atomically drop the glass inset
  top-edge highlight; deleting the (always-dead) utilities keeps the intended glass depth incl. the
  inset sheen. Verified in-browser: a .glass box-shadow again includes its inset segment.
- #3: drop the per-element focus-visible outlines from the rail chrome buttons and SavedViews action
  buttons — the global button:focus-visible fallback already draws the identical 2px accent ring, so
  they were redundant. Verified: the pin button rings from the global rule alone.
- #5: unify the focus-within outline offset (offset-2) on the SearchBar and ShareDialog pills.
- #2 (double outline on the pill when an inner button is focused): NOT fixed — the clean fix
  (has-[input:focus] so only the input rings the pill) does not generate under Tailwind v4 (measured:
  no rule emitted). Kept the working focus-within, which is a11y-correct (every element still shows a
  focus indicator); the overlapping ring is an accepted cosmetic edge-case.
- #4 (weak ring on solid-fill selected items): deferred to R10 with the selected-paint tokenisation.

Gate green (tsc/eslint/prettier/knip/vitest 79).
2026-08-08 09:28:23 +02:00
peter c11e2822e9 feat(a11y): R9 focus foundation — app-wide focus-visible fallback + nav rail focus
- Global :focus-visible fallback (@layer base): one accent outline for keyboard users on every
  button/link/[role=button]/[role=tab]/summary that doesn't define its own. Non-input controls had no
  visible keyboard focus app-wide; this closes the void with a single rule. More specific focus styles
  (.selectable, an input's focus:ring, a menu item's data-active highlight) override it.
- PopoverItem now carries outline-hidden so menu items keep their data-active highlight without also
  drawing the global outline (no double ring).
- Nav rail rows adopt .selectable (via rowBase); rail chrome buttons (logo/pin/about/sign-out) get a
  focus-visible ring; the settings row already uses rowBase. Deduped the account trigger's selectable.

Deferred to R10: the chip/pill selected-PAINT unification (a .chip/.pill/.is-current repaint of the
~14 selection idioms) — the R10 token redefinition repaints those surfaces anyway, at less risk than a
hand migration now. R9's job (the focus/a11y foundation) is complete.

Verified: tsc/eslint/prettier/knip/vitest (79) green; browser computed-style confirms the global ring
renders (2px accent, offset 2px) on a plain button, .selectable overrides it with an inset ring, and
an outline-hidden menu button draws no global outline.
2026-08-08 04:51:02 +02:00
peter 51f9c8d0ed feat(a11y): R9 focus foundation — glass shadow fix, focus-visible ring, highlight/selected split
S1 — recipe foundation:
- Move .glass/.glass-card/.glass-menu into @layer components so a JSX shadow-* utility overrides the
  glass box-shadow again; the ~9 dead shadow-* on glass surfaces (modals, menus, save-bar, chat) now
  paint their intended drop-shadow. Perf-mode and :hover overrides stay unlayered so they still win.
- Add a .selectable recipe: the hover tint menu/list rows already use, PLUS the app-wide
  focus-visible ring that was missing on non-input controls (the biggest a11y gap).
- Split the popover roving highlight from the selected state (AnchoredPopover): a selected/checked
  PopoverItem now gets a ring ONLY, so its resting paint (bg-accent fill / text-accent / checkbox)
  survives being highlighted instead of downgrading to a 35% tint or being recoloured. Fixes the 4
  review findings deferred from R10-S0 generically; drops the leftover per-consumer data-active ring
  in tableHeader.

S2 — worst offenders:
- Account-menu trigger + switch/add/sign-out rows adopt .selectable (visible keyboard focus).
- Saved-views rows: group-focus-within reveals the hover-only action buttons for keyboard users;
  aria-current on the active row; focus-visible ring on every button.
- The 3 invisible-focus inputs get an accent focus ring: SearchBar + ShareDialog via focus-within on
  the glass pill/row, AboutCell via focus-visible on the trigger.

Deferred to a later R9 sprint: the full unification of all ~14 selection idioms, a shared <NavItem>,
and .is-current/.chip recipes (no consumer yet in this pass).

Verified: tsc/eslint/prettier/knip/vitest (79) green; browser computed-style checks confirm the glass
shadow now wins, the focus outlines render (2px accent), and the split renders (selected ring-only,
unselected fill).
2026-08-08 04:27:51 +02:00
peter f8e0ab1520 fix(popover): single hover-follows highlight without stealing input focus (UAT)
Second UAT finding on the tag popover: the keyboard-active row stayed put while
a fainter CSS :hover chased the pointer — two competing highlights. Root cause:
floating-ui's focusItemOnHover is (correctly) OFF for input-hosting menus because
setting its activeIndex on hover drags DOM focus onto the item and blurs the
create-tag input mid-typing; the only pointer feedback left was the item's own
`hover:bg-*`.

Fix, applied in the shared PopoverItem so it's general:
- Drive the visible highlight off a NEW `hoverIndex` state (highlightIndex =
  hoverIndex ?? activeIndex), decoupled from floating-ui's activeIndex — hovering
  sets only hoverIndex, never DOM focus, so one highlight follows the pointer AND
  the input keeps its caret. Keyboard nav (onNavigate/onMatch) clears hoverIndex
  so it takes the highlight back; leaving the menu clears it too.
- Split PopoverItem's `active` into focusActive (roving tabindex, keyboard) vs
  visualActive (data-active highlight).
- Remove the now-redundant `hover:bg-*` from the 5 consumers so data-active is the
  sole highlight (it was also a same-specificity tie with data-active).

Verified in-browser: hovering moves the single highlight; typing "testtag" in the
create field then hovering items keeps focus+caret on the input (caretPos intact);
mouse-leave returns the highlight to the keyboard row. Gate: tsc/eslint/prettier/
vitest 79 green.
2026-08-08 01:38:06 +02:00
peter 22e0d286c0 fix(popover): stronger, centralized active-row highlight (UAT finding)
The roving-focus row in every popover menu was barely distinguishable from the
menu surface — an accent fill at only 24%, and the recipe was copy-pasted across
5 consumers. Centralize it in PopoverItem (ACTIVE_HIGHLIGHT, applied to every
item) and strengthen it: accent/35 fill + a 1px inset accent ring for a defined
edge that reads across all 6 schemes in both themes. Consumers no longer carry
the recipe.

Uses the now-working `bg-accent/NN` / `ring-accent/NN` utilities (v4 does the
opacity modifier via color-mix) instead of the old hand-written color-mix
arbitrary value the v3 no-op forced. Verified in-browser: fill + inset ring
render on the active item, dark and light.
2026-08-08 01:16:21 +02:00
peter 70bda98868 build(deps): migrate Tailwind CSS v3.4 → v4
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.
2026-08-08 00:50:05 +02:00
peter edc09e3651 revert(player): drop the HD-latch resolution trick, keep the windowed fullscreen button
The large-logical-iframe + CSS-scale-down trick to force a 1080p start did not
work behind the user's work VPN (YouTube caps quality by network there regardless
of viewport size), and the two-phase native switch could downgrade a latched
1080p to 480p on some streams. Not worth the complexity for an unreliable win —
revert to the original "opens at whatever resolution YouTube picks" behaviour;
the user sets 1080p manually via fullscreen + the gear as before.

Kept: the windowed maximise (fullscreen) button added to the player meta row.
2026-08-02 02:02:31 +02:00
peter 8f1d576341 feat(player): drop to native sizing after 1080p latches (full-size YouTube controls)
Two-phase HD: phase 1 keeps the fixed 1920x1080 iframe scaled down to latch
1080p; once the quality poll sees hd1080, switch to native responsive sizing so
YouTube renders its own controls (the gear especially) at full size instead of
scaled-down. A latched pick survives the shrink on a normal VOD.

CONDITIONAL / under observation: on one was_live stream in dev, quality dropped
hd1080 -> 480p ~2s after going native (YouTube re-caps to the small viewport);
held 1080p elsewhere. Gated behind HD_DROP_TO_NATIVE so phase 2 can be turned
off in one line to fall back to the guaranteed-1080p permanent-scale phase 1.
2026-07-31 05:47:20 +02:00
peter 8349f9338c feat(player): start videos at 1080p + add a windowed fullscreen button (R8 S5)
YouTube picks an embed's quality at player-creation from the iframe's internal
viewport and latches it; a windowed-size embed was stuck ~360-480p, forcing a
manual fullscreen + gear trip to 1080p. Create the iframe at a fixed 1920x1080
logical size and CSS transform-scale it DOWN to fit (a CSS transform doesn't
shrink the internal viewport, so YouTube still serves 1080p, latched for the
session even while it paints small). A letterbox-contain fit via a
ResizeObserver handles both the windowed and big-view boxes. Gated behind
FORCE_HD_LOGICAL_SIZE for an easy revert if a restrictive network overrides it.

Also add a maximise (fullscreen) button to the windowed player's meta row: the
only fullscreen control reachable in the windowed video was YouTube's own, which
hides our chrome. It mirrors the F key and leads to the docked bar + our true
fullscreen. Corrects the stale code note that claimed the scale trick fails
(that was scaling a SMALL player up; scaling a LARGE player down works).
2026-07-31 05:03:34 +02:00
peter 7b6481055c chore(popover): drop the unused AnchoredPopover type export (knip) 2026-07-31 03:43:41 +02:00
peter a6e3ef8122 feat(player): download works in fullscreen + de-dup the transport controls (R8 S5)
Two R8 S5 (player big-view control-bar) follow-ups:

- Modal now portals into `document.fullscreenElement ?? document.body` (reactive), the
  same fullscreen-aware root the popover platform uses. A node under <body> is painted
  beneath whatever holds the fullscreen lock, so the download dialog — a Modal, opened
  from the player's control bar — would open invisibly in true fullscreen. With that
  fixed, the `!isFullscreen` guard on the player's DownloadButton is dropped, so download
  joins add-to-playlist as working in true fullscreen. (Every Modal is now fullscreen-
  aware; the fix is at the primitive, not the call site.)

- The queue transport cluster (prev / counter / next + auto-advance + loop toggles) was
  duplicated inline in two places — the modal queue bar and the big-view docked control
  bar — with identical logic and only theme + label-visibility differing. Extracted a
  `<PlayerTransportControls variant="modal"|"docked">`; a mode/icon change now lands once.
  Behaviour-preserving (the docked bar's trailing divider and the wrapper rows stay at the
  call sites). Note the modal variant keeps its `bg-accent/15` active tint, which is a
  latent no-op under Tailwind v3's hex --accent — left as-is for R10 S0's styling audit.

Gate green (tsc/eslint/76 vitest). Device-UAT'd in real Chrome: transport controls render
correctly in both the modal queue bar and the docked bar; the download button now shows in
true fullscreen and its dialog portals into the fullscreen stage, fully visible and
centered. No console errors.
2026-07-31 03:24:49 +02:00
peter 2984aa3240 fix(popover): resolve the R8 platform code-review findings
Correctness:
- Escape is now strictly topmost-only for EVERY mode. Hover popovers register in
  the shared layerStack too (and floating-ui's own Escape is off for all modes), so
  an open tooltip/hover-card no longer either swallows the Escape from a Modal/player
  underneath (its document-level stopPropagation vs their window listeners) or
  double-closes both. A player's own overlay hover-card now takes Escape before the
  player, keeping it alive.
- An invisible (referenceHidden, scrolled-out) surface is no longer a dismissable
  layer, so it can't silently eat an Escape aimed at what's actually on screen, nor
  make a player defer.
- PopoverSurface splits into a wrapper that returns null before any hook and an inner
  that holds them, so a mounted-but-closed popover (≈10 per table row) subscribes no
  fullscreen listeners — was ~1800 document listeners on a 100-row table.
- The fullscreen portal no longer remounts on a key: one persistent container is
  re-parented body↔fullscreen-element with appendChild (atomic move), so the open
  panel's focus/caret/scroll/animation survive a transition and an element SWAP is
  handled by the element-identity dependency.
- AddToPlaylist drops `disabled={busy}` on its items (it blurred the focused item to
  <body> and permanently killed roving focus); re-entrancy is already guarded in
  toggle(), and aria-busy conveys the pending state.
- New `hostsInput` option turns off BOTH typeahead and focus-item-on-hover for the two
  menus with an inline text field, so a pointer passing over a list item no longer
  steals the field's caret and mis-routes the next Enter. Replaces their `typeahead:false`.
- ChannelTags' single-pass fit loop gains a whole-set fast path, so it stops collapsing
  chips into "+N" when they would all have fitted.
- `initialFocus={-1}` is applied only when a `selectedIndex` is set (the sync-focus race
  it works around); a plain nav menu now lets the focus manager focus normally, so an
  item-less menu (first-run tag editor) still has a keyboard entry point.

a11y:
- `ariaLabel` now wins on menu-role panels — the surface strips useRole's competing
  `aria-labelledby` when an explicit label is given (verified: the filter/View menus
  are named by their column/label, not the trigger).
- PopoverItem routes caller props THROUGH getItemProps and applies its own
  tabIndex/disabled/data-active/className last, so a caller handler or tabIndex can no
  longer be silently dropped or clobber the roving model.

Regressions / consistency:
- Tooltip shows/hides on contact again (explicit 0/0 hover delay) instead of inheriting
  the platform's 120/140 ms intent delay.
- ChannelTags' editor list regains its useScrollFade overflow mask.
- ChannelTagsControl takes a `bounded` prop; the channel detail page passes false so
  tags show inline in the wide header instead of collapsing to "+N".
- ChannelPage's tag-catalog query is gated on subscribed && !demo (matches the only
  consumer's render condition); the manager's tag toggle now also invalidates
  qk.channel(id) so the detail page's cached tags stay in step.
- DetailCustomizeMenu + PrefToggle revert off the platform to the pre-R8 inline
  `absolute` menu: in the player's overlay it renders inside a transform:scale(1.25)
  subtree, and portalling out to <body> painted it at 1×. Same reason the player's own
  menus stay hand-rolled; this also restores PrefToggle to plain buttons (the rows are
  independent toggles, not a radio group). The stale PlayerModal fullscreen comment is
  corrected too.

Gate green (tsc/eslint/76 vitest). Self-UAT in-browser: hover-over-item no longer
steals the tag input's caret; text filter keeps caret while narrowing; menu aria-label
is the label not the trigger; selectedIndex lands focus on the checked item; roving +
Escape-close+refocus intact; add-to-playlist items never disabled. NOT verified here
(needs a device / playing media): the fullscreen portal reparent and the player-overlay
paths — owed device UAT, as they already were.
2026-07-31 02:10:14 +02:00
peter 2727b59fa5 feat(popover): player description card on the platform, add-to-playlist works in fullscreen (R8 S4d)
The description hover card loses its two hand-managed timers and its fixed `bottom`
anchor. It now opens above the title through the platform, which means `flip` can rescue
it when there is no room up there either — the old fixed anchor could only grow upward.
Its width moves from a px clamp to a rem one, so it follows the font-scale slider like
the rest of the app.

Add-to-playlist is no longer disabled in the player's true fullscreen: its menu rides
the platform, which portals into the fullscreen element rather than <body>. The download
button stays hidden there on purpose — it opens a Modal, and Modal still portals to
<body>, so its dialog would open invisibly. Lifting that needs the same fullscreen-aware
root inside Modal.

NOT migrated, deliberately: PlexPlayer's gear and tracks panels. They are the one place
where portalling would be a visual regression — the control bar sits inside the player's
`transform: scale(1.25)` windowed subtree, so a panel portaled out of it renders at 80%
of the surrounding UI's size. Positioning would still be correct (floating-ui reads
post-transform viewport rects); the panel's own type and padding would not. Doing this
properly means either portalling into that scaled wrapper or dropping the scale, and
either way it needs a UAT with a playing video that this environment cannot run.

Verified in-browser: the description card opens after its hover-intent delay, anchored
8px above the title and left-aligned to it, with the description loaded.
2026-07-31 00:16:41 +02:00
peter e35c4be561 feat(popover): migrate the dialog surfaces onto the platform (R8 S4c)
The column filter panel, the nav's account panel and the header's sync read-out are
mixed content with their own tab order rather than lists of choices, so they migrate as
`dialog` popovers — trapping and returning focus, dismissing through the shared layer
stack instead of their own document listeners.

The column filter needed a structural change: its open state lived in the header row
while the panel rendered per column, which cannot work when the platform anchors a panel
to its own trigger. Funnel and panel are now one module-level component (module-level
still matters — declaring it inline once remounted the popover on every keystroke, which
broke the caret and IME composition). That retires three hand-rolled focus chores at
once: capturing the trigger element at open time because a per-column ref nulled out
before focus could return, the effect that stopped Escape stranding focus on <body>, and
the useDismiss registration that had to include the trigger so its own mousedown did not
close-then-reopen the panel.

The filter also stops being one undifferentiated panel: a text filter is a dialog whose
input takes initial focus, while the select and multi filters are real menus with roving
focus — select as radios, multi as checkboxes.

`menuitemcheckbox` is new to the platform, and the two existing multi-select toggle lists
(channel tags, add-to-playlist) move onto it as well: several tags or playlists can be on
at once, so they were never one exclusive choice. Role-only change, no interaction
difference.

Verified in-browser: all three anchor exactly where their hand-rolled placement did,
Escape closes and returns focus to the trigger, the text filter keeps focus and caret
position while typing narrows the table, and the multi filter opens as a checkbox menu
with roving focus.
2026-07-31 00:11:26 +02:00
peter 280de2e592 feat(popover): migrate the click menus onto the platform (R8 S4b)
AddToPlaylist, LanguageSwitcher, the Plex DetailCustomizeMenu and ViewSwitcher each
had their own anchoring, dismissal and (for ViewSwitcher) keyboard engine. All four now
use the platform, so their items are PopoverItems with real menuitemradio/aria-checked,
roving focus, typeahead and flip/shift anchoring.

Two platform additions this needed:
- `selectedIndex`: opening a single-choice menu must land on the choice already in
  effect, which is what ViewSwitcher's hand-rolled engine did by seeding its roving
  index to the current option.
- `ariaLabel` on PopoverSurface: an icon-only trigger leaves the panel with no name.

Fixed while wiring `selectedIndex` up: it stranded focus on the menu container. That
path focuses its item synchronously in a layout effect, and FloatingFocusManager's own
initial focus then ran afterwards and took focus back; the no-selectedIndex path only
escaped this because it defers focus to a microtask while waiting for the item list to
populate. On a roving list the list navigation owns initial focus, so the manager is now
told to take none (initialFocus -1).

Behaviour changes worth naming:
- The header LanguageSwitcher no longer opens on hover. The platform focuses the first
  item on open, so a hover-open would yank focus off whatever the user was doing. Both
  variants are now click-only, like every other menu in the app.
- PrefToggle became a PopoverItem, so the rows callers inject through the menu's `extra`
  prop join the same roving list as the built-in ones.
- AddToPlaylist fetches its list on open, so on the first paint there are no items to
  focus and focus stays on the trigger (as it did before the migration); arrows move
  into the list once it lands.

Verified in-browser: ViewSwitcher opens on the checked mode, arrows rove, Escape closes
and refocuses the trigger; the ChannelTags pilot is unregressed; AddToPlaylist anchors
bottom-start without the click reaching the feed card underneath; the rail language menu
keeps its right-edge, bottom-aligned anchoring.
2026-07-31 00:02:14 +02:00
peter 2756c21dbd feat(popover): fullscreen-aware portal + migrate the hover surfaces (R8 S4a)
The platform's FloatingPortal targeted <body>, which is painted UNDERNEATH whatever
holds the fullscreen lock. That is why the player's in-bar menus were either
non-portaled (and clipped) or, for add-to-playlist / download, disabled outright in
fullscreen. PopoverSurface now portals into `document.fullscreenElement` while one
exists, via a new reactive `useFullscreenElement()` on the existing WebKit-aware
fullscreen lib.

Two floating-ui details this had to work around: a null `root` means "the container
does not exist yet" (no portal node is created at all), so the no-fullscreen case must
pass undefined; and the portal node is built once and ignores a later root change, so
the portal is keyed on the fullscreen state to remount on a transition.

Migrated the three hand-rolled hover engines onto the platform:
- Tooltip: kept as a passive caption via a new `passthrough` prop on PopoverSurface,
  so it cannot swallow a click aimed at a control it overlaps during the close delay.
  Gains aria-describedby wiring it never had.
- AboutCell: the 160 ms hide-timer hover bridge is now safePolygon; the close-on-page-
  scroll is now hide-and-follow, so the card reappears when the row scrolls back.
- TagManager: same bridge removal; the scroll-fade moves to an inner scroller so the
  mask rides the scrolling box rather than the glass panel.

Verified in-browser: tooltip flip + centering + pointer-transparency, About card flip-
above with the measured height, tag popover right-start anchoring, and the portal
round-trip body -> fullscreen element -> body on a live fullscreenchange.
2026-07-30 23:49:29 +02:00
peter 7ed98956cc feat(overlay): shared layer-stack for topmost-only Escape across modals, popovers, players (R8 S3)
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.
2026-07-30 23:33:50 +02:00
peter d950867d26 fix(popover): visible accent highlight on the active menu item (R8 S2 UAT)
The tag editor's active row barely differed from the menu background. Drive
the highlight off the roving `data-active` attribute (reliable across click-
vs key-open, unlike :focus-visible, which a click-open's programmatic focus
doesn't trip) and tint it with the accent. `bg-accent/NN` can't be used —
the theme's --accent is a hex var and Tailwind v3 can't inject alpha into it
— so use a color-mix arbitrary value, matching the app's glass idiom.
2026-07-30 23:33:50 +02:00
peter 5d1854b808 feat(popover): keyboard + focus + ARIA layer for the popover platform (R8 S2)
Add the ViewSwitcher-grade menu model to the R8 popover platform, via
Floating UI's idioms rather than a second hand-rolled roving engine:
useListNavigation (↑/↓/Home/End roving, focusItemOnOpen so both click- and
key-opens land on the first item), useTypeahead, and FloatingFocusManager
(trap into the surface, return focus to the trigger on close). A new
<PopoverItem> self-registers for navigation and carries the right role
(menuitem / menuitemradio+aria-checked / option+aria-selected); menu/listbox
surfaces are focus-managed by default.

Wire the ChannelTags editor as the pilot: tag toggles become menuitemradio,
and the inline create-input stopPropagations non-Escape keys so arrows and
typed chars stay in the field (typeahead off for that menu).
2026-07-30 23:33:50 +02:00
peter f091247624 refactor(channels): shared tag-mutation hook + a channel_detail tag_ids test (R8 review)
Address the two remaining review findings on the tag work:
- extract useChannelTagActions — the optimistic attach/detach + create-and-attach flow that
  the channel manager and the channel detail page had copy-pasted; the cache-shape-specific
  optimistic update is injected (manager maps a ManagedChannel[]; detail patches one
  ChannelDetail), so the two stay in sync;
- add a DB-lane test asserting GET /api/channels/{id} returns exactly this user's personal
  tag links — not another user's, not the auto/system (user_id NULL) links.
2026-07-30 23:33:50 +02:00
peter 3352d0095c fix(channels): only show the detail-page tag editor on subscribed channels
attach_tag/detach_tag call _user_subscription, which 404s for a channel you don't
follow — but the tag control was rendered for any non-demo channel, so tagging an
un-subscribed/explored channel popped a global error + reverted the optimistic add.
Gate the control on ch.subscribed (personal tags are for your subscriptions anyway).
2026-07-30 23:33:50 +02:00
peter 56f95fa626 feat(channels): tag editing on the channel detail page (R8 S1 Part 2)
Bring the same tag control to the channel page, next to Subscribe:
- backend: GET /api/channels/{id} now returns this user's `tag_ids` for the channel
  (added to _channel_detail_dict + the ChannelDetail interface), matching the manager
  list shape;
- ChannelPage renders the shared ChannelTagsControl, wired to attach/detach/create with
  an optimistic update on the channel-detail cache (reverting on failure) and a manager
  list invalidation so a tag added here shows there too.

Reuses the R8 popover platform + ChannelTagsControl — no new tag UI.
2026-07-30 23:33:50 +02:00
peter 2b70ba7a1f fix(channels): tag-cell review follow-ups — rename-aware fit, measured gap, close reveal-all
Address the /code-review findings on the tag cell:
- key the overflow-fit measurement on tag id+name so a RENAME (which changes chip
  width) re-runs the fit, not just add/remove;
- reserve the '+N' pill space using the MEASURED gap (gap-1 is rem, scales with the
  font slider) instead of a px constant;
- force the reveal-all hover popover's state closed when nothing is collapsed, so it
  can't linger open against an unmounted '+N' trigger.
2026-07-30 23:33:50 +02:00
peter 8ae06ea1fe fix(channels): center table, kill popover top-left flash, exact "+N" count (R8 S1 UAT)
Second UAT pass on the tag cell + popover platform:

- Popover top-left flash (real root cause): the popIn keyframe animates `transform`,
  which overrode Floating UI's positioning `transform: translate(x,y)` for the animation's
  duration, parking the popover at (0,0). Move the animation to an INNER wrapper so the
  positioned element's transform stays clean (verified: menu is at the correct spot from
  frame 0). The isPositioned visibility gate is kept as a belt-and-suspenders.
- Table shifted right / not centered: my previous fixed-15rem tags column + nowrap Sync
  pushed the table past the max-w-[96rem] wrapper so it overflowed. Trim to 12rem tags and
  shorten the long "full history queued/coming" sync labels to "queued"/"coming" so the
  table fits the wrapper (1594 <= 1628) and centers again.
- "+N" count wrong/missing while adding tags: replace the iterative shrink (which lagged a
  render and could clip the pill's digits) with a deterministic measure — a hidden measurer
  lays out all chips + a sample pill, and we compute the exact fit in one pass. The count is
  always correct now (verified across varied rows), and a bare "+" with no number is
  structurally impossible.
2026-07-30 23:33:50 +02:00
peter 0fd95c15a1 fix(channels): stable tag column width, no popover flash, no sync wrap (R8 S1 UAT)
Three UAT refinements on the tag cell + popover platform:

- Fixed tag-column width (w-[15rem], was max-w): under table-layout:auto a content-
  driven cell re-solved every column's width on each chip added, so the table visibly
  shifted (and the "+N" count flickered) until the cap was hit. A fixed footprint makes
  adding/removing tags move nothing (verified: column x unchanged, "+N" count stable).
- No popover flash: gate the floating surface's visibility on Floating UI's isPositioned
  so it never paints at (0,0) top-left for a frame before the transform lands.
- Sync cell no longer wraps: flex-nowrap so the recent/full badges stay on one line
  (the column grows to fit them instead of breaking to two rows).
2026-07-30 23:33:50 +02:00
peter cfa8be950d feat(channels): fixed-width tag cell with "+N" overflow + inline create (R8 S1)
Redesign the channel tag cell (the popover pilot's first real consumer) to fix two
UAT-reported bugs and two UX gaps:

- Jumping editor (bug 1): the "+" edit trigger now sits in a STABLE slot at the end,
  so toggling a tag (which grows the chip row) no longer moves the anchor — the editor
  popover stays put (verified dx/dy = 0).
- Whole-table reflow (bug 2): the cell is width-bounded (max-w inside the cell, since
  table-layout:auto ignores the column width — the documented channel/About trap) and
  single-line, collapsing overflow into a hoverable "+N" pill. Rows no longer wrap and
  reflow (verified uniform row heights).
- The "+" is ALWAYS present (even with zero tags), and its menu can CREATE a tag inline
  (type + Enter → create + attach) — tagging no longer requires the top-of-page dialog.

New shared ChannelTagsControl (used by table + cards; the detail page is Part 2) over the
R8 popover platform: a `menu` editor popover + a `hover` reveal-all popover. Greedy rem-safe
fit via ResizeObserver. `api.createTag` now returns Promise<Tag>.
2026-07-30 23:33:50 +02:00
peter e644b4f7d7 feat(popover): add the Floating UI anchored-popover platform + migrate TagsCell pilot (R8 S1)
Introduce useAnchoredPopover + PopoverSurface over @floating-ui/react — one primitive
replacing the hand-rolled measured engines: flip() (two-way flip), shift() (width clamp),
hide() (hide-and-follow when the trigger leaves its scroller), autoUpdate (scroll follow),
FloatingPortal (escape ancestor stacking contexts). Supports menu + hover modes; focus
management lands in S2.

Wrap the app and the public watch page in <FloatingTree> so Escape / outside-press dispatch
is topmost-only across nested layers (S3 folds in the Modal + players).

Migrate the richest engine, TagsCell, as the pilot — ~140 lines of bespoke positioning
(coords state, flip layout-effect, scroll rAF loop, useDismiss, Overlay) collapse into one
hook call. Verified in-app: click opens, portals to <body>, positions below the trigger,
role=menu + aria-expanded/haspopup, Escape and outside-click close, no console errors.
2026-07-30 23:33:50 +02:00
peter df8a2a6d52 chore: point repo/registry URLs at git.b1fr0st.eu (Forgejo->Gitea migration)
- Welcome.tsx REPO_URL (footer link) — takes effect on next image rebuild
- README clone URLs, docs/self-hosting.md image ref
- docker-compose.selfhost.yml image refs
2026-07-30 03:36:23 +02:00
peter 9dc7f80305 improvement(welcome): gallery landing, refreshed copy, name-free screenshots
Rebuild the landing preview into a per-module screenshot gallery (feed hero + channels, playlists, search, player, downloads, Plex), reusing the existing Preview/Lightbox. Rewrite the hero + feature copy in a plainer, concise tone that reflects the current modules (Download Center, Plex, two-way playlists), in EN and HU. Regenerate all screenshots name-free. Bring the README up to date: fix the broken landing image, add the Plex feature, and correct the now-temporary YouTube-search behaviour.
2026-07-29 18:28:51 +02:00
peter 81d0943fdc fix(watch): drop the Web Audio reroute, keep Media Session for iOS background audio (0.57.2)
Reported: on iPad Brave a shared video's audio still stopped when backgrounded, while youtube.com
keeps playing. Root cause: routing the element's audio through createMediaElementSource pulls it out
of the normal media pipeline the browser's background-playback feature (and iOS) hooks into, and iOS
then suspends the AudioContext on background — the opposite of the goal. YouTube uses a plain media
element + Media Session and lets the browser background it. So drop the Web Audio graph entirely and
keep only the Media Session metadata + play/pause handlers, leaving native background handling in
charge. Frontend-only.
2026-07-28 03:29:48 +02:00
peter 7765d19813 fix: code-review round 1 — 4 findings
- og: guard safe_abs_path's None (missing/gc'd poster) before _jpeg_size, else the public /watch OG
  render crashed with an uncaught AttributeError
- channels: hoist the priority step button to module scope so it no longer remounts on every
  hold-repeat tick (which dropped the pointer capture and could let a hold run away on pointer drift)
- watch: only route audio through Web Audio on iOS (where a backgrounded <video> is suspended);
  other platforms keep their reliable native audio, avoiding a foreground-silence risk if the
  AudioContext can't resume
- share: drop the unstable `create` mutation object from the auto-create effect deps (it changes
  every render); the links.data trigger + autoTried ref already fire it exactly once

The clipboard-on-focus finding is intended (the user explicitly asked for auto-paste on focus; the
read is best-effort and browsers reject it without a gesture, so no real prompt fires).
2026-07-28 02:49:34 +02:00
peter d5c9fb9918 feat(watch): keep audio playing in the background on the shared watch page (S4)
A shared /watch video stopped the moment you backgrounded the browser on iOS (Safari/Brave suspend a
<video>), while an audio-only share kept playing — the user wanted YouTube-style background playback
for video too. Route the element's same-origin audio through a Web Audio graph (once that graph is
live, iOS keeps the audio session running in the background while the frame freezes) and add Media
Session metadata + play/pause handlers for lock-screen controls. Set up lazily on first play (the
gesture an AudioContext needs) and only once. Best-effort — any unsupported bit falls back to the
plain <video>. Background continuation itself is an iOS behaviour only real-device UAT can confirm.
2026-07-28 02:29:19 +02:00
peter 9d3d69f0e9 feat(channels): press-and-hold priority stepper + inline-editable value
The priority arrows changed one step per click, so setting a large value meant many clicks. Now a
press-and-hold auto-repeats at an accelerating rate (a quick tap still steps once; keyboard path
kept), and the PATCH is debounced per channel so a hold sends one request with the final value, not
one per notch (flushed on unmount). The value also sits LEFT of the arrows (cursor never covers it)
and is click-to-edit — type any integer directly, negatives allowed — via an absolutely-positioned
input that overlays without disturbing the table row layout.
2026-07-28 02:22:36 +02:00
peter eb922cb589 feat(channels): surface full-history pull + honest stored/total count on the channel page (S3)
The catalog only stores the recent-backfill/RSS subset of a channel's uploads (video_count is
YouTube's raw total), so a subscribed channel looked like it was "missing" videos. Now the channel
page header shows "{stored} / {total} videos" when there's a gap, and a "Full history" action queues
the deep backfill (the same deep_requested trigger the channel manager uses) — showing a loading
state until done. Backend: channel_detail now returns deep_requested/backfill_done.
2026-07-28 02:22:36 +02:00
peter cdcffb8c91 feat(downloads): categorize download failures + a readable error dialog (S2)
Failed downloads stored the raw yt-dlp/ffmpeg text and dumped it as a truncated red
one-liner in the queue. Classify the failure into a category (unavailable / login_required /
geo_blocked / format_unavailable / drm / postprocess / source_missing / network / unknown)
and surface it properly:

- backend: app/downloads/errors.classify maps the raw message to a category code; the worker
  stores it in the new download_jobs.error_code (migration 0061) at both failure sites; resume
  clears it; the serializer exposes it. Raw text kept as technical detail.
- frontend: the queue row shows a clean localized reason + a "details" link opening a modal
  with the explanation, the raw text behind a disclosure, and a Retry only for transient
  categories (network/postprocess/unknown).
- tests: classify() unit coverage over verbatim yt-dlp/ffmpeg message shapes.
2026-07-28 01:40:22 +02:00
peter 3f15f35256 feat(downloads): S1 UI quick wins — queue default, dates, auto-link, thumb fallback
- Always land on the Queue tab (drop the persisted last-tab) so new work/failures are the default view
- Show the download date (relative) on Library rows
- Share dialog opens with a default link already generated when none exists; all link controls stay available
- Thumbnail falls back to the self-hosted poster when a remote (i.ytimg/fbcdn) thumbnail 404s
- Add a clipboard-paste affordance to the add-URL box (explicit button + best-effort on focus)
2026-07-28 01:21:50 +02:00
peter 90749a9e45 refactor(fe): eliminate every explicit any; enforce no-explicit-any (R7 S3a·4)
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.
2026-07-27 22:57:33 +02:00
peter 5de6be8fde refactor(api): literal-union the closed-value string fields (R7 S3a·1)
Video.status, Playlist.kind/source (+ PlaylistDetail), DownloadJob.job_kind
were bare `string`; type them as named unions verified against the backend
(VALID_STATES={new,watched,hidden}; kind user|watch_later; source local|
youtube; job_kind download|edit). VideoStatus then propagates through the
optimistic-override map, onState, and the VideoCard/VirtualFeed/PlayerModal
props — tsc now rejects a status typo (e.g. a stale "in_progress", which is a
derived filter, never a stored status) at every call site.
2026-07-27 22:40:11 +02:00
peter 34297b696f refactor(fe): migrate plex/messaging/admin/downloads/notifications to qk (R7 S2b)
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.
2026-07-27 22:10:03 +02:00
peter ad86d6d126 refactor(fe): query-key factory + migrate feed/channels/playlists (R7 S2a)
Introduce lib/queryKeys.ts as the single source of truth for React Query
keys — the R7 driver was ~200 hand-written key literals across 51 files
(`["feed"]` alone in 15+), where one typo silently orphans a key so an
invalidate never matches.

S2a migrates the three highest-traffic domains (feed/video, channels,
playlists) plus the shared me/my-status/tags roots: ~90 literal sites in
18 files now call qk.*(). Pure substitution — each factory fn returns the
byte-identical array the call sites used before, so TanStack's prefix
matching is unchanged (qk.feed() still invalidates qk.feed(filters)).
Co-invalidation clusters are deliberately NOT bundled into helpers (they
differ per site), so no query's refetch scope changes.

Nullable keys (playlist(selectedId), ytSearch(q)) accept null so a null
segment is preserved (["playlist", null]) while a no-arg call is the bare
prefix key — guard is `!== undefined`.

Gate green: typecheck, eslint (0 err), prettier, 68 vitest. Smoke: app
boots, feed/facets/tags/my-status all 200, no console errors. Factory
extended to plex/messaging/admin/downloads/notifications in S2b.
2026-07-27 21:48:35 +02:00
peter d3a3f41b63 fix(player): left-align the big-view control bar so overflow scrolls from the start
justify-center + overflow-x-auto stranded the left-end controls (Previous, the
N/M counter) when the bar overflowed a narrow window. justify-start makes the
overflow scroll from the start — and matches the conventional left-aligned
video-player control bar.
2026-07-26 03:34:05 +02:00
peter 375c814bb6 fix(player): dock the big-view control bar in its own strip (UAT follow-up)
The overlaid, auto-hiding bar fought YouTube's seek bar for the same bottom
pixels: while ours showed it covered YT's seek + buttons, and reaching them
meant moving off the area and waiting for our bar to fade — a fiddly, "random"-
feeling dance keyed on mouse movement.

Now the bar is DOCKED and always visible: the video area is inset by the bar's
height (a wrapper around the mount, since YT.Player replaces the mount element
with its iframe and would drop a class set on it), so the bar sits in its own
strip and never overlaps the video. YouTube's seek/controls stay reachable by
hovering the video's bottom edge, just above ours. No auto-hide, no guessing,
no fight. Verified in Chrome: iframe.bottom == bar.top (no overlap), bar opacity 1.
2026-07-26 03:18:15 +02:00
peter 51193105a7 fix(player): first-class maximise/fullscreen view (UAT round)
UAT feedback on the F-maximise view — three fixes:
- Own control bar in the big views (maximise + our fullscreen): previous/next +
  N/M, auto-advance, loop, add-to-playlist, download, mark-watched, exit, and a
  true-fullscreen toggle. Auto-hides on idle. Add/download are hidden in true
  fullscreen (their popovers portal to <body>, outside the fullscreen element).
- Wheel volume works in the big views again: the centre interaction overlay is
  now armed in maximise/fullscreen (it only yields to YouTube's own settings
  menu), so the wheel bubbles to our handler instead of the bare iframe. F also
  offers true fullscreen that WE own (Fullscreen API on the stage), so wheel/
  arrow volume and our controls work inside it too — YouTube's own fullscreen
  button, which we never surface, is not involved.
- Back (mouse/browser) steps out one level at a time via nested history layers
  (useBackToExit): fullscreen -> maximise -> the modal -> closed, instead of
  closing the whole player at once.

Verified in Chrome: wheel volume in maximise + fullscreen, the control bar, and
the full Back ladder (fullscreen -> maximise -> modal -> closed).
2026-07-26 02:48:46 +02:00
peter bdf7c03b59 fix(r5): address the eighth /code-review high round — 7 findings
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).
2026-07-24 04:44:20 +02:00
peter a5336d20e0 fix(r5): address the seventh /code-review high round — 7 findings
Five of them sit where round 6 turned F from a Fullscreen API call into a CSS
maximise: the guards around it still asked `isFullscreen`, which that pivot made
permanently false.

- Arrow-key volume was dead in the maximised view: the scroll-deference checked
  only `isFullscreenRef`, so the (now hidden but still scrollable) card kept
  swallowing Up/Down. Both flags now count as "the video fills the screen".
  Measured: 87px of scroll room on the focused card, ArrowDown moves 80 -> 75
  and scrolls nothing.
- The maximised view had no way out once the pointer touched the video. Reaching
  YouTube's controls means clicking into the cross-origin iframe, which takes
  keyboard focus with it, and the re-arm paths (stage mouseleave, window focus)
  need the pointer to leave the browser entirely while the stage fills it. So
  the exit now lives on screen, in our DOM: a button that un-maximises AND pulls
  focus back, re-arming every shortcut.
- WebKit fires only `webkitfullscreenchange`/`webkitFullscreenElement`, so on
  Safari/iPadOS YouTube's own fullscreen never registered — no overlay yield, no
  focus reclaim. One `fullscreenEl()` reader, both event spellings.
- The stage claimed `z-index: 9999`, the glass tuner's reserved level, for a job
  that only needs to beat the modal chrome it is nested in (its siblings top out
  at z-menu). Dropped to rail-level; nothing escapes the modal's stacking
  context either way.
- `inset: 0` already sizes a fixed layer — the inherited `100vw`/`100vh`
  over-constrained it, which is how a classic scrollbar gets a horizontal
  overflow and a mobile URL bar hides the bottom edge (YouTube's control bar).
- The shortcut hint still promised "F: fullscreen" in both locales.

Backend, both "the fix stopped one layer short":
- The startup HLS sweep was awaited inside lifespan, so uvicorn still served
  nothing until it finished — a thread doesn't help when nothing is listening
  yet. Fire-and-forget task instead, cancelled on shutdown. Measured with 200
  planted segments: "Application startup complete" now precedes "swept 1".
- An RSS poll where every channel failed returned normally, so the scheduler
  recorded `ok` with the outage buried in the summary string. `failed == total`
  now raises: a run that reached nothing is a failed run.

Suites: backend 147 -> 150.
2026-07-24 03:41:29 +02:00
peter aae37591cb fix(player): reclaim keyboard focus when fullscreen ends
UAT found a dead end: F -> YouTube's fullscreen button -> Esc left the player
maximised with no way back, and a further F produced a second, control-less
fullscreen with the wheel driving YouTube's own volume box.

Measured cause: reaching YouTube's control bar means clicking INTO the player,
which moves keyboard focus into the cross-origin iframe — and it stays there.
Our window-level shortcuts then never fire (document.activeElement === IFRAME),
so F went to YouTube instead of toggling our maximise.

Fix: on fullscreenchange, when fullscreen ends, focus the modal card again.

Verified in real Chrome, step by step:
- F -> maximised, no browser lock, focus on our card
- YouTube's own FS button -> real fullscreen (innerHeight 889 -> 1024)
- leaving fullscreen -> focus back on our card (was: still the iframe)
- F -> back to the normal modal view (was: a second fullscreen with no controls)
2026-07-24 03:07:46 +02:00
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