Commit Graph
1380 Commits
Author SHA1 Message Date
peter 7dc9f712a0 release: 0.65.0 — R14 channel-table redesign + immediate full-history backfill
Bump VERSION to 0.65.0 and add the user-facing release notes for R14
(zoom-fit manager table, ✓ charm, N/S/L moved to the channel page,
live-refresh, immediate "get full history").
2026-08-15 07:26:09 +02:00
peter 0842babc77 fix(channels): trim fixedLayout column widths to fit narrower viewports (R14 F5)
Shave the manager table's fixed column widths (fixed sum 720->632px) and
convert priority/actions from px to rem so they scale with the text-size
slider like their siblings: tags 12->9rem, actions 104px->5.5rem,
lastUpload 6.5->6rem, stored 5.5->5rem, length 5->4.5rem, priority
56px->3.5rem. Gives the fluid Channel/About columns ~90px more room, so the
table fits without a horizontal scrollbar down to ~700px containers instead
of overflowing at the md/iPad-portrait band.
2026-08-15 07:20:18 +02:00
peter 5720356191 fix(channels): address R14 deep-review findings
- Make the immediate-backfill guard SYMMETRIC: run_deep_backfill now also
  registers the channel it pages in _backfill_in_progress, so it and
  backfill_channel_now mutually exclude per-channel and never advance the
  same cursor concurrently (whichever started first). The interactive cap
  moves to a separate _immediate_running counter so the scheduler's channels
  don't false-trip it.
- Attribute the immediate backfill's spend: wrap it in
  quota.attribute(user_id, VIDEOS_BACKFILL_FULL) (route passes user.id), so
  it shows under the user's full-backfill action, not the OTHER/system bucket.
- Tests: the update_channel->background-task wiring + the demo guard
  (scheduled for a real user, NOT for demo); distinct N/S/L counts (2 short
  + 1 live) so a short/live swap fails; extracted channelDetailPollMs with a
  pure unit test of the poll-or-stop predicate.
- a11y: role="img" on the fully-synced charm so screen readers announce it.
2026-08-15 07:11:50 +02:00
peter 2cc77757d9 fix(channels): address R14 code-review findings
- list_channels no longer computes the Normal/Shorts/Live aggregation the
  frontend stopped reading when R14 dropped the N/S/L table column; the dead
  ManagedChannel.count_* fields are gone too.
- Immediate "get full history" backfill now coordinates with the paced
  scheduler via a small in-flight set: capped at 2 concurrent + per-channel
  dedup, and run_deep_backfill skips a channel an immediate backfill owns —
  killing the cursor race and bounding the quota-drain burst, without losing
  the speed win.
- Add test_channel_backfill.py: the channelDetail N/S/L counts + the guard's
  no-op / cap / scheduler-skip behaviour.
- Inline the syncedChannels useMemo (its dep is a fresh array each render, so
  it never cached); fix the stale "~2-min ticks" comment (it's 10 minutes).
2026-08-15 06:38:19 +02:00
peter a342e15e05 feat(channel): backfill a channel's full history immediately on request
Clicking "get full history" now kicks an immediate background deep
backfill of that one channel (backfill_channel_now: own session, up to
~3000 videos in one pass, quota-guarded) instead of waiting for the
scheduler's paced 10-minute ticks (5 pages/channel/tick). The request
returns instantly and the channel page's live poll shows the count fill
in seconds. A very large channel's tail still falls to the scheduler
(deep_requested stays set).
2026-08-15 06:01:42 +02:00
peter be738d3791 feat(channel): live-refresh the channel page while full-history backfills
The channel detail page now polls (via useLiveQuery) while THIS user's
full-history backfill is running (deep_requested && !backfill_done), so
the stored count and the N/S/L breakdown climb on their own as the
scheduler adds videos — no manual F5 — and it stops polling once the
back-catalogue is in. Widen useLiveQuery's interval callback to allow
returning `false` (stop) so "poll while active, stop when done" is a
first-class, reusable cadence.
2026-08-15 05:50:11 +02:00
peter 95aa57a2ff style: prettier-format the DataTable fixedLayout test 2026-08-15 05:31:55 +02:00
peter b4dae04631 test(channels): cover applySyncFilter + DataTable fixedLayout
- applySyncFilter: the fully/needsFull/all predicate the top chips drive
  (moved off the retired Sync column) — guards which channels show.
- DataTable fixedLayout: asserts the colgroup + table-fixed appear only
  when opted in, with widths on fixed columns and none on fluid ones.
2026-08-15 05:30:40 +02:00
peter 7ee0e9cd02 feat(channels): R14 zoom-fit table — fluid columns + column fogyókúra
- DataTable: opt-in `fixedLayout` (colgroup + table-layout:fixed) so the
  table fits its container at any zoom/text-size; only the channel manager
  opts in — playlists/discovery/Plex keep auto-sizing.
- Remove the N/S/L and Sync columns from the manager table. Fully-synced
  status becomes a ✓ charm on the Stored count; the sync filter moves to an
  `applySyncFilter` pre-filter still driven by the top chips.
- Channel column is now fluid: ChannelLink truncates with the full name on
  hover (title). Data columns get fixed widths so only Channel/About flex.
- N/S/L (normal/short/live) breakdown moves to the channel page: add the
  three counts to the channelDetail endpoint (one grouped query, same
  filters as the list) + the ChannelDetail type + a meta-line entry.
- Drop the now-dead sync/types i18n keys (en+hu).
2026-08-15 05:28:59 +02:00
peter ea7bf593cb release: 0.64.0 (r13-sort-filter-unification) 2026-08-15 04:54:08 +02:00
peter 19f1905da9 release: 0.64.0 — R13 sort/filter idiom unification + Hidden tab
Bump VERSION to 0.64.0 and add the user-facing release-notes entry for
the R13 sprint (unified sort/filter controls, Hidden channels tab,
channel-manager toolbar declutter, filter-rail jitter fix).
2026-08-15 04:53:08 +02:00
peter 5838cf1c56 test(channels): cover ActionMenu keyboard-skip + stats ETA value; drop redundant disabledIndices
Deep-review follow-up on R13 S3.

- Add a roving-keyboard-nav test proving a disabled ActionMenu item is
  skipped by arrow keys, and assert the stats "left" row renders the
  formatEta value (formatEta had no coverage anywhere).
- Remove the disabledIndices plumbing from AnchoredPopover + ActionMenu:
  it was redundant. useListNavigation already skips items via their DOM
  disabled/aria-disabled attribute, and PopoverItem renders a native
  disabled button — verified against @floating-ui/react 0.27
  isListIndexDisabled and by a mutation test. Behaviour is unchanged and
  now guarded by the new test.
2026-08-15 04:45:32 +02:00
peter bb2d6999fd fix(channels): address R13 S3 review findings
- ActionMenu: skip disabled items in roving keyboard nav (plumb
  disabledIndices through the AnchoredPopover platform); a pending
  action no longer strands focus on an unfocusable button.
- ActionMenu + ChannelStatsPopover: add type="button" to the popover
  triggers so they can't submit an enclosing form.
- ChannelStatsPopover: restore the per-stat explanations as a native
  title on each row — the guidance the old inline stats showed is back.
- i18n: drop the now-dead channels.tags.yourTags / .manage keys (en+hu);
  the stats *Hint keys are live again via the restored row titles.
- test: cover ActionMenu (open/select/close/disabled) and
  ChannelStatsPopover (rows, conditional ETA row, per-row hint title).
2026-08-15 04:09:30 +02:00
peter a26d96630d i18n(channels): add missing HU actionsLabel (R13 S3)
The ⋯ menu aria-label was added to en only; add the Hungarian so every
user-facing string ships in both languages.
2026-08-15 03:42:29 +02:00
peter b52c87b336 polish(ui): tighter header gap, stronger row hover, readable tag chips (R13 S3)
- Channel-manager tab row: pt-4 -> pt-2, closing the too-wide gap under the
  fixed header.
- Row hover was barely visible (--card sits close to --bg in dark schemes). New
  shared --hover token (a 9% foreground mix, clear in every scheme + theme,
  auto-adapting via --fg); the .selectable recipe and the DataTable row both use
  it, so every hoverable row app-wide (tables, nav rail, menu rows) reads.
- Channel tag chips: text-3xs -> text-2xs + font-medium, so the tiny labels stay
  legible on the accent fill across all colour schemes.

Self-E2E'd: tighter gap, a table-row hover now clearly visible. gate green
(tsc/eslint-0/vitest 147/knip/prettier/build).
2026-08-15 03:40:35 +02:00
peter 6de6dece03 refactor(channels): collapse the stats row into a Stats hover chip (R13 S3)
The six inline catalog stats crammed into the tab row become one compact
"Stats" chip that reveals the numbers on hover — a new ChannelStatsPopover on
the AnchoredPopover hover mode, one icon-per-row (channels, recent synced, full
history, [ETA], my videos, quota left). Kept in the manager's own toolbar (the
header SyncStatus is global; these are channel-catalog figures). Retires the
local Stat helper + the now-unused formatEta import. i18n channels.stats.label
(HU+EN). Self-E2E'd: hover reveals the list. gate green (tsc/eslint-0/prettier).
2026-08-15 03:33:34 +02:00
peter ca7e4b16a0 refactor(channels): ⋯ overflow ActionMenu + TAGS-as-trigger (R13 S3)
Declutter the channel-manager tags row:
- New reusable ActionMenu (⋯) on the AnchoredPopover platform — a compact
  overflow menu of one-shot actions with optional pending/disabled + spinner.
  The two big glass buttons (Read subscriptions, Backfill everything) move into
  it, freeing the row.
- The "YOUR TAGS" caption becomes a "✎ TAGS" trigger button that opens the tag
  editor directly, retiring the separate "Manage tags" button.

Adds i18n (channels.actionsLabel, channels.tags.label; HU+EN). ActionMenu is
built to be reused for other overflow menus later. Self-E2E'd: ⋯ opens the two
actions, ✎ TAGS opens the manager. gate green (tsc/eslint-0/prettier).
2026-08-15 03:27:15 +02:00
peter 003043a989 test(sidebar): cover the date preset/custom selection logic (R13 S2c)
The deep-review flagged that the feed sidebar's new date single-select
derivation was the one bit of migrated logic without a test, while its sibling
primitives (FilterChipGroup/SegmentedToggle/SortControl) all gained one. Lift
the pure logic into lib/dateFilter (deriveDateKey + applyDateChip), same split
as columnSort, and cover it: dateKey for none/preset/custom/off-preset-value,
the preset toggle-off, the preset-not-active-while-custom-open case, and the
Custom open/close + maxAgeDays clearing. Sidebar now calls the helpers; the
onChange is behaviour-identical (self-E2E'd: preset on/off, Custom editor).
11 new tests (vitest 136 -> 147). gate green. Branch not merged.
2026-08-15 02:27:22 +02:00
peter 80cdafdbfe fix(ui): address self-review findings (R13 S2c)
Three low-severity findings from the /code-review high self-pass:
- Plex duration: the "Any" chip no longer false-highlights when durationMin/Max
  hold a set-but-off-bucket value (stale/version-skew state) — it selects
  nothing then, matching the pre-sweep behaviour, instead of colliding on the
  empty-string sentinel.
- FilterChipGroup: count badge now guards on count > 0 (was `!= null`), matching
  the Tabs badge so a future count:0 can't render a stray "0" pill.
- Extract the shared CountBadge component; Tabs and FilterChipGroup now render
  the count-badge token from one source (was two near-identical inline spans).

gate green (tsc/eslint-0/vitest 136/knip/prettier). Duration self-E2E'd:
bucket select + Any toggle intact. Branch not merged.
2026-08-15 01:37:05 +02:00
peter 9fdf274422 refactor(ui): ChannelPage Videos/About tabs -> shared Tabs (R13 S2c)
The channel page's underline tab pair was the last hand-rolled tab idiom on a
swept surface. It now uses the shared <Tabs> (rounded-lg pill, active accent
shadow) like every other tab-set, sitting inline with the channel action
buttons on the same separator row. Drops the bespoke tabClass helper. Only the
NavSidebar icon rail (aria-current, icon-only app nav) stays a distinct idiom.
Self-E2E'd: Videos/About switch, content flips. gate green (tsc/eslint-0/
prettier). Branch not merged.
2026-08-15 01:17:46 +02:00
peter 07e95ca8e8 refactor(ui): fold the feed TagChip onto the shared filterChipClass (R13 S2c)
The feed sidebar's multi-select TagChip (language/topic/tag chips) was the last
hand-rolled chip whose className duplicated the canonical geometry verbatim
(it's the reference look filterChipClass was derived from). Point it at the
shared constant; the only delta is dropping a one-off hover:shadow-sm so every
filter chip in the app now hovers identically. Badge markup unchanged.
gate green (tsc/eslint-0/prettier). Branch not merged.
2026-08-15 01:08:17 +02:00
peter dd2d53bb96 refactor(ui): fold Settings + Stats tabs into shared Tabs (R13 S2c step 6)
Tabs gains an `orientation` prop (+ optional per-tab `icon`/`testId`) so the
two hand-rolled NAV tab-sets that duplicated its active treatment now use it:
- SettingsPanel vertical rail (appearance/notifications/account) -> vertical
  Tabs with icons; settings-tab-* testIds preserved
- Stats overview/system tabs -> Tabs (rounded-full+border -> the rounded-lg
  tab canonical, per the agreed tab shape)

The underline ChannelPage tabs and the icon NavSidebar rail are intentionally
left as distinct idioms. Self-E2E'd: settings rail switches + persists with
icons, stats tabs toggle. gate green (tsc/eslint-0/vitest 136/knip/prettier).
Branch not merged.
2026-08-15 01:04:59 +02:00
peter b57aec113e refactor(ui): close the sort/layout reuse-gaps (R13 S2c step 5)
Two surfaces reinvented controls the app already consolidated:
- PlaylistsRail sort (a bespoke <select> + hand-rolled asc/desc flip) now uses
  the shared SortControl; "custom" is a directionless option (no arrow), the
  "unsynced first" pin rides the trailing slot as a pill toggle
- PlexPlaylistView Accordion/Tree layout (two hand-rolled toggle buttons) now
  uses the shared ViewSwitcher with a Rows3/ListTree option set

Adds aria-label i18n (plex.playlist.layoutLabel; HU+EN), drops the now-unused
ArrowUp/ArrowDown + ReactNode imports. Self-E2E'd the rail sort: key switch
reveals the arrow, asc<->desc flips, pin toggles, custom hides the arrow.
gate green (tsc/eslint-0/vitest 136/knip/prettier). Branch not merged.
2026-08-15 01:01:03 +02:00
peter 42d0173095 refactor(ui): unify Channels + Stats filter chips (R13 S2c step 4)
- channel-manager sync-status chips (F2) -> FilterChipGroup (rounded-lg -> the
  canonical rounded-full), controls-row alignment preserved via className
- channel-manager user-tag chips (multi-select) restyled to filterChipClass
  (rounded-lg -> rounded-full) so the whole manager chip set is one look
- stats system time-range chips (F4) -> FilterChipGroup (text-sm -> canonical)

Adds aria-label i18n (channels.filters.syncLabel, stats.rangeLabel; HU+EN).
Self-E2E'd: sync chips + tag chips rounded-full, view TABS stay rounded-lg
(their own canonical), stats 7/30/90d toggles. gate green (tsc/eslint-0/
vitest 136/knip/prettier). Branch not merged.
2026-08-15 00:53:12 +02:00
peter f26e329ecf refactor(ui): unify Plex filter chips; retire local Chip/ChipRow (R13 S2c step 3)
PlexSidebar's bespoke chip system is gone. The media scope (both/movie/show)
now uses the shared SegmentedToggle (fullWidth bar, matching the feed
Mine/Library scope), and every filter chip renders the canonical rounded-full
look:
- show / rating / duration / added (single-select) -> FilterChipGroup, with
  the null/"" "Any" sentinels mapped to string option values
- genres / content-ratings (multi-select) -> plain buttons on filterChipClass
  in their own labelled group (same look, independent semantics)

The local Chip (glass-card resting) and ChipRow helpers are deleted; the
unused ReactNode import dropped. Self-E2E'd on Plex: scope bar, show + rating
chips toggle, zero leftover glass-resting chips. gate green (tsc/eslint-0/
vitest 136/knip/prettier). Branch not merged.
2026-08-15 00:48:09 +02:00
peter 579af9cf89 refactor(ui): shared FilterChipGroup; unify feed filter chips (R13 S2c step 2)
New single-select FilterChipGroup primitive on the canonical rounded-full
"Filters bar" chip look (pill + shadow-xs + press feedback), plus an exported
filterChipClass so multi-select siblings share ONE geometry source of truth.

Migrations:
- feed watch-state chips (F1, Feed.tsx) -> FilterChipGroup, testIds preserved
- feed content-type toggles restyled to filterChipClass (stay multi-select) so
  the whole toolbar row is one chip look; wrapped in their own labelled group
- feed date presets + Custom (F3, Sidebar.tsx) -> FilterChipGroup with a
  behaviour-preserving dispatch (preset toggle-off + Custom editor intact)

Adds aria-label i18n (feed.showLabel/contentLabel, sidebar.datePresetLabel;
HU+EN) and FilterChipGroup.dom.test.tsx (6 tests). Self-E2E'd: watch switch,
date preset on/off toggle, Custom opens the range editor. gate green (vitest
136, tsc/eslint-0/knip/prettier). Branch not merged.
2026-08-15 00:35:22 +02:00
peter 61ee7af9e4 fix(ui): snap scaled root font-size to a whole pixel
The root font-size was calc(16px * var(--font-scale)); a non-integer scale
(1.02 -> 16.32px) made every rem-derived box height fractional. When a filter
section's visible row count changes on a facet refetch, its fractional height
shifts everything below it by the sub-pixel remainder — a deterministic ~0.5px
CSS move that snaps to a visible 1px jitter of the whole filter rail at DPR=1.

round(calc(16px * var(--font-scale)), 1px) keeps the font-scale feature but
quantises the root to an integer pixel, so metrics land on integers and the
sub-pixel reflow jitter is gone app-wide. Verified: root 16.32->16px, chip row
26.453->26px, build + Lightning CSS pass round() through intact.
2026-08-15 00:07:25 +02:00
peter 61e848e63a refactor(ui): shared SegmentedToggle for MATCH controls (R13 S2c step 1)
New source-agnostic SegmentedToggle primitive (rounded-full pill track, one
active option, aria-pressed drives the paint via the .pill recipe). Migrates
the three hand-rolled MATCH segments onto it:
- feed tag any/all "Match" toggle (Sidebar)
- feed Mine/Library scope bar (Sidebar; fullWidth + icons + per-option tips)
- Plex any/all combine toggle (PlexSidebar MatchToggle is now a thin binding)

Caption mode (justify-between + label) covers the match toggles; fullWidth
+ size="md" covers the scope bar. Adds SegmentedToggle.dom.test.tsx (6 tests).
Part of the app-wide single-select control unification; branch not merged.
2026-08-14 22:58:42 +02:00
peter ed1a2b7256 test(channels): cover the tab-history logic; memoize viewTabs (S2b review)
Addresses the 2 applied findings from /code-review high on 735d08c:
- Add ChannelsProvider.dom.test.tsx (4 tests): setView pushes a _chTab history entry +
  persists the tab, re-selecting the current tab pushes nothing, a genuine popstate carrying
  _chTab restores + re-persists it, and a popstate without _chTab is ignored. Real storage
  (jsdom) with the account pinned to 1; only NavigationProvider is mocked. vitest 120->124.
- Memoize viewTabs (keyed on the translation fn) so it isn't rebuilt on every Channels render.

The third finding (consolidate the hand-rolled single-select sync chips with <Tabs>) is
deferred by design into the S2c plan, where MatchToggle will be a shared segmented-control
primitive that can also absorb the sync chips.

gate-green: tsc + vitest 124 + eslint-0 + knip + prettier.
2026-08-14 04:01:38 +02:00
peter 735d08c91d refactor(channels): adopt shared Tabs + usePersistedTab for the manager tab row (R13 S2b)
Retire the hand-rolled channel-manager view pills for the shared <Tabs> component (given an
optional className so it embeds in the justify-between header without the default mb-4), and
back view + layout on usePersistedTab instead of manual getAccountRaw/setAccountRaw useState.
That subsumes the account-pin re-hydrate (usePersistedTab now re-reads on the pin), so
ChannelsProvider's bespoke markManagerDirty/useAccountRehydrate is gone; setView keeps the
history-tab logic (_chTab pushState + popstate) layered on the stable raw setter, and
useAccountPersistedState's setter is now memoized so that stable action holds.

Per the user's call, unify every channel-manager pill on the rounded-lg idiom: the view tabs
via <Tabs>, the sync chips and tag chips + the "manage tags" affordance from rounded-full to
rounded-lg — no more odd-one-out rounded-full.

gate-green: tsc + vitest 120 + eslint-0 + knip + prettier. Self-E2E'd: tab switch, browser
Back across tabs (Blocked->Hidden->Subscriptions with localStorage in sync), and a fresh tab
restoring view=hidden on the FIRST load; zero console errors.
2026-08-14 03:50:43 +02:00
peter e6688edbcc refactor(nav): drop redundant sub-view markDirty; add useFilterState re-hydrate tests
Addresses the 2 findings from /code-review high on 726c382:
- NavigationProvider: openChannel/enterYtSearch no longer markDirty. A sub-view is an
  overlay, not a page change — the page re-hydrate's replaceState already spreads the
  _chan/_yt marker, so the overlay survives on top of the RESTORED page. Removing the
  markDirty lets a fresh tab still restore the stored landing page under a pre-pin overlay
  (previously it stuck on the default). setPage keeps markDirty (a real navigation).
- useFilterState.dom.test.ts: model a mutable pinned account and add 3 integration tests —
  null->id re-hydrate adopts the stored map, the migrate seed carries a legacy value forward
  on re-hydrate when the key is empty, and a pre-pin edit is preserved (markDirty guard).

gate-green: vite build + vitest 120 + eslint-0 + knip + prettier. Fresh-tab restore re-E2E'd
(lands on Plex with full state, zero console errors).
2026-08-14 03:26:30 +02:00
peter 726c382524 refactor(account): address account-pin re-hydrate review — extract module, harden guards
Fixes the 5 findings from /code-review high on the account-pin work:
- Extract the reactive-account primitives (ACTIVE_ACCOUNT_KEY, activeAccountId,
  notifyActiveAccountChange, useActiveAccountId) into a new dependency-free
  lib/activeAccount.ts, breaking the storage.ts <-> useAccountRehydrate.ts import cycle.
  storage.ts / api/core.ts / useAccountRehydrate.ts now import from it.
- NavigationProvider: the page re-hydrate now PRESERVES existing history.state markers
  (spread, not overwrite), and openChannel/enterYtSearch markDirty so a sub-view opened
  before the account pins isn't clobbered by the page override.
- useAccountRehydrate: reset the dirty flag after each account transition, so a future
  non-reloading account switch can't skip the re-hydrate and leak the prior account's state.
- Doc-link useAccountRehydrate to the pre-existing useEffect([me?.id]) server-adopt pattern.
- Tests: model a mutable pinned account and add useSortState integration tests for the
  null->id re-hydrate + the markDirty no-clobber guard (vitest 115->117).

gate-green: vite build + vitest 117 + eslint-0 + knip + prettier. Self-E2E'd on a fresh
tab: still lands on Plex with Movies/In-progress/Year restored, zero console errors.
2026-08-14 03:12:50 +02:00
peter 9917d908eb fix(nav,plex,playlists): extend account-pin re-hydrate to every affected provider
The fresh-tab restore bug wasn't Channels-specific: any provider that mounts above
<App> and reads per-account state at useState-init hit it, since <App> pins the tab's
account only after /api/me. Audited all of them — FeedFilters/FeedView/Prefs/App already
self-correct via their own me?.id server-prefs adopt effects, so the still-broken ones
were the pure-localStorage providers with no server adopt: NavigationProvider (landing
page), PlexProvider (scope/show/sort/filters/q), PlaylistsProvider (selected playlist).
Each now adopts the shared useAccountRehydrate hook (markDirty in the setters; page uses
replaceState so the restore doesn't add a phantom Back step). Also baked re-hydrate into
useAccountPersistedState/Object so their consumers (persisted tabs, player prefs) are
robust even if one ever mounts before the pin. Self-E2E'd on a genuinely fresh tab: it
lands on Plex with Movies/In-progress/Year/search all restored on the FIRST load.
2026-08-14 02:56:55 +02:00
peter b57e4fb9bc fix(channels): re-hydrate per-account manager state once the account is pinned
The provider tree mounts above <App>, which pins this tab's account only after /api/me
resolves — so on a genuinely fresh tab (empty sessionStorage) the manager's sort, filters,
view and layout initialised from an unknown account and stayed at defaults until a second
reload. Make the active account reactive (a subscribable store + useActiveAccountId via
useSyncExternalStore; setActiveAccount/clearActiveAccount notify) and re-read the stored
value when the account transitions null→id, via a shared useAccountRehydrate hook. A
markDirty guard (called by each owning setter) prevents clobbering a value the user changed
in the brief pre-pin window; the effect is keyed on the account id only, so the user's own
writes can't loop it. useSortState, useFilterState and ChannelsProvider (view/layout) all
adopt it. Affects sort identically — this was a long-standing caveat, not new to R13.
2026-08-14 02:37:28 +02:00
peter ca9dd54923 fix(channels,datatable): address R13 S2a review + unify tag chips with the Tags column filter
Review fixes (user /code-review high):
- DataTable: hoist a stable EMPTY_FILTERS fallback so a controlled caller that omits
  `filters` doesn't churn a fresh {} each render.
- DataTable: the persistKey blob now stores only the slices this table OWNS — a
  controlled sort/filter is written as null/{} instead of a stale never-read duplicate.
- useFilterState: optional `migrate` seed (used only when storage is empty);
  ChannelsProvider carries the retired LS.channelFilter sync chip forward via migrateSyncChip.
- Channels: guard the sync-chip read with typeof instead of an `as string` cast.

Also unify the tag filter, same as the sync one: the top "YOUR TAGS" chip row is now a
controlled view over the Tags COLUMN filter (`filters.tags`), replacing its own tagFilter
state — so the chips and the Tags funnel share one state bidirectionally, the card layout
applies it via filterRows, and it persists with the rest of the map.
2026-08-14 02:19:44 +02:00
peter 837fdcd408 feat(channels,datatable): unify sync-status into one controlled DataTable filter (R13 S2a)
The top sync chips and the Sync column's own select filter were two independent
states with different vocab (all/needs_full/fully_synced vs fully/needsFull) and
subtly different semantics. DataTable gains a controlled filters/onFiltersChange
pair (symmetric to the existing controlled sort); ChannelsProvider now owns the
filter map via a new useFilterState hook, so the top chips, the Sync column
funnel, the card layout, and the header "full history" deep-link all drive ONE
filters.sync. The card layout reuses the exported filterRows predicate instead of
its own status pre-filter. Vocab unified on the column's fully = recent_synced &&
backfill_done, which now matches the Sync cell's own "fully synced" badge.
2026-08-14 02:00:17 +02:00
peter 665bc6f7b5 fix(plex,channels): address R13 S2 review findings
- Shared usePlexClearFilters hook replaces the duplicated clearAll (was copied in
  PlexBrowse and PlexSidebar) — one "reset every Plex narrowing" both call.
- The Plex duration chip's minutes unit is localized (plex.filter.durationUnit:
  "min"/"perc") instead of a hardcoded "m".
- The Plex collection chip falls back to the "Collection" label, not the raw
  rating_key id, when collectionTitle is missing.
- Dropped the dead rowClassName that dimmed hidden rows in the subscribed table
  (hidden channels are excluded there now — they live in the Hidden tab).
- Renamed the misleading `set` null-check predicate to `has`.

Gate green: tsc/eslint-0/prettier/knip clean, vitest 106.
2026-08-14 01:34:48 +02:00
peter 5e7169e064 feat(plex): active-filter chip row above the grid (R13 S2)
Plex now shows the same at-a-glance "what's narrowing this" row the feed has,
instead of the narrowing being visible only inside the collapsed sidebar. New
usePlexActiveFilters is the Plex analogue of useActiveFilters — it turns the Plex
state (expanded filters + watch-state + sort) into the source-agnostic
ActiveFilter[] the SHARED ActiveFilterChips renders. One removable chip per
dimension (genres/ratings/people emit one each), plus Clear-all resetting filters
+ watch-state + sort. Labels reuse the existing plex.* i18n keys; no new strings.

Lives in components (not lib) since it reads the Plex provider — lib stays
provider-free like useActiveFilters. Gate green: tsc/eslint-0/prettier/knip.
Self-E2E'd: a sort=year chip renders, removing it resets sort to title and hides
the row.
2026-08-14 00:56:03 +02:00
peter 002ef0ab84 feat(channels): proper channel cards for Hidden + Blocked tabs (R13 S2)
The Hidden and Blocked tabs were a bare row of name chips ("two withered
chips"). Both now render a responsive grid of real channel cards via a shared
ExcludedChannelList: avatar + name + open-on-YouTube (reusing ChannelLink) and
one labelled reversing action (Unhide / Unblock). One presentation, parameterised
by icon/label/action/copy — the two "excluded channel" tabs now match.

Gate green: tsc/eslint-0/prettier/knip clean. Self-E2E'd: Hidden tab renders both
channels as cards with avatars, YT links, and Unhide buttons.
2026-08-14 00:49:09 +02:00
peter 6062007947 feat(channels): add a Hidden tab; move hidden channels out of Subscriptions (R13 S2)
Hidden subscriptions get their own tab (mirroring Blocked): a flat list with an
Unhide action, instead of dimmed rows scattered through the Subscriptions table.
- ChannelsView += "hidden"; the subscribed table now EXCLUDES hidden channels,
  so hiding a channel (the eye button) moves it to the Hidden tab and unhiding
  (Eye) returns it — both via the existing patch mutation.
- The "Hidden" top-level status chip is dropped (ChannelStatusFilter loses
  "hidden"); the status row is now purely sync-status (all/needs_full/fully_synced).
- Bilingual i18n (en/hu): channels.tabs.hidden + a channels.hidden block; the now
  unused channels.filters.hidden removed.

Hidden = out of the personal (Mine) feed only, still in the shared Library — a
softer exclusion than Blocked (hidden everywhere). Gate green: tsc/eslint-0/
prettier/vitest 106. Self-E2E'd: hide → appears in Hidden tab → unhide → back,
all /api 200.
2026-08-14 00:38:47 +02:00
peter ccd7170a20 fix(sort): harden shared sort primitives (R13 S1 review round 2)
- SortControl no longer renders a blank select when an always-sorted view
  (no defaultLabel) is handed a key its options don't offer: it falls back to
  the first option instead of value="" (which also tripped React's controlled
  warning). Robustness now lives in the primitive, not each caller.
- PLEX_SORT_FALLBACK is Object.frozen: it's handed out by reference from
  healSort/healPlexSort and stored as provider state, so freezing turns any
  accidental in-place SortState mutation into a loud error instead of silently
  corrupting the shared fallback.
- Test: SortControl falls back to the first option for an unavailable key.

Gate green: tsc/eslint(0 err)/prettier/knip clean, vitest 106.
2026-08-14 00:10:13 +02:00
peter 02c8c71c53 fix(sort): address R13 S1 review findings
- PlexSidebar activeCount now compares the HEALED sort, so an out-of-scope key
  (movie "duration" after switching to shows) no longer inflates the
  active-filter badge while the control + grid show the default.
- PlexProvider strips the legacy `sortDir` from the filters blob, so it stops
  leaking into the facets query key and being re-persisted on every change.
- PlexProvider sort migration guards the fall-through: valid JSON that isn't a
  SortState now falls back instead of using the raw JSON string as the key.
- New shared healPlexSort(sort, scope) is the single heal both PlexBrowse and
  PlexSidebar use (removes the noop-label plexSortOptions call + the duplication).
- Tests: useSortState.dom.test.ts (hydrate/persist/heal round-trip via renderHook)
  and SortControl.dom.test.tsx (options, pick, direction toggle, directionless,
  trailing). vitest 94 -> 105.

Gate green: tsc/eslint(0 err)/prettier/knip clean, vitest 105.
2026-08-14 00:00:31 +02:00
peter 45bd588af7 refactor(channels): share useSortState across the manager + discovery (R13 S1)
ChannelsProvider and ChannelDiscovery each hand-rolled the same sort plumbing
(useState<SortState> + parseSortState + a persist key). Both now use the shared
useSortState hook. hydrateSort gains an empty-options rule: with no vocabulary
passed it parses without healing (these owners heal for display via
ColumnSortControl against the live columns), so the un-healed provider store is
preserved. plexSortKeys made module-private (knip).

Closes R13 S1: feed, Plex, and the two channel tables all sort through one
SortControl + one SortState model. Gate green: tsc/eslint(0 err)/prettier/knip
clean, vitest 94.
2026-08-13 23:41:12 +02:00
peter e4bdc5ea6a refactor(plex): unify Plex sort onto SortControl + a single SortState (R13 S1)
Plex sort was a bespoke chip-pill row plus a separate asc/desc chip pair, with
the key in PlexState.sort and the direction hidden in PlexFilters.sortDir. Now:
- sort is one SortState {key,dir}; sortDir is dropped from PlexFilters (and from
  the facet-key destructure). The wire call still splits it into sort + sort_dir
  at the server boundary, so the backend is untouched.
- The chip sort-row is replaced by the shared SortControl (select + arrow) — the
  same idiom the feed and channel manager use. New lib/plexSort.ts owns the
  scope-dependent vocabulary (movies add duration) + the title/asc fallback.
- PlexProvider migrates the old storage in place: a legacy bare-key plexSort
  string plus the old filters.sortDir become the {key,dir} state, so an existing
  sort preference survives the upgrade.
- PlexBrowse and PlexSidebar heal the stored sort against the current scope's
  options, so a movie-only key (duration) left over after switching to shows
  can't ask the server for an unavailable sort.

Gate green: tsc clean, eslint 0 errors, prettier clean, vitest 93.
2026-08-13 23:36:34 +02:00
peter 440bdafa74 refactor(feed): drive the feed sort toolbar through the shared SortControl (R13 S1)
The feed's bespoke select + asc/desc button is now the generic SortControl,
fed a SortOption[] built from the feed vocabulary (relevance offered only with
a query; shuffle/relevance directionless). The feedSort wire codec
(parseSort/buildSort) stays as the server-boundary translation, and the
reshuffle button rides SortControl's `trailing` slot so shuffle keeps its
re-seed affordance. Behaviour-equivalent; ArrowUp/ArrowDown imports dropped.
2026-08-13 23:28:47 +02:00
peter 7d9da80795 refactor(sort): source-agnostic SortControl + SortOption model (R13 S1 foundation)
Introduce the one sort control every content surface will share:
- columnSort.ts gains the SortOption vocabulary + pure pickSort/healSort/
  hydrateSort helpers (direction lives in SortState, never in the key).
- SortControl.tsx: the generic dropdown + asc/desc toggle, with an optional
  "no sort" choice (tables) and a trailing adornment slot (the feed's reshuffle).
- ColumnSortControl is now a thin wrapper over SortControl (API unchanged, so
  Channels + ChannelDiscovery are untouched); optionsFromColumns moved to
  lib/sortOptions.ts to keep the component file fast-refresh clean.
- useSortState.ts: the per-account persist/hydrate/heal plumbing the channel
  manager and discovery each hand-rolled, ready to replace both.

Pure logic is fully unit-tested (columnSort.test.ts 10 -> 23). Gate green:
tsc/eslint/prettier clean, vitest 93 pass. Feed/Plex/channels migration follows.
2026-08-13 23:24:59 +02:00
peter f3b95ed543 release: 0.63.3 (r12-s5-player-frontend) 2026-08-13 03:45:24 +02:00
peter 8ff28dcc69 release: 0.63.3 — shared player volume core + PlexPlayerSettings extraction (R12 S5) 2026-08-13 03:45:13 +02:00
peter 92ef6116f0 fix(player): hold last-known Plex volume on a corrupt read, normalise once (review)
- applyVolume/setVol now pass the element's CURRENT level as the normalize
  fallback instead of undefined, so a corrupt persisted volume holds the
  playing level rather than jumping to PLEX_SCALE.default=1 (full) — the "full
  blast" failure mode playerVolume.ts's header explicitly warns against. (The
  old bare clamp muted instead; both are wrong for corrupt data.) readMerged
  shallow-merges stored prefs without per-field validation, so a corrupt
  volume is reachable.
- setVol normalises the level ONCE and both applies + persists that same value,
  so the <video> element and the stored pref can never disagree.
- Test documents the hold-last-known contract (corrupt raw + valid fallback ->
  fallback; only an unusable fallback lands on the scale default).

Gate green (tsc / eslint-0 / prettier / knip / vitest 83 / pytest 234).
2026-08-13 03:43:02 +02:00
peter cba992379c refactor(player): share the volume core across both players + extract PlexPlayerSettings (R12 S5 frontend)
Volume-consolidation (conservative — no behaviour or prefs-format change):
- playerVolume.ts is now scale-generic (a VolumeScale parameterises range /
  default / integer rounding). YT_SCALE (0-100 ints) and PLEX_SCALE (0..1
  float) bind the same tested core; the YouTube wrappers keep the original API
  so the existing tests are unchanged.
- The Plex usePlayerVolume hook drops its bespoke clamp01 arithmetic for the
  shared normalizeVolumeIn / stepVolumeIn at PLEX_SCALE. Side effect (a latent
  bug fix): a corrupt persisted volume now falls back to the default instead of
  a bare clamp silently muting it. The separate .muted flag stays Plex-only.
- clamp01 stays for the seek-bar fraction (its non-volume use).
- Tests: the generic core is covered at both scales (float no-round, corrupt ->
  default, step/clamp) — the Plex-side arithmetic finally has unit coverage.

PlexPlayerSettings extraction (verbatim JSX move, behaviour-equivalent):
- The ~240-line gear/tuning panel (sync / playback / subtitle / clock tabs)
  moves out of PlexPlayer into PlexPlayerSettings.tsx (a pure prefs+patchPrefs
  view owning no state). PlexPlayer shrinks 1402 -> 1173 lines; Slider/Toggle
  imports it no longer uses are dropped.

Gate green (tsc / eslint-0 / prettier / knip / vitest 83 / pytest 234). Dev
server smoke: app builds + loads clean, no console errors. The interactive
settings panel needs a playing Plex video (unreachable on localdev) -> owed to
the user's UAT.
2026-08-13 03:20:01 +02:00