The name check used the log redactor's key list, which leaves out the
short keys `pass`, `pas`, `user` and `usr` that the URL stripper drops.
Both now share `isDisplaySensitiveKey`, so a name holding `?pass=…`
collapses to the host; `user`/`usr` are account names like `username`
and stay shown at the top level, as by design.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The browser build's PLAYLIST_UPDATE returned at once while its request
ran on, so the row stayed refreshable and removable and the shared
registry saw the source as idle. The event now settles when the refresh
does (the PWA service resolves after its success or error report; the
Electron fallback awaits its handler), and both the Sources row and the
switcher keep the source busy and held until then.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A fragment is cleaned whatever its shape: `#https://joe:pw@…` loses its
userinfo, an unclassifiable secret is dropped, and the name check reads
a URL in the fragment as well as a parameter list.
- Every refresh holds its source in the root SourceActivityService, which
deletes and the startup auto-refresh already use: Sources-row refreshes,
the switcher's M3U refresh and the shared Xtream flow. The switcher no
longer offers Refresh or Remove for a source the page is refreshing, the
removal service ignores a held source, a second refresh from either
entry point is skipped, and the page disables Remove while it is held.
- The long-input timing spec allows 3 s: the inputs take about 0.25 s,
a backtracking pattern takes minutes, and a loaded runner took 1.6 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A URL nested in a parameter or path segment is percent-decoded one
round at a time until it reads as a URL or parameter list, so one
encoded twice is cleaned too; a value still encoded after the last
round is dropped. The name check unwraps nested values the same way.
- The two segments after `/live/` and its siblings count as an account
only when a stream follows them, so a playlist path such as
`/live/news/list.m3u` keeps its file name in the URL and the title.
- Adding a playlist from the phone drawer closes the drawer first, so
two focus-trapped modals never stack.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A proxy URL can carry a whole source URL, or a token pair, in one path
segment. The display stripper already cleans such segments; the name
check now collects their secrets too, so a name holding them collapses
to the host.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Strip and redact the account segments after `/LIVE/`, `/Movie/` or an
encoded marker such as `/%6Cive/`, as after `/live/`. The display-name
check also treats an Xtream path password as a URL secret.
- A source cannot be removed while a switcher refresh clears and refills
it: the removal service ignores the request, the switcher disables
Remove, and the Sources page shows the refresh as busy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PR #1932 CI:
- CodeQL (polynomial regular expression on uncontrolled data): the
embedded-URL and keyed-pair patterns in stripUrlCredentials could retry
from every position of a long run of letters. Matches now start only
where a name can start (lookbehinds) and every name part is bounded, as
is the bare-userinfo pattern; a spec times 50,000-character runs. The
preceding "wip: linear URL-cleaning regexes" commit holds that change.
- Initial bytes ratchet (+135 B over after master's #1928 lowered the
baseline): stripUrlCredentials sat behind the shared logging barrel,
which startup code imports, so esbuild placed it in an initial chunk
although only the lazy Sources page and switcher use it. It now lives
beside its only consumer in playlist/shared/util source-display, and the
logging barrel exports the two redaction helpers it needs. Measured
locally on the merge with master: 1,537,497 bytes, within 1,538,700.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(web): provide form-field defaults per component, not at the root
app.config.ts provided MAT_FORM_FIELD_DEFAULT_OPTIONS in the root
injector. Importing the token kept @angular/material/form-field in
main.js, and Material 22.2's form field imports @angular/forms/signals,
which would add @angular/forms to startup as well (the reason the
Angular 22.2 dependency update exceeds the initial-bytes ratchet). Every
form field renders in a lazy chunk, so the outline + dynamic defaults
now come from provideFormFieldDefaults()
(@iptvnator/ui/components/form-field-defaults) on each component that
renders <mat-form-field>.
tools/nx/check-form-field-defaults.mjs (ui:form-field-defaults:validate,
run in CI) reports a component that renders a form field without the
provider, and any use of the token outside the shared provider. The
Xtream import form test asserts outlined, dynamically sized fields with
no root provider; without the component provider they render as fill.
renderer.initialBytes: 1,596,201 -> 1,534,604 bytes (-61,597) on a local
production build; the baseline is lowered to the measured value.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(web): cite #1928 as the initial-bytes baseline evidence
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(ui): check form-field providers per component on the TypeScript AST
The guard matched templates and providers with regular expressions over
the whole file: a quoted inline template went unread, one component's
provider covered another in the same file, an earlier array-valued
provider hid the call, and provideFormFieldDefaults() in app.config or a
route passed although it loads Material's form field on startup again.
It now reads each @Component decorator's own template and providers
array, and reports any provideFormFieldDefaults() call outside one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Local review on the PR round:
- Greptile: the Sources sort menu's radio group had no accessible name,
unlike every other menu group (UI guidelines). It is named by its
section, "Sort by".
- Codex: the switcher popover capped its height once, when it opened, so
shrinking the window while it was open left the footer and the lower
rows out of reach. A window resize refits the open popover.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Local Greptile (two P2s) on the PR round, both regressions against master:
- The redesigned Sources page scrolled as one, so the title, Clean up and
Sort left the view in a long library (master kept a sticky header, and
the UI guidelines say headers stay and lists scroll). The header now
stays and only the list below it scrolls, which also keeps a row reached
by keyboard from scrolling under the header.
- Master marked the active playlist on the Sources page; the new row only
tinted the current row in the compact (switcher) size. The active
source's row is now tinted with the same --app-list-selected-* token on
the page too; the switcher keeps its check.
The design handoff called the header non-sticky and the current tint
switcher-only; both followed master and the guidelines instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Local Greptile (5/5, one P2) on the PR round: the row's status tag
formatted "Not responding · 3 days" against the time the row mounted (or
the language last changed), so a new failure never gained its duration
and an existing one froze while Sources stayed open. A shared root
SourceClock now ticks once a minute and only the status tag reads it, so
dates and the meta line do not recompute every minute.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PR #1932 round 1 (CI + local Codex/Greptile on the bot fixes):
- CI: category-management.e2e refreshed the active playlist through the
old switcher trigger's inline refresh button, which the redesign
replaced with the switcher row menu (⋯ → Refresh playlist). The helper
now goes through that menu; it failed on all three OS shards.
- A desktop Xtream delete's completed event cleared its progress while
PlaylistsService still cleaned up (parental lock, stored state), so the
already-deleted source was actionable again on Sources and back in the
switcher until the store dropped it. A terminal event now only ends
Cancel; the row stays busy until the delete settles and the removal
service clears it.
- A running delete (from the switcher, say) disappeared from Sources when
the page's saved filters excluded it, hiding its only progress and
Cancel. Running deletes stay listed whatever the filters.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Greptile bot (PR review, P1 security): an unnamed import of
https://host.test/list.m3u#api_key=secret123 stored list.m3u#api_key=…
as its title. isUrlDerivedName only knew names ending in a query, and
sourceSecrets only read the top-level query, so the row and the toolbar
showed the secret while the displayed URL hid it.
A name that ends the URL with a query or a fragment is now URL-derived,
and shows the host. The secrets a typed name is checked against now
include sensitive fragment parameters and, as stripUrlCredentials cleans
them, the credentials of URLs and parameter lists nested in parameters
(nested account names included).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex bot (PR review, P1): stripUrlCredentials copied every non-sensitive
parameter value unchanged, so a URL nested in one (a proxy's
?url=http%3A%2F%2Fjoe%3Ahunter2%40origin.test%2F…) kept its userinfo and
password and reached the Sources row through sourceDisplayUrl.
Kept query and fragment values and path segments are now cleaned
recursively, up to four levels: a nested URL loses its credentials the
same way, a nested parameter list loses its sensitive keys, and a part
that still names a secret (joe:hunter2@host, token=…) is dropped. Deeper
nesting is dropped rather than shown.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Local review round 7 (Greptile 5/5 with one P2; Codex was clean): when the
last source of the remembered switcher type was removed while two other
types remained, the list kept filtering by the missing type. Its segment
was gone, no segment was selected, and the list said "Nothing found". The
list now filters by All until a source of that type returns; the remembered
choice is kept.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The series view re-normalizes the selected item through
createStalkerInfo() on every render, and that mapping carried only seven
hand-picked tmdb_* fields. tmdb_genres, tmdb_countries, tmdb_media_type
and tmdb_supplied_release_date were dropped right after enrichment, so
the clickable Discover chips never appeared on Stalker series.
The carried keys now come from a Record over StalkerVodInfo's tmdb_*
keys, so a new enrichment field fails to compile until it is listed.
Saved Stalker entries now also keep tmdb_supplied_release_date, and the
recommendations exclusion index (trustedReleaseYear) skips a date TMDB
supplied, as the flag's contract requires.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Local review round 6 (Codex; Greptile was clean at 5/5):
- Typing a search or picking a type in the switcher highlighted the first
match but kept the list scrolled where the previous result left it, so
the highlighted row could sit above the view. The list returns to the
top whenever the search or type changes.
- A source with no kept history whose check was inconclusive (connection
checks paused by the connectivity guard, a cancelled check) lost the
live cause and time, and its dot said only "Not verified". The status
now keeps them; a running check still reads as checking.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Local review round 5 (Greptile; Codex was clean):
- Ctrl/Cmd+P opened the playlist switcher over an open dialog (an
editor, a confirmation), taking the focus out of it and handing it back
to the toolbar behind the dialog on close. The shortcut now leaves the
key alone while MatDialog has open dialogs.
- SourceListRefreshService logged a failed refresh's raw error, which can
carry the playlist URL and its credentials. It now logs through the
redacting portal logger (createLogger).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Local review round 4 (Codex + Greptile):
- Removing the active source handed over to its neighbour even when that
neighbour's own delete was still running ("Removing…"), activating a
source about to disappear. replacementAfterRemoval (playlist shared
util) picks the nearest row that is not leaving, on the Sources page and
in the switcher; SourceRemovalService also refuses a leaving replacement.
- In the browser build a "Check again" for an account the user then
edited could finish last and overwrite the new account's status under
the same playlist ID. Portal statuses are now keyed by playlist and
connection fingerprint, so the old check lands under the old key.
- The switcher scrolled the highlighted option into view through a
selector built from the playlist ID, which a restored ID with "." or
":" broke (or made throw). It now finds the row by its exact DOM ID.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Local review round 3 (Greptile; Codex was clean):
- Name order compared the stored title, while the rows show a URL-named
or credential-bearing source by its host: a source stored as
get.php?username=… sorted by its hidden account details. sortPlaylists
takes the list's name function, and Sources and the switcher pass
sourceSortName, the name they display.
- The Xtream delete benchmark clicked Remove while preparing, which starts
the four-second Undo window; capture setup before the trigger could
outlast it, so the delete committed on its own before the measurement
and the close button was gone. Preparation now stops at the open menu,
and the trigger clicks Remove and then the toast's close button, with
the trigger time taken right before the commit click as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Local review round 2 (Codex + Greptile):
- The delete committed whenever the shared snack bar was dismissed, so
another message (a refresh finishing elsewhere) cut the four-second Undo
window short and deleted the source early. The window is now the
service's own timer; only the toast's close button (commitNow) or a
second delete commits sooner.
- The health history was keyed by playlist ID only, so an edited address
or account inherited the old connection's result, failing run included.
Entries now keep a 32-bit FNV-1a fingerprint of the source health key
(no URL or credential); a result for another connection starts over and
a stale entry no longer shows.
- The M3U refresh-cancellation probe still queried the removed row's
.cancel-btn, .busy-state__message and .action-spinner, so its cancel
never fired; it now uses the source row's selectors (smoke run passes,
cancelEffect=true).
- The Xtream delete benchmark waited to catch the "Removing…" row coming
back before it left, which a fast delete could finish before the wait
began. The driver now arms a delete-playlist operation window before the
commit click and ends on its completed event and the absent row.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Local review round 1 (Codex + Greptile) on the redesign:
- A saved Responding / Not responding filter hid every source without a
result, and the hidden rows were what started the checks, so the list
stayed empty. keepSourcesChecked (portal shared data-access) now keeps
every library source checked while the Sources page lives, on desktop
where a check is one shared job that rows join.
- Removing from the switcher left its focus trap open around the search,
outside of which the Undo toast sits: Tab could not reach Undo. The
popover now closes first; Undo takes the focus and returns it to the
trigger (SourceRemovalOptions.returnFocusTo). Every switcher action
closes through one path, so a closing row menu no longer pulls the focus
back into the popover.
- A running desktop Xtream delete brought its source back into the
switcher as an ordinary option that could be opened or removed again; the
switcher now leaves it out until the delete ends (the Sources page shows
its progress and Cancel).
- Undo re-activated the removed source over a source the user had chosen
since, when nothing had replaced it (it was the only row a filter
showed). Undo now restores only while no other source is active.
- The switcher highlight stays on its option, or in range, when the list
changes under the same search, so Enter still opens something.
- The status dot named every failure "Could not connect". SourceStatus
now carries the check's cause (access denied, account disabled, timeout…,
kept with the health history and validated on read), and M3U dots again
say the check covers the playlist URL, not its channels.
Tests: new specs for keepSourcesChecked, the saved-filter check, the Undo
guard and focus return, the switcher close/highlight/deleting cases and
the dot's cause; the filter and Undo specs fail without their fixes. The
switcher keyboard E2E now deletes and undoes from the keyboard.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(backup): export and restore Xtream user state in the PWA
Without an Electron bridge the backup wrote empty Xtream favorites,
history and positions, and import only parked the state until the
playlist was next opened.
PwaXtreamBackupStateService reads and writes the four PWA maps through
USER_DATA_STORAGE, so self-hosted server storage is covered as well.
Export converts typed keys, resolves legacy ids only through their
snapshot, and fails on an unreadable map instead of writing []. Import
applies at once through applyAndConsume, remapped to the imported
playlist id; a failed write keeps the snapshot parked for the store.
Title snapshots travel in a new optional collectionItems field that
older builds ignore. The storage key names are shared with the portal
data source as XTREAM_USER_DATA_KEYS.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(backup): write PWA title snapshots before the Xtream lists
A browser restore that failed after its first list write kept the
pending state parked for the Xtream store, but that replay carries no
title snapshots, so they were lost. Merge them first; a damaged cache
is replaced, while a storage that cannot be read still fails the entry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(backup): keep the artwork of PWA recently viewed items
The browser build saves a backdrop with each view and shows it ahead of
the title snapshot's, but the export dropped it, so a restore showed
older artwork. Carry it as an optional backdropUrl on the archive's
recently viewed items; the desktop build ignores it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(xtream): keep view artwork in the PWA pending restore state
A failed browser restore is replayed by PwaXtreamDataSource.restoreUserData,
which rewrote recently viewed items without their backdropUrl; a refresh
dropped it the same way in clearPlaylistContent. Both now carry it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(backup): restore PWA Xtream lists before the snapshot cache
Writing the optional title snapshots first could take the storage space
the favorites, history or positions needed, failing a restore that fits
without them. The lists now go first, as the snapshot-storage contract
asks; after a failed list write the snapshots are still merged for the
store's replay, unless storage is full.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(backup): pick the Xtream backup path by the SQLite data source
The backup chose its Xtream path by the mere presence of window.electron,
while XTREAM_DATA_SOURCE falls back to the browser storage whenever the
bridge lacks the Xtream SQLite API. On such a partial or stale preload the
export read the wrong store and the restore took the SQLite path. Both now
follow RuntimeCapabilitiesService.supportsXtreamSqliteDataSource.
Also casts the test helper's prototype instance through unknown, which the
spec typecheck requires.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(xtream): restore PWA playback positions under the target playlist
A PWA restore that is replayed by the Xtream store kept each position's
exported playlistId. When the backup was imported under another id, the
VOD details page rejected those positions as belonging to a different
playlist. restoreUserData now stamps the playlist it restores into.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The switcher popover is a height-capped flex column. With a long list
(hundreds of sources) the browser shrank every part in proportion to its
size, not only the list: the type filter, which clips its overflow,
collapsed to 2px and showed no labels, and the search field dropped from
36px to its 21px text line. The header, search, type filter, empty state
and footer are now flex: none, so only the list shrinks and scrolls.
The search field now matches the header and side-panel search fields:
the shared --app-search-* tokens, a 10px radius, an 18px icon, the same
text size and focus halo.
The Electron E2E opens the switcher in a 320px-tall window and checks the
search height, the type toggles and the scrolling list; it fails without
the fix (search 21px).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The context panel beside Sources, categories, Settings and collections
drew a 1px outline-variant border against the content surface, whose
background, shadow and rounded top-left corner already mark the edge;
the line clashed with that corner. The border stays 1px but transparent,
so the panel width, the persisted sidebar widths and the resize handle's
position do not move. The handle keeps its hover and drag highlight.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(portals): drop provider placeholder metadata at the mapping layer
Stalker and Xtream portals send "N/A", "-" or an unrated "0" score for
fields they have nothing for, and the detail templates rendered them as
data. Normalize them where provider rows are mapped: createStalkerInfo,
the shared VOD view-model adapters and the Xtream detail selection.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(portals): hide placeholder ratings on catalog cards
formatGridRating passed a provider "N/A" through as the badge text and
rendered an unrated zero as "0.0". Read the value through the shared
readProviderRating so both hide the badge and resolveGridRating reaches
the generic rating behind a placeholder rating_imdb.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(xtream): keep placeholder ratings off the sparse VOD fallback page
A get_vod_info made only of placeholders is now stripped to sparse and
routed to the fallback page, which read the top-level catalog ratings
raw: "N/A" survived and a zero became "0.0". Read every candidate
through readProviderRating so the next usable rating wins.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* perf(search): one 300 ms debounce between typing and the global search query
Typing in the header search box waited twice before a query ran: 350 ms in
the workspace shell (SEARCH_INPUT_DEBOUNCE_MS), then another 300 ms in
SearchResultsComponent. On J4 (large catalog, "system") the query and first
page take 30-40 ms; about 650 ms of the ~690 ms from the last key to settled
results was waiting.
The results component now searches as soon as the applied term reaches the
store, and the shell debounce is the only one, lowered to 300 ms: above the
gap between keys of steady typing, so a word typed in one go still queries
once. Superseded queries are dropped by the existing request versions. The
component's route sync reads the store term untracked, so a term the shell
applied before writing q is not pushed back to the stale q and re-queried.
J4 locally (3 runs per build): lastKeystrokeToSettledMs P50 692-695 ms ->
336-337 ms, ipcCallsPerSearch 1 and sqlStatementsPerSearch 2 unchanged. At
400 ms per key both builds run five queries; only typing with 300-350 ms
gaps queries per key where it did not before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(journeys): enforce J4 ipcCallsPerSearch 1 and sqlStatementsPerSearch 2
Both counters were identical in all 816 J4 iterations of the 136 CI journey
jobs since #1816 and in all 18 iterations of three runner runs of this PR
(run 38165392046, attempts 1-3). They guard the single debounce: a search
that queries per keystroke or twice for one term raises them. Guard-only
note, since the wall-clock gain came without moving them. Added to the
Performance journeys --only list; the direction check reports 2 added,
nothing weakened.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(search): run a prefilled in-portal search again once the catalog has loaded
Review follow-up on #1899 (Greptile P1). Without the results component's
300 ms timer, an in-portal search applied while the portal's catalog is still
loading (a reload or deep link with q) runs against the incomplete catalog,
and nothing re-ran it afterwards. The old timer only helped when loading
finished within 300 ms. The component now runs the current in-portal search
once more when nothing of the catalog is loading or importing any more, the
readiness the Discover route already uses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(search): one search effect for a new term and the loaded catalog
Review follow-up on #1899 (Greptile). With separate effects, a term and the
end of catalog loading arriving in the same flush each ran executeSearch for
the same term: the request version dropped the first answer, but the
catalog scan or desktop search still ran twice. One effect now tracks both
triggers and searches once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(workspace): don't flash the import overlay on a cached Xtream open
Opening an already-imported Xtream portal reads the saved catalog in tens
of milliseconds, but the session made the shell mount the full-screen
import overlay right away, so it flashed for about two frames between the
skeleton and the loaded page (J2 open-source).
A session that has only read the saved catalog (loading-cached, store
isImporting still false) now shows the overlay after a 300 ms delay.
Remote import phases and refresh preparation still show it immediately,
and a slow or stuck cache read still blocks and stays cancellable.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(workspace): keep the overlay delay while another playlist refreshes
Refresh preparation skipped the cached-load delay for any playlist, so
opening a cached portal while another playlist prepared a refresh still
flashed the overlay. Only preparation of the shown playlist (any on the
dashboard) now shows it at once.
Also words the J2 layout-shift history from #1895 in the past tense: the
cached-load overlay no longer mounts on a J2 click.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The refine menu held 12 rows at 48px plus two section titles (657px).
Angular Material 22 sets no max-height on a menu panel, so below ~800px
the overlay was pushed up over its own Refine button, and at 600px the
last thresholds were clipped behind an invisible scrollbar.
The minimum-rating thresholds now open in a submenu from one row that
names the active threshold. The menu is about 390px and opens below its
button at 600px. The submenu trigger sits in a role="none" wrapper:
Material offsets a submenu by the first direct mat-menu-item's
offsetTop, and with the sort rows inside a role="group" that item was
the trigger itself, which opened the submenu 335px off screen.
The Electron E2E opens both menus by keyboard in a 600px-tall window
and checks that each lies whole on screen without scrolling.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clearing an optional playlist field (User-Agent, Referrer, Origin,
credentials, EPG URL lists, file path) did not persist in Electron: the
codec maps an empty value to `undefined`, `ON CONFLICT DO UPDATE` skips
undefined keys, and `parseAppPlaylist` prefers the stale column over the
updated payload, so the old value came back on the next read.
`playlistConflictUpdate` now sets every optional column the document
leaves empty to NULL, so both desktop upserts (`DB_UPSERT_APP_PLAYLIST`
and `DB_UPSERT_APP_PLAYLISTS`) replace the stored row. The cleared-columns
list moves from the web backend's playlist repository into the shared
conflict module, and the server now uses the shared version.
`date_created`, `name`, `type`, `autoRefresh`, `payload` and `last_usage`
are never cleared.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(m3u): keep groups hidden in Manage groups after a restart (#1894)
Electron fills the playlist store from DB_GET_APP_PLAYLIST_METAS, which
reads the metadata columns only. hiddenGroupTitles lives only in the JSON
payload, so after a restart the store had none and the groups rail showed
every group, although SQLite still held the saved selection; saving the
dialog from that state then overwrote it.
M3uWorkspaceRouteSession already reads the full playlist to publish its
channels and favorites. It now copies hiddenGroupTitles from that read into
the store (updatePlaylistMeta, persist: false) before the channels, unless
the store value changed while the read was queued behind a save.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(m3u): wait for the playlist entity before hydrating hidden groups
The startup inventory publishes playlists only after settings and EPG
reconciliation, while a restored or deep-linked playlist route waits for
the metadata read alone. The route session's full read could therefore
finish first, and updateOne dropped the hydration for the missing entity
for the whole session. The hydration now waits for the entity.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(m3u): cover hidden groups when the Groups route opens before the inventory
An Electron E2E reloads the renderer on the Groups route while the startup
inventory is held (the EPG reconcile fails once and its retry is parked),
and records from document start whether a hidden group is ever rendered
visibly. It fails on the previous hydration that did not wait for the
playlist entity. A service test pins that getPlaylist reads hidden groups
from a meta update that is still being written.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(m3u): type empty hidden-group arrays in the route session spec
The CI spec typecheck (apps/web/tsconfig.spec.json) rejected `[]` inferred
as never[] inside the PlaylistMeta and Playlist casts with TS2352.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
- Delete app-playlist-item and SourceHealthIndicatorComponent, replaced by
app-source-row and its tile dot.
- Delete the ngrx type-filter slice (FilterActions, selectedFilters,
selectActiveTypeFilters); the Sources filters live in
SourcesViewStateService.
- Drop the empty state's unused "no results" variant and its styles.
- The switcher and Sources list components are no longer in the max-lines
baseline.
- Docs: UI guidelines (source row, Sources page and switcher, status dot,
selected-row tokens, UI-kit mixins), the source-health contract and the
zoneless inventory. Release notes for the redesign and the credentials fix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The switcher's mat-menu becomes a CDK connected overlay
(app-playlist-switcher-panel), 440px wide, with a dimmed, blurred
backdrop; only its list scrolls and row menus open above it.
- Header with the source count, Add playlist and All sources.
- Search by name or host, autofocused; the type filter is one segmented
control (All / M3U / Xtream / Stalker with counts) instead of three
checkboxes, remembered between openings (the old checkbox setting is
migrated). The search is no longer kept between openings.
- "Recent" (the three most recently opened, kept across restarts) above
"All" in the Sources page order, as compact source rows: the current
one tinted with a check. Each row's ⋯ menu holds Refresh, Playlist
info, Account info, Check again and Remove (with Undo); the footer's
info, account and add actions moved there and to the header.
- Keyboard: a listbox driven by aria-activedescendant from the search
field. Arrow keys move and wrap, Enter opens, Shift+F10 opens the
row's menu, Escape closes and returns focus to the trigger. Hints in
the footer.
- Deleting from a row menu moves focus to the toast's Undo, since the
row (and its focus) left the page.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The trigger is a 40px secondary button: the active source's tile with
its status dot, its name and "type · channels" (or the source count),
and a chevron that turns while open. The name comes from
sourceDisplayName, so a URL-named playlist no longer shows its URL or
credentials in the toolbar.
- Cmd+P / Ctrl+P opens the switcher wherever it is shown (Cmd+K stays
the command palette) and is listed in the keyboard shortcuts dialog.
- The trigger's inline refresh button is gone; refresh stays in the
playlist's ⋯ menu and the command palette.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Side list (UI-kit side list): title with the playlist count and an Add
button, a Type group, and on desktop a Status group (Any / Responding /
Not responding). The two filters combine and persist; each group counts
within the other's choice. The chosen row uses one selected-row token
(soft tint, outlined in light theme).
- Page: a 28px title with the visible count instead of the bordered
header bar; Clean up and the sort menu are secondary buttons. Clean up
shows how many sources do not respond. Sort adds Last updated, Most
channels and (desktop) Not responding first, and keeps Name Z-A and
Custom order.
- List: app-source-row with hover actions (Refresh, Edit, ⋯); a failing
row keeps Retry in view. "No matches" offers Reset filters. Dragging in
custom order no longer reuses positions of rows a filter hides.
- Delete with Undo: no confirmation; the row leaves at once and the delete
runs when the toast closes (its close button commits at once). Deleting
the active source switches to the next row. A large Xtream delete shows
its row as "Removing…" with progress and Cancel, as before.
- The list's refresh flows moved into SourceListRefreshService; E2E and
performance drivers follow the new markup and delete flow.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One app-source-row (regular for the Sources page, compact for the
playlist switcher) with an app-source-tile, built on pure helpers:
- resolvePlaylistSourceKind: one provider rule for icons, filters and
counts (the list filter and the panel counts used to disagree).
- sourceDisplayName / sourceDisplayUrl: a name or URL never shows a
password, token or MAC. URL imports that stored
"get.php?username=…&password=…" as the title now show the host.
- stripUrlCredentials (shared/logging): drops what the log redactor
would mask, plus short user/pass keys.
- formatSourceDate / sourceMetaParts / sourceStatusTag: one meta line
order and Intl dates and durations in the UI language.
- SourceStatusService + SourceHealthHistoryService: one status per
source (desktop health check, browser Xtream portal status) and the
last conclusive result kept across restarts, so "not responding for
3 days" survives a relaunch. No URL or credential is stored.
Also fixes playlistNameHasCredentials answering differently on repeated
calls (global regex lastIndex), and adds the UI-kit button, menu and
segmented-control mixins (libs/ui/styles/_app-controls.scss); menus opt
in with class="app-menu", styled by the components that use them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(playback): resume an episode whose connection drops instead of skipping it (#1902)
Embedded MPV sessions run with keep-open, so a provider that closes the
connection mid-episode (and refuses ffmpeg's own reconnect) reaches the app
as eof-reached: status `ended` at the drop position. The reconnect policy
counted `ended` as a loss only for live playback, so on VOD it stood for
the end of the file and series autoplay started the next episode.
- The reconnect policy now treats a VOD `ended` with more than
max(10 s, 1 % of the duration) left as a dropped connection and reloads
it at the last position. A reload that ends again before getting 5 s past
where it resumed proves the file ends there (bitrate-estimated duration),
so it is left as the end and autoplay continues after one attempt.
- The service passes the snapshot duration to the policy.
- The player emits playbackEnded only for an `ended` session without a
pending reconnect.
- Xtream mock: `streamdrop` scenario whose episodes drop a third of the way
into a local 60 s clip and refuse the retry for 4 s; Electron E2E covers
the native-view engine (skips without an Embedded MPV runtime).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(playback): keep the early-EOF retry on a refused reload or after a seek
The resume point that tells a reload ending where it resumed from a new
drop was recorded before the reload ran, so a reload the addon refused made
the original EOF look like the end and the next poll cancelled the retry.
Record it only once the reload started, and forget it when the user seeks:
an EOF after a seek is judged on its position alone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(e2e): fail the stream-drop resume check when the reload restarts the episode
A reload that started the episode over would also reach the 25 s mark
within the timeout, since the mock drops each episode only once. Every
position played after the drop must now stay within 3 s of it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(workspace): slide the content and header into place when a source opens
Opening an Xtream portal from the dashboard inserted the 323 px context
panel and the header Back in the frame that swaps the page: the content
pane jumped right by the panel's width and the switcher, search and actions
by Back's slot. That one frame was J2's whole layout shift (0.2327: 0.222
for the content pane, 0.011 for the header, scored with the pane's
distance), plus 0.0001 when the category search and sort buttons appeared
with the categories.
LayoutSlideDirective animates these moves as a 180 ms FLIP: the new layout
applies at once and only a transform animates, so nothing waits and no
layout shift is recorded. The content surface covers the strip it uncovers
while sliding, Back fades in, and reduced motion keeps the jump. The
category search and sort buttons render disabled while the list loads.
Locally J2 renderer.layoutShiftScore reads 0 (was 0.233), J1 0 / 0, J3
0.001, J4 0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(journeys): lower J2 layoutShiftScore to 0
The slides land on the runner: run 38147072417 (d2cb9445d) reads
open-source/renderer.layoutShiftScore 0 with no shift recorded in all 5
measured iterations (J1 0 / 0, J3 0.001, J4 0 unchanged). Ratchet the
baseline from 0.233 to 0 and record the perf:compositing evidence for the
slide in the shell contract.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
upgradeContentTitleFtsTokenizer recreates and rebuilds content_title_fts and
records migration:content-title-fts-remove-diacritics:v1, but not
migration:content-title-fts-trigram:v1. On a new profile the tokenizer
upgrade therefore did the first rebuild, and on the second launch
ensureContentTitleFts found its own marker missing and rebuilt the whole
index again, synchronously inside initDatabase on the main thread, right
after the first catalog import. A trigram rebuild costs about 4.5 s per
million titles (sqlite3 3.51, synthetic titles). Every J4 iteration paid it
too, since each launches a copy of a freshly seeded profile.
The upgrade's transaction now records both markers. The skipped-release
upgrade harness counts rebuilds across every initialization of a profile
and requires exactly one; it fails for all six cases without the fix.
Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(interfaces): add the server storage HTTP contract
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(web-backend): read server storage configuration
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(web-backend): open the server database and track revisions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(web-backend): playlist and user-state repositories
Playlist documents use the shared desktop row codec, so the server writes
the same rows. keepPayload upserts take the stored playlist/items/header
fields when the client leaves them out. Xtream user data lives in the
server-only server_user_state table, one JSON value per namespace and
playlist.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(web-backend): storage error mapping and access token guard
Storage errors map to JSON status bodies and are logged by name and code
only. An optional bearer token guards every route except /, /health,
/config.js and /v1/instance, provider proxy routes included.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(web-backend): serve the /v1 server storage API
/v1/instance is always public and reports whether server storage is on.
With storage, /v1/playlists serves whole documents with ETags and 304s,
accepts keepPayload upserts, and deletes playlists with their user state;
/v1/user-state and /v1/app-state store the rest. The proxy routes are
unchanged unless an access token is configured.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(web-backend): open server storage on start and close on shutdown
With IPTVNATOR_STORAGE=server, main.ts opens the database before it
listens and exits with a clear message when the data directory is not
writable. SIGTERM and SIGINT close the server and the database. The
native better-sqlite3 module stays outside the esbuild bundle.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web-backend): match weakened ETags on playlist reads
nginx weakens the ETag of a gzipped response, and clients send the weak
form back in If-None-Match. The route now compares tags weakly, as RFC 9110
requires for GET, so it answers 304 before reading the document.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(docker): persist server storage on a volume
The image builds better-sqlite3 in a native stage per target platform,
enables server storage on the /data volume, and raises the nginx body
limit to 64 MB for /api with gzip for JSON. The compose file mounts a
named volume and documents the optional access token. The PWA does not
use the storage yet; docs say so.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(web-backend): type the storage route responses
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web-backend): harden server storage reads, deletes and app state
- Metadata reads return the stored document without its payload fields,
so fields that live only in the JSON survive a metadata round-trip.
- A conditional GET checks that the playlist exists before answering 304.
- Deleting a playlist and its user state commits in one transaction; the
ETag moves only after the commit.
- Browser app state is stored under a client: prefix and the instance id
is repaired when empty, so a client write cannot stop the backend.
- User-state buckets have no prototype, so ids like __proto__ round-trip.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(self-hosted): keep the access token unset until the PWA sends it
Add the release note for the Docker server storage volume.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web-backend): bound shutdown and reject padded playlist ids
- SIGTERM closes idle connections at once and cuts the rest after five
seconds, so a long provider download cannot keep the backend running.
- Playlist ids with surrounding whitespace are rejected: the row codec
trims ids, so storage and ETags would track different keys.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web-backend): clear optional columns on whole-document upserts
The row codec maps empty values to undefined, which ON CONFLICT DO UPDATE
skips, so a cleared User-Agent, Referrer or credential kept its old column
value and came back on the next read. Server upserts now write NULL to
every replaceable column the document no longer sets.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(self-hosted): linear token parsing, no-store app state, longer start
- The access guard reads the Bearer token by string scanning instead of a
regular expression that CodeQL flagged as polynomial on long whitespace.
- GET /v1/app-state/:key sends Cache-Control: no-store like the other
state routes, so devices never reuse a stale value.
- The entrypoint waits up to IPTVNATOR_BACKEND_START_TIMEOUT (300 s) for
/health, because the backend migrates its database before it listens.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(self-hosted): metadata reads skip payloads, redact JSON parse warnings
List playlist metadata from a server-only payload-free copy of each
document instead of running json_remove over every payload blob, and log
only the error name when a stored JSON value fails to parse, since the
parser's message quotes the stored text.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web-backend): copy carried-over panel clock into playlist metadata
When an upsert keeps the stored serverTimezone, build the metadata copy
from the saved payload so metadata and full reads agree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Every reused-instance MPV IPC command now carries a request_id and waits, within the existing 2 s bound, for mpv's matching reply; an error or missing reply falls back to a fresh launch like a socket failure, and the racing post-loadfile seek is gone. MPV launches run one at a time through a launch queue so overlapping launches never act on the same child; Stop on a queued launch delegates to the in-flight launch's own closer, a failed exit stays retryable, and the dock keeps the newest launch. Position polling and output-error reporting moved out of mpv-session.service.ts, which leaves the max-lines baseline.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(storage): server storage design and implementation plans
Design for keeping playlists and user data on the self-hosted server
(#305), revised against master, plus three implementation plans: storage
seams, the web-backend storage API, and the PWA server mode.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(database): share the playlist row codec
Move buildPlaylistRow, parseAppPlaylist and the upsert conflict helpers
from the Electron database operations into libs/shared/database
(@iptvnator/shared/database/app-playlist) so the self-hosted web backend
can write the same rows. The Electron modules re-export them; behavior is
unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(database): read the data root from IPTVNATOR_DATA_DIR
Deployments that keep the database on a mounted volume, such as the
self-hosted web backend in Docker, can now set the data root. The E2E
override still wins; without either variable the root stays ~/.iptvnator.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(services): add the playlist storage backend resolver
PlaylistStorageBackend becomes the single place that decides whether
playlists persist through the Electron bridge or IndexedDB, and defines
the PlaylistDocumentStore contract a server store will implement.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(services): resolve playlist storage through one backend
PlaylistsService asks PlaylistStorageBackend for its document store
instead of reading window.electron and supportsSqlite directly. Paths that
only make sense on the desktop (legacy IndexedDB import, worker deletion
with progress, the favorites-channel query) check for the Electron kind
explicitly. Behavior is unchanged: the backend still resolves to the
Electron bridge or IndexedDB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(xtream): store PWA user data through USER_DATA_STORAGE
PwaXtreamDataSource and PwaCollectionStorage read and write favorites,
recently viewed items, collection snapshots and playback positions through
the new USER_DATA_STORAGE token (default: localStorage) instead of calling
localStorage directly, so a deployment can substitute a server-synced
storage. Behavior is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(pwa): document the storage seams
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(database): bracket access in the shared playlist codec
libs/shared/database compiles with noPropertyAccessFromIndexSignature,
which the Electron backend did not enforce on the moved code.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* build(electron): alias the shared playlist codec in the worker bundle
The database worker is bundled by esbuild with an explicit alias table;
without the new entry, @iptvnator/shared/database/app-playlist resolved
under the library's index file.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(services): read localStorage lazily in USER_DATA_STORAGE
When the browser denies storage access, reading localStorage itself
throws. The default factory returned the global, so injecting the token
failed and PwaXtreamDataSource could not be constructed. The adapter now
reads the global inside each call, where the data source already guards
its reads.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(database): describe /data as an example data root
The Docker image does not set IPTVNATOR_DATA_DIR yet; that arrives with
the server storage backend.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
CatalogTitleMatchService.matchTitles sent a caller's whole batch as one DB_MATCH_TITLES request, and the DB worker answers one request at a time: a recommendations batch held every other read for 95 s on a 3.9M-title library. The service now deduplicates the titles, keeps the 200-title cap, and sends requests of five titles one after another, concatenating the answers in order. The answer stays all-or-nothing: a failed request returns no matches, a lock that starts withholding everything stops the batch, and a batch starts over when ParentalLockService.version() changes between requests (no matches after three changing attempts).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): welcome screens and drop overlay follow the app theme
The welcome dashboard, the empty Sources page and the playlist drop
overlay switched on `prefers-color-scheme`, so with the app set to dark on
a light OS (or light on a dark OS) they painted the other theme's colours.
They now read `--app-*` tokens, which follow the `.dark-theme` class set
from Settings, and the hard-coded blues are derived from the selection
colour (a local "strong" accent mixed toward the heading ink keeps chips
and filled labels at 4.5:1 in both themes).
White rgba() fills that vanished in the light theme (season empty panel,
catalog refinement chips and menu divider, Xtream archive banner and
disabled paginator icons, shell download-activity track, search field)
now use the widget surface, on-surface mixes or the search tokens.
Reads of custom properties that nothing declares are fixed: the release
notes error, the search-layout empty state and the collection reload dim
now use declared tokens; unset hooks are replaced by their fallback value.
New guard `pnpm run styles:theme-references:validate` (CI) rejects
undeclared var() reads and prefers-color-scheme outside the settings
resolver. Electron E2E os-color-scheme.e2e.ts flips the OS scheme under
each explicit app theme and checks colours, contrast and screenshots.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(e2e): retry the rejected-drop comparison when the card dismisses mid-read
The rejection hides itself after 1.8s, and the OS-scheme comparison reads
the overlay twice with an emulateMedia call between. A slow runner could
lose the card between the reads; the comparison now drops again until both
reads see it. A colour that follows the OS still fails every attempt.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(e2e): measure welcome text contrast from rendered pixels
The feature and source cards paint gradients, which a backgroundColor
walk ignores, so card titles, descriptions and chips could pass while
falling short on the actual card. Every text on the three surfaces is now
measured against the pixels under it, as the filled button labels were.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(tools): scan only runtime sources in the theme-references guard
The guard read every tracked file under apps/ and libs/ except specs, so
an E2E, mock-server or test-helper declaration could satisfy a runtime
var() read that nothing in the app declares (dashboard-rail-focus.e2e.ts
sets --cover-rail-width), and a test-only read could fail the check. It
now skips spec/test/e2e files, test helpers and stubs, testing projects,
the E2E and mock-server apps and the marketing website.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(xtream): set live paginator tokens through mat.paginator-overrides
The live layout hand-declared --mat-paginator-* tokens, which the UI
guidelines route through the overrides mixin so a mistyped name fails the
build instead of silently doing nothing. Same values, same output.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(tools): skip every test-only naming convention in the theme guard
The runtime scan still read .stub, .harness, .fixtures, .spec-stubs,
.spec-helpers, .spec-fixtures, test-setup and test-double sources, and
test-stubs/ or *fixtures/ directories, so a declaration in one could mask
an undeclared runtime read. A file is now test-only when a dot segment
after its name marks it (spec, stub, fixture, harness, mock, spec-*,
test-*, *-fixtures), its stem is a test bootstrap, or it sits in a test
directory. Runtime names such as xtream-connection-test.service.ts stay
in the scan; no runtime source imports an excluded file.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): keep welcome card text legible while hovered
At the 16%/10% hover tint the dark theme's body text on a feature card
measured 4.45:1. The hover fill steps up to 10%/6% instead (4.78:1 in
dark, 6.6:1 in light); lift, border and shadow carry the rest. The E2E
now measures feature and source card text while hovered, in both themes;
with the old tint it fails at 4.45.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(playback): size diagnostic cards to the player and wrap their labels
The recovery cards ellipsized every translated action wider than their
124px label column (de, ru, hu, pt, pl, by and others), and the banner
took its padding from the window rather than the player. In a short inline
player just wider than the 760px breakpoint that squeezed the content
until it overflowed, and align-items: center pushed its top above the
scroll origin.
The panel host is now a size container over the player: padding uses
container units, a compact layout applies to players up to 560px tall,
auto margins centre the content without clipping its top, labels wrap,
and the cards grow to 240px.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(playback): keep short diagnostic players at full content width
Measure the layout E2E with the whole banner translated, not only the
card labels: the badge, headline, description, hints and utilities set the
banner's height too. With them, a by banner in a 920px-wide, 404px-tall
player had 45px to spare, because 18cqi side padding left a 588px column.
Short players now grow their side padding from 16px at the 760px
breakpoint, which keeps the 720px column and about 90px spare.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(workspace): remove standalone-layout branches no route can reach
Every routed view sits under the /workspace route (the other top-level
paths are redirects), so isWorkspaceLayoutRoute() was true for every
caller. Global search became a route on 2026-01-12 and the last route
outside the workspace went with the home component on 2026-03-02;
nothing opens SearchResultsComponent in a MatDialog.
Removed as unreachable:
- isWorkspaceLayoutRoute() and the route's data.layout marker; the
channel list, Xtream live layout and unified collection read the q
query param directly, the clear-view command no longer checks a flag
that was always true, and the M3U EPG guide header action keeps only
its supportsEpg gate.
- The playback sidebar's playlist header (Home button and playlist
switcher, showPlaylistHeader, its stylesheet and the macOS padding
rule). The switcher itself stays: the workspace header uses it.
- Xtream search's dialog mode (MAT_DIALOG_DATA initialQuery, dialogRef,
close buttons) and inline search input; Back is offered whenever the
search is not the global one, as before.
- Stalker search's inline-input flag (Back is always offered, as before).
- SearchLayoutComponent's showSearchInput, showCloseButton, closeClick,
searchTermChange and focusSearchInput, and SearchFormComponent, which
only it rendered.
- SETTINGS.BACK_TO_HOME and PORTALS.SEARCH in all 19 locales, the
zoneless checklist entry for the deleted form, the specs that only
covered these paths, and two Electron E2E assertions that the deleted
form is absent.
Behaviour of every reachable path is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(ui): drop the macOS host class and switcher rule nothing uses
The app root's `macos-platform` host class only fed the removed sidebar
header's padding rule, so no stylesheet reads it any more. The shared
portal-sidebar partial's `.sidebar-header .switcher` rule matched no
element: the only `switcher` class lived in that removed header, which
used `.current-playlist`, not `.sidebar-header`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): one focus ring colour everywhere via the shared mixin
#1866 gave the app a keyboard focus ring token (`--app-focus-ring`, at
least 3:1 on every surface), but 59 component rings still drew their own
colour: the selection blue under several names (`--app-selection-color`,
`--mat-sys-primary`, `--app-selection-border`, the EPG's `$accent-blue`,
`--embedded-mpv-accent`, `--apd-accent`, the hero's `--accent-color`),
the heading colour, or the overlay's literal text colour. The selection
blue falls to 2.6:1 on the stronger selection tint.
- Every one now includes `focus-ring.focus-ring-declarations`, keeping its
offset; the projects that newly import `libs/ui/styles` declare
`ui-styles`.
- The mixin reads the token alone: it is declared on `html` in both
themes, and the fallback chain cost bytes in every ring.
- `tools/nx/check-focus-ring-colour.mjs` joins `styles:focus-visible:
validate`: an outline in a focus rule must use the token or, over
video, the player's `--pc-*` palette. Three deliberate exceptions are
listed with their reasons, and a stale one is reported. On master it
reports these 59 rings.
- The keyboard-focus E2E asserts each ring's colour equals the token
where it is drawn.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): search fields and shadow rings take the focus colour too; guard reads them
Local review (Greptile P2): the ring-colour guard read only `outline`, so a
focus indicator drawn as a shadow or a border kept any colour. Search
fields showed focus as a `--mat-sys-primary`/selection border with a 12-16%
halo, the EPG guide's keyboard cell as an inset `$accent-blue` shadow, and
the downloads cards tinted their artwork border with the M3 primary.
- Every one now uses `--app-focus-ring` (the halos and tints keep their
strength through `color-mix()`); over video the panel's search border
mixes the overlay's own ring colour.
- The guard reads a focus rule's outline, its unblurred ring or line
shadows and its border colours. Neutral boundaries (separator and
widget-border tokens, transparent, currentColor) and blurred lift
shadows are not indicators. On the previous commit it reports these 21
declarations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(tooling): check every focus colour on its own; count exceptions only where they excuse a ring
Greptile on #1882 (2×P2):
- The guard joined a declaration's colours and accepted the lot when the
token appeared anywhere, so `border-color: var(--app-focus-ring) red`
or a `color-mix()` of the token and red passed. It now splits each
declaration into plain colours, every `color-mix()` argument included,
and checks each one: the token, the player palette or a neutral
boundary. An outline or shadow ring without a colour is drawn in the
text colour, which only a border may keep.
- An exception counted as used when its value appeared in any
declaration (the diagnostic's amber is also a text colour), so a stale
one was never reported. It now counts only where it excuses an
off-token focus indicator.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(settings): the row a search result reveals takes the app focus ring
Codex on #1882 (P2): choosing a settings search result with Enter focuses
its row by script, and the keypress keeps `:focus-visible`, so the row's
60%-transparent selection outline was a real keyboard focus ring under
3:1, not the passive marker its guard exception described. The row now
includes the shared mixin (keeping its 12px radius), the exception is
gone, and the keyboard-focus E2E reveals a row with Enter and asserts the
app ring on it; with the old rule it fails on the 60% colour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(tooling): accept focus colour tokens by exact name
Greptile (local, P2): the guard matched `--app-focus-ring` and the neutral
boundary tokens by prefix, so `var(--app-focus-ring-other, red)` or
`var(--app-separator-strong)` passed. It now reads the property a `var()`
names and accepts exactly `--app-focus-ring`, a `--pc-*` palette token or
one of the four neutral boundary tokens; their fallbacks are never drawn,
since the tokens are always declared, so they are not checked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(tooling): check the body of a focus mixin, the shared ring included
Greptile (local, P2): a mixin body has no selector and its includes are
not expanded, so the shared `focus-ring-declarations` itself escaped the
colour check; turning it red would change every ring and pass. The
walker now names the mixin a declaration sits in, and the colour guard
treats the body of a mixin whose name mentions focus as a focus rule.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(tooling): read logical border sides in the focus colour guard
Greptile on #1882 (P2): only `border`, `border-color` and the physical
sides were read, so `border-inline-start: 2px solid red` in a focus rule
passed. The guard now reads every colour-carrying border property: the
shorthand and the physical and logical sides, with or without `-color`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(tooling): an exception excuses its own colour, not the declaration
Greptile (local, P2): an exception matched the whole declaration, so in
the player stylesheet `box-shadow: 0 0 0 2px #ffffff, 0 0 0 4px red`, or
the white mixed with red, passed. Exceptions now apply to each plain
colour, by exact value, like every other check; an exception counts as
used only where it excuses one of a focus indicator's colours.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(tooling): tell a var() or calc() width from the ring colour
Greptile (local, P2): `outline: var(--ring-width) solid var(--app-focus-
ring)` failed the guard, since any non-length token counted as a colour.
Math functions now count as lengths, and a shorthand with one colour slot
takes its literal colour, else an accepted token (the other `var()`s are
widths), else reports every candidate it cannot prove; `border-color`
still checks a colour per side.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(tooling): read a shadow ring whose spread is computed
Codex (local, P2): with `box-shadow: 0 0 0 calc(1px + 1px) red` or a
`var()` spread, only three literal zeros were left as lengths, so the
shadow looked flat and was skipped. A shadow is now skipped only when it
is provably not a ring: a literal non-zero blur in the third place, or
every length a literal zero with no `var()` that could be a spread. The
test with a variable-width token ring now asserts it is read, not skipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(tooling): read shadow lengths by type, with the colour first or no blur
Greptile (local, P2): the guard took a shadow's first three tokens as its
lengths, so `0 2px` (a line in the text colour, no blur) was skipped as
blurred and a colour-first lift shadow was read as a ring. Lengths are
now read by type: a colour sits before or after them, never between, and
a missing blur is zero. A `var()` counts as a possible length, so a
shadow is skipped only when the first three possible lengths prove a
blur, or every length and the first four possible ones are zero.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(tooling): a negated focus condition does not make a focus rule
Greptile (local, P2): the colour guard read `:focus-visible` inside
`:not()` as a focus rule, so a hover style such as
`.button:hover:not(:focus-visible) { border-color: red; }` failed. The
selector is now tested without its `:not()` arguments; a focus condition
elsewhere, `:has()` included, still makes a focus rule.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(tooling): read a width in any CSS length unit
Greptile (local, P2): only px, em and rem were lengths, so `outline: 1pt
solid var(--app-focus-ring)` took `1pt` for the colour and failed. Every
CSS length unit (absolute, font-relative, viewport and container) now
counts as a length.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): the open-in-playlist chip rings in the app focus colour
Codex on #1882 (P2): the chip's inline component styles drew its focus
ring in `--app-selection-color`, out of the colour guard's reach (it reads
stylesheets). It was the only inline-style focus ring in the repository;
it now uses `--app-focus-ring` like every other ring.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(ui): move the open-in-playlist chip styles into a stylesheet
Codex on #1882 (P2) suggested this over scanning TS inline styles: the
chip was the only component with a focus ring in inline `styles`, which
the colour guard does not read. Its styles now live in
`open-in-playlist-chip.component.scss` unchanged, the ring through the
shared `focus-ring-declarations` mixin, so the guard covers it (a drifted
colour there is reported).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>