On Windows with a light OS theme, scrollbars rendered light even when the
app was switched to dark. Two combined causes:
- The page never declared `color-scheme`, so Chromium colored native
scrollbars from the OS preference. Declare `color-scheme: light` on html
and flip it to `dark` via `html:has(> body.dark-theme)` plus the
`.dark-theme` block itself.
- Scrollbar styling referenced `--mat-sys-*` tokens, which are never
emitted by the current Material theme setup (mat.define-theme +
all-component-themes does not produce system tokens). Those
`scrollbar-color` declarations computed to `auto`, falling back to the
native (light) scrollbar. Switch scrollbar styling to the `--app-muted-color`
design token (defined for both themes), replace hardcoded white
`rgba(255,255,255,.08)` thumbs, and add an explicit `scrollbar-color`
where only `scrollbar-width: thin` was set.
Verified live in Electron via CDP in both themes: scrollbar-color resolves
and scrollbars render dark in dark theme regardless of the OS setting.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(dashboard): TMDB trending rail and hero enrichment (backdrop, badges, S/E)
Two dashboard additions, both async after first paint so the page renders
exactly as fast as before:
Trending rail ("Trending this week", dashboardRails.tmdbTrending toggle,
default on, rendered only when TMDB is opted in AND the Electron DB
worker is available):
- TmdbTrendingService fetches /trending/{movie,tv}/week (one request
each, cached one day per language in tmdb_metadata under
trending:week), merges by popularity; exposed via the enrichment
facade (getTrendingWeek)
- DashboardTrendingService matches the titles against imported Xtream
playlists with ONE batched DB_MATCH_TITLES request, applying the same
two-tier + year-compatibility rule as actor pages; matched cards show
the playlist name and navigate straight to the detail view, unmatched
cards open the global search prefilled (?q=)
- The load fires only after the dashboard's own recent/favorites data
is in (never competes for the worker at startup) and once per session
- DashboardRailCard gained optional queryParams for the search links
Hero enrichment:
- DashboardHeroTmdbService patches the hero with a TMDB backdrop (only
when the item has none), a rating badge and up to two genre chips —
via the enrichment facade, so previously opened items resolve from
the SQLite cache without network; memoized per title per session,
staleness-guarded against hero changes in flight
- Series heroes show the tracked "S{n}·E{n}" badge from the playback
position; the watch-progress bar no longer applies to live heroes
Settings: new dashboardRails.tmdbTrending toggle in Settings > Dashboard.
i18n: 3 new keys translated into all 17 locales via tools/i18n patches.
Tests: dashboard-trending.service.spec.ts (gating, matching, year guard,
single-flight); settings fixtures updated. Docs updated
(tmdb-metadata-enrichment.md Dashboard Integration section, CLAUDE.md).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dashboard): resolve hero TMDB extras for Stalker embedded-series items
Stalker vclub items carry type 'movie' in activity rows but are TV shows
on TMDB, so the hero's movie lookup found no confident match and the
backdrop/badges never appeared — while the detail view (which resolves
via is_series) showed them. When a movie-typed hero item has no movie
match, retry the lookup as TV: the detail view has usually already
cached that resolution, and misses are negative-cached.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(tmdb): cross-portal "Similar" rail — Stalker gets it, Xtream gains other-portal matches
The Similar rail only existed on Xtream because it matched against the
locally loaded catalog; Stalker catalogs are server-paginated, so its
detail views had no rail despite tmdb_recommendations being cached.
New CrossPortalSimilarService (libs/services) matches recommendations
against ALL imported Xtream playlists with one batched DB_MATCH_TITLES
worker request — the same two-tier normalized-title + year-compatibility
rule as actor pages and the trending rail. Electron-only; resolves to []
in the PWA.
- Stalker: the shared VodDetailsComponent (movies; covers catalog and
inline detail hosts) and stalker-series-view (series) now render a
"Similar" rail from cross-portal matches, each card badged with the
source playlist and navigating into that portal's detail view.
- Xtream: vod/serial detail rails keep instant local-catalog matches and
append cross-portal matches (current playlist excluded, deduplicated
against local hits by normalized exact title), also playlist-badged.
- Loads async after the detail view renders, staleness-guarded; the
section only appears when there is something to show.
Tests: cross-portal-similar.service.spec.ts (PWA gate, navigation
targets, playlist exclusion, type/year guards). Docs updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(tmdb): drop TestBed from cross-portal similar spec
The services Jest target has no @angular/core/testing (same CI failure
as the cache spec earlier) — construct the service via Injector.create +
runInInjectionContext instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dashboard): address PR review — reactive opt-out gates, retry after empty trending load
- Trending rail and hero TMDB extras now vanish immediately when the
TMDB opt-in is switched off mid-session: the render computeds read the
settings signal through isAvailable/isEnabled instead of trusting data
loaded earlier (Codex P2 ×2).
- loadedOnce latches only after a successful non-empty load, so a
transient TMDB outage on first visit no longer suppresses the rail for
the whole session — the next dashboard visit retries (greptile P2).
- Unified the duplicated heroTmdbExtras() read in the hero computed
(greptile P2).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(ui): slim ContentHero to non-scrolling hero block
The page scroll container moves out of the hero into the upcoming
PortalDetailShellComponent; the hero no longer hosts a default content
slot for player/seasons/extras.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(ui): add PortalDetailShellComponent with browse/watch states
Two-state detail layout shell: owns page scroll, hero collapse animation
(~300ms, reduced-motion aware), Escape-to-close-player handling, and an
About block that re-stamps host-provided *detailTags/*detailMeta
templates in watch state. The [detail-player] slot is never wrapped in a
shell conditional so the host-owned player subtree survives state
changes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(playback): restyle inline player header as now-playing bar
Adds a back button (emits the existing closed output — in watch state
back returns to browse, not route navigation) and replaces the
hardcoded header strings with i18n keys (PORTALS.NOW_PLAYING,
CLOSE_PLAYER, BACK_TO_BROWSE).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(xtream): migrate VOD and serial detail templates to detail shell
Hero chips/meta/actions become *detailTags/*detailMeta/*detailActions
templates; the inline player moves to the full-width [detail-player]
slot; trailer and similar rail move to [detail-extras]. Escape and the
now-playing back button close the inline player back to browse.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(stalker): migrate shared VodDetails and series view to detail shell
The shared VodDetailsComponent (Stalker VOD, collections, search) and
StalkerSeriesViewComponent move their hero content into
*detailTags/*detailMeta/*detailActions templates and host the inline
player in the full-width [detail-player] slot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(ui): season tabs replace two-level seasons navigation
SeasonTabsComponent renders a pill row (dropdown beyond 6 seasons), an
optional season description and a back-to-playing chip. The season
container auto-selects a season (inline-playing episode's season →
most recent in-progress → first), emits seasonSelected for
auto-selections so lazy-load/enrichment hooks keep firing, highlights
the inline-playing episode, and loses the seasons grid + 'Back to
seasons' button. Download/progress helpers move to pure utils; season
strings are now translatable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(portals): season descriptions under the season tabs
Xtream reads season overviews from get_series_info; Stalker exposes the
TMDB season overview through TmdbEnrichmentService.getSeason (same
cache rows as the episode enrichment) and keys it per tmdbId so views
reused across detail navigations cannot leak descriptions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: document two-state detail layout and season tabs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): app-prefix detail template directive selectors, build fixes
detailTags/detailMeta/detailActions → appDetailTags/appDetailMeta/
appDetailActions per @angular-eslint/directive-selector; type the
Escape host listener as Event; drop a redundant ?? in season tabs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(i18n): translate new detail-page keys across all 17 locales
13 PORTALS.* keys (now-playing bar, About block, season tabs, episode
empty states) translated via the i18n-fill workflow.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): keep now-playing bar actions inside the viewport
The nowrap title made the header grid track grow to min-content and
pushed Copy/Close out of the page; min-width: 0 on the header/title row
lets the title truncate instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(ui): expandable season description and episode info dialog
Season descriptions get the same show more/less toggle as the hero
(new shared ExpandableTextComponent); episode cards and list rows get
an info button that opens a dialog with the full plot, duration, air
date and a Play action — card heights stay fixed and click-to-play
semantics stay intact. Also: drop the per-frame backdrop blur on the
player card and make the watch-enter scroll instant so the hero morph
stops competing for frame budget.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(portals): re-run TMDB season enrichment when the show match arrives
With season tabs the first seasonSelected fires as soon as seasons
load — usually before the async show-level TMDB enrichment has written
tmdb_id — so the season fetch silently no-oped and was never retried,
leaving episode stills/plots and the season description empty. Both
detail hosts now key the fetch on (tmdb_id, selected season) in an
effect, so it runs whichever arrives last. Also adds breathing room
above the About divider in watch state.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): move episode info button into the card body, compact overlay actions
Three overlay buttons crowded the thumbnail on hover and the new info
button had no styling at all. Info belongs with the content text: it
now sits as a quiet ghost button at the end of the title row (revealed
on card hover, MDC touch target clamped so it cannot swallow the
play-on-click card area). Download + watched-toggle stay on the
artwork, shrunk to 28px with a subtle edge border. List view already
used the shared action style and is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): single watched indicator-toggle on episode cards
Watched state was signalled three times on one card: the green badge,
the hover toggle in the top-right cluster, and a checkmark appended to
the title. Now one control does both jobs — a toggle at the top-left
of the artwork that looks like the old green badge when watched
(always visible) and appears as a ghost circle on hover when not.
The right cluster keeps only the download action; the title checkmark
is gone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(playback): back arrow in watch mode navigates straight to the list
Leaving the theater took two clicks: back to browse, then back to the
list. Watch state already shows everything browse offers (episodes,
About, extras), so the bar's back arrow is now route-level back
(new backClicked output wired to each host's goBack), while Close
player and Escape keep exiting to browse without navigating. Replaces
PORTALS.BACK_TO_BROWSE with a generic top-level BACK key (all locales).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(ui): compact episode stills in the list view
When a season has genuinely distinct per-episode art (TMDB stills or
provider images), list rows show a 96x54 thumbnail with the episode
number riding on it instead of the number square. When every episode
repeats the same image (providers often send the series poster), the
plain number square stays — a column of identical posters is worse
than none.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(ui): compact list-thumbnail spec under the max-lines limit
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): address Greptile review — real fallback asset, untracked overflow measure
default-episode.png never existed (pre-existing in the grid card, copied
into the list thumbs) — both onerror handlers now fall back to the real
default-poster.png. The expandable-text and content-hero measuring
effects read the expanded signal via untracked so toggling show
more/less no longer rebuilds their ResizeObservers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(tmdb): opt-in TMDB metadata enrichment for Xtream and Stalker portals
Adds an opt-in TMDB integration (Settings > Metadata) that enriches
detail views with a field-level merge — the provider stays authoritative
for stream data, TMDB fills editorial fields when the match is confident.
Enrichment:
- Movie/series details: plot, cast (avatar chips), director, genres,
rating, poster/backdrop, official YouTube trailers
- Confidence-gated matching: provider tmdb_id trusted; otherwise
normalized-title search with year gate (±1; series accept earlier
premieres), season-suffix stripping, Cyrillic search-language override,
and language-prefix fallback variants
- Lazy season/episode enrichment: real episode names, overviews, stills
- "Similar" rail (Xtream): TMDB recommendations matched to the catalog
- Actor pages per portal with full filmography, availability filter and
an Electron-only "All portals" scope backed by a batched DB_MATCH_TITLES
worker op over the trigram FTS index
Infrastructure:
- SQLite cache table tmdb_metadata (details, search verdicts, seasons,
persons; per-language, TTL-guarded), in-memory fallback for the PWA
- Settings: enable toggle, own-API-key override with a live "check key"
button; TMDB attribution in Settings and About
- Embedded key stays an empty placeholder; CI injects TMDB_API_KEY via
tools/tmdb/inject-tmdb-key.mjs when the secret is configured
- normalizeTitle shared between renderer and DB worker
- CSP: allow YouTube embeds (frame-src was 'none'; trailers never worked)
Fixes and refactors along the way:
- fix(stalker): Advanced Search sent bare get_ordered_list requests and
skipped the auth handshake when isFullStalkerPortal was missing on the
active-playlist meta — full portals answered "Authorization failed."
and search looked empty; now mirrors the catalog request shape and
routes through makeAuthenticatedRequest with URL-based detection
- fix(stalker): TMDB fields survive info re-normalization; detail views
prefer the store copy patched by async enrichment over stale snapshots
- refactor(xtream): split oversized vod/serial detail components into
component-scoped playback services; detail routes re-initialize on
route param changes (router reuses them for detail-to-detail nav)
- i18n: all new keys translated across the 18 locales
Docs: docs/architecture/tmdb-metadata-enrichment.md + CLAUDE.md updates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(tmdb): provide route params observable to inline collection details, linearize regexes
The global-collection inline detail host builds a fake ActivatedRoute for
VodDetailsRouteComponent/SerialDetailsComponent with only snapshot.params.
Since the detail components now read route.params via toSignal() (detail->
detail re-init), the missing observable crashed component construction and
the content hero never rendered — broke dashboard-activation, favorites and
recent Electron E2E on all platforms. Provide the params observable
alongside the snapshot and assert it in the component spec.
Also resolves both CodeQL js/polynomial-redos alerts: bracket-stripping in
normalizeTitle now excludes opening delimiters inside the classes, and
youtubeEmbedUrl extracts watch?v= ids with a linear two-pass match instead
of "watch\?.*v=". Combining-diacritics range rewritten as explicit \u
escapes (greptile note).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(tmdb): surface TMDB-only VOD score in the rating badge, drop youtube.com from CSP
Review follow-ups on PR #1123: the Xtream VOD detail badge renders
rating_imdb, but the merge wrote the TMDB score only into `rating`, so a
TMDB-only score was never displayed (Codex P2) — fill rating_imdb when the
provider left it empty, mirroring the Stalker merge. All trailer iframes
are normalized to youtube-nocookie.com, so the extra youtube.com frame-src
allowance was dead surface (greptile) — removed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(tmdb): resolve confirmed review findings — matching correctness, race guards, cache schema
Fixes the confirmed findings from the PR #1123 code review:
- Stalker search: setSelectedContentType now runs BEFORE setSelectedItem,
so the TMDB enrichment gate in the selection hook no longer sees the
content type of the previously open tab (wrong/no enrichment after
ITV -> search -> movie).
- Title normalization is now two-tier (normalizeTitleKeys): the exact
normalized form keeps a trailing year, the base form strips it and
remembers the tag. Year stripping is anchored to the end of the title
("2001: A Space Odyssey" keeps its year) and language-prefix stripping
is UPPERCASE-only ("It: Chapter Two" is no longer amputated).
- All catalog matching (similar rail, actor pages, DB worker
DB_MATCH_TITLES) compares exact forms first and only accepts
year-stripped matches when the stripped tag is year-compatible (+-1)
with the TMDB year — "Blade Runner" (1982) can no longer claim a
catalog "Blade Runner 2049". CatalogTitleMatch carries the stripped
trailingYear so the renderer can apply the guard to worker matches.
- mergedBackdrops tolerates a plain-string backdrop_path; enrichment
merge+patch blocks are wrapped in try/catch so a malformed provider
payload can no longer become an unhandled rejection.
- loadGlobalMatches (both actor routes) guards against actor->actor
navigation races — a slow match for the previous person no longer
overwrites the current one's results.
- tmdb_metadata media_type CHECK widened to ('movie','tv','person') and
person rows now use the honest 'person' type (TmdbCacheMediaType).
Pre-release dev DBs with the narrow CHECK are rebuilt in place — the
table is a pure cache, so the migration is a self-healing
drop-and-recreate keyed off sqlite_master.
Docs updated (tmdb-metadata-enrichment.md, CLAUDE.md). New regression
coverage: title-normalization.util.spec.ts, two-tier cases in
tmdb-similar.util.spec.ts and title-match.operations.spec.ts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): harden embedded mpv session handling and support detection
- guard the session controller against late startup rejections clobbering
a newer session during fast channel zapping
- exclude the refresh timestamp from the session-update dedup key so idle
sessions stop re-emitting IPC updates every 500 ms
- macOS: reconcile async loadfile replies by request id so a rejected
seek/aid/speed on a live stream no longer flips the session to error
- append --ozone-platform=x11 on Linux in main.ts so direct binary and
AppImage launches match the packaged .desktop launcher behavior
- return a sandbox-specific unsupported reason in Flatpak/Snap instead of
asking the user to install mpv inside the sandbox
- update the stale "macOS only" embedded MPV claim in CLAUDE.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(playback): populate Linux audio tracks and fix ARM Linux packaging
- Linux poller now reads track-list/count each tick and walks the scalar
track-list/N/* sub-properties when the count changes, so the audio-track
menu is no longer empty; selection reconciles from the aid property
- afterPack replaces the x64 embedded_mpv.node with an
embedded-mpv-unavailable.txt marker in arm64/armv7l Linux packages, and
package-layout verification rejects foreign-architecture addons while
requiring the marker
- extend native source invariants for the non-fatal async-reply rule
(macOS) and the Linux track-list polling contract
- document the Linux track-list mechanics and ARM packaging behavior in
docs/architecture/embedded-mpv-native.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(playback): embedded mpv player UX polish and full localization
- click on the video toggles pause (same action as Space) with a 250 ms
grace period so double-click fullscreen cancels the pending pause; no
DOM overlay is drawn — the dock transport icon is the feedback
- timeline scrubbing previews the drag position locally and commits a
single seek on release instead of one IPC seek per drag pixel
- translate all player UI strings (controls, tooltips, aria-labels,
status and recording messages) via new EMBEDDED_MPV.PLAYER.* keys,
synced across en + 17 locales through the i18n-fill workflow
- replace the legacy @Output() EventEmitter with the signal output() API
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): react to language changes and respect ozone platform hint
Address review feedback on #1122:
- add a translationsTick signal (onLangChange/onTranslationChange/
onDefaultLangChange) read by every computed() and template helper that
calls translate.instant(), so labels re-evaluate on a runtime language
switch and when the translation file finishes loading after mount
- suppress the Linux --ozone-platform=x11 fallback when the user set
ELECTRON_OZONE_PLATFORM_HINT, matching the existing respect for an
explicit --ozone-platform switch
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(cleanup): delete nine dead components, orphaned i18n keys and unused deps
Removes verified-dead components (0 class/selector references outside
their own files): EpgListComponent (+ epg-list-item), EpgViewComponent,
LiveEpgPanelComponent, StalkerCollectionChannelsListComponent,
NavigationComponent, FilterSortMenuComponent, video-player
ToolbarComponent, PortalCollectionShellComponent and
LoadingOverlayComponent, together with their barrel exports.
Alive code extracted from the deleted trees:
- LiveEpgPanelSummary -> libs/ui/shared-portals/src/lib/live-epg-panel-summary.ts
- EpgProgramActivationEvent -> libs/ui/epg/src/lib/epg-program-activation-event.ts
- epg-list.utils.ts trimmed to the three timeline-used helpers and moved
to libs/ui/epg/src/lib/epg-program.utils.ts
- epg-item-description/ moved up out of the deleted epg-list/ folder
Also removes 18 i18n keys now unused (from all 18 locales), dead CSS
selectors targeting the deleted elements, and unused dependencies:
lodash (+ @types/lodash), semver, @ngrx/component-store and
@videojs/http-streaming (videojs-quality-selector-hls declares no peer
dependency on it; video.js 8 bundles VHS).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(coverage): move shared-portals to Tier C, fix stale doc references
The Tier A gate failed in CI because deleting the dead epg-view and
live-epg-panel components removed the only specs in libs/ui/shared-portals.
The lib now contains a single type-only interface (LiveEpgPanelSummary),
so there is no runtime code to unit test; reclassify it to Tier C with a
documented reason, matching the gate's own guidance.
Also update remaining doc references to the deleted components in
docs/architecture/stalker-epg.md, iptvnator-ui-guidelines.md and
CLAUDE.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(security): harden Electron IPC against MITM, SSRF, path and injection risks
S1 TLS: validate certs by default on playlist/EPG fetches (opt-out via IPTVNATOR_ALLOW_INSECURE_TLS); new util/secure-https.ts.
S2: write-file IPC restricted to save-dialog-authorized paths.
S3: XTREAM_PROBE_URL guarded by assertRemoteUrlAllowed + maxRedirects:0; new events/url-safety.ts (+19 tests).
S4: EPG titles rendered via interpolation, not [innerHTML].
S5: downloads reveal/play limited to recorded download paths.
S6: Stalker cmd encoded (slash-preserving) to block query injection.
EPG-worker and Stalker fetches reject file://-style/credentialed URLs; LAN/self-hosted targets remain allowed.
* perf(player): lazy-load web video players via @defer
Wrap Video.js/HTML5/ArtPlayer in @defer (on immediate) so video.js, hls.js,
artplayer and mpegts.js split into a deferred chunk loaded on first playback
instead of eagerly on the player route. Embedded MPV (native) stays eager.
Spec uses DeferBlockBehavior.Playthrough.
* fix(player): remove leaked HTML video listeners on destroy
volumechange used a mismatched removeEventListener reference, while
loadedmetadata and timeupdate were never removed at all. Bind all three to
stable handler fields used for both add and remove, and add a teardown
regression test asserting each listener is detached on destroy.
* refactor(dashboard): extract pure navigation helpers from DashboardDataService
Move the 8 stateless link/navigation-state/type-kind helpers into a new
dashboard-navigation.util.ts so the routing logic is independently testable and
the 1260-line god-service shrinks. DashboardDataService keeps the public methods
as thin delegators (facade) so the public API and the single consumer
(workspace-dashboard-rails) are unchanged. First slice of the DashboardDataService
decomposition; verified by the existing service spec (33/33) and the app typecheck.
* fix(review): address PR feedback (IPv6 link-local, write-path cap, @defer placeholder)
- url-safety: broaden IPv6 link-local detection to the full fe80::/10 range
(fe80:: through febf::), not just the fe80:: prefix (+ regression tests).
- playlist.events: cap authorizedWritePaths (evict oldest past 32) so a save
dialog opened without a following write cannot accumulate entries until restart.
- web-player-view: add a @placeholder to each @defer (on immediate) player block
to avoid the one-frame blank/layout-shift before the chunk resolves.
* fix(security): close Electron network and download gaps
* test(downloads): cover cancellation and restart cleanup
* fix(downloads): address Greptile review gaps
* test(security): reproduce remaining Greptile findings
* fix(security): close remaining Greptile findings
* test(downloads): reproduce early database queue stall
* fix(downloads): release queue after setup failures
* test(downloads): reproduce completion queue stall
* fix(downloads): release queue after completion failures
- merge origin/master into PR #964 and keep embedded MPV test on the isolated playback sub-entrypoint
- centralize EPG capability through DataService.supportsEpg and update PWA web-e2e expectations
- split BrowserAccessError copy between Electron and PWA diagnostics
Radio audio-player buttons (skip-prev, play/pause FAB, skip-next, mute,
volume slider) had zero matTooltip or aria-label. The UX audit flagged
this: "Skip-prev / Skip-next — what do they skip? In radio, is the
next 'track' the next station? Next in favourites? Random? Without a
tooltip the buttons trade silently." Same critique applied to mute
and play/pause for screen-reader users.
Add hover tooltips and screen-reader labels on all 5 controls. Skip
prev/next say "Previous station" / "Next station" so the behaviour is
explicit (they walk the active filtered list). Play/Pause flips its
label with state. Mute/unmute flips when isMuted() || volume === 0.
Volume slider gets its own label so the slider thumb is announced
correctly.
i18n: 7 new AUDIO_PLAYER.* keys translated across all 17 non-English
locales by per-locale agents; all 18 locales pass coverage and
placeholder integrity checks.
52/52 ui-playback tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Swap M3 primary palette from violet to azure so checkboxes, radio
buttons, raised CTAs, and active states read as the same blue used by
the rail selection token. Cascading template + SCSS updates align the
remaining hand-rolled surfaces (Add Playlist dialog, VOD play button,
radio player, multi-EPG, empty-state CTAs) with the unified system.
LIVE stays red (broadcast role), cyan stays on EPG "now" indicator,
green stays on completed-download — semantic colors keep their meaning;
only the indiscriminate accent uses get folded into the blue primary.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(playback): handle IPTV proxy URLs without file extensions
IPTV proxy servers (Acexy, Xtream Codes, etc.) use query-string URLs
like `https://proxy.example.com/ace/getstream?infohash=abc` that return
raw MPEG-TS streams without a file extension in the path.
`getExtensionFromUrl()` was returning garbage for these URLs (e.g.
`com/ace/getstream` from splitting on dots across path segments). All
four player backends then misrouted the stream — typically to HLS.js
which failed because the response is raw MPEG-TS, not an HLS manifest.
Root fix: rewrite `getExtensionFromUrl()` to extract the extension from
the last path segment only, returning `undefined` when there is no
dot-separated extension.
Player adjustments: treat `undefined` extension as MPEG-TS (the most
common format for live IPTV proxy streams) across all four backends
(HTML5, ArtPlayer, VideoJs, web-player-view).
* test(m3u-utils): cover URL extension parsing
* fix(playback): preserve query-declared stream extensions
---------
Co-authored-by: 4gray <serega05@gmail.com>
* fix(playback): avoid redundant VideoJS source resets
* fix(playback): clean up cleared VideoJS sources
* refactor(playback): use signal APIs in VideoJS player
closes#608
Audit followup to the support-loop and volume-restart fixes. Two more
effects pulled in transitive signal deps that would have caused the same
class of regression the next time the surrounding helpers grew.
1. Component session-fan-out effect: the body called
scheduleControlsHide(), which reads isPlaying/menus.anyOpen/statusLabel/
controlsVisible. Those became tracked deps of the effect, so opening a
popover, pausing, or hovering re-ran the whole body — re-emitting
timeUpdate. If a parent ever wires timeUpdate back into
playback.startTime as a "resume where I left off" feature, this would
have been the next stream-restart bug. Wrap the side-effect block in
untracked() so the effect only listens to session changes.
2. Controller stalled-tracker effect: tracked the full session signal
even though only status was needed. The session payload updates ~2 Hz
during playback (positionSeconds advances), making the effect re-run
constantly to call a no-op. Add a sessionStatus computed and track
that instead — fires only on real status transitions.
No behavior change for current users; both fixes are preventative.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
Same root cause as the loading-loop fix: the session-creation effect
read this.volume() while building startSession's initialVolume argument,
which made volume a tracked dependency of the effect. Each volume tick
re-ran the effect, the cleanup disposed the active session, and a fresh
one was created — which for VOD/series meant restarting playback from
the beginning.
Read volume via untracked() inside the effect. The value is only needed
once at session creation; subsequent volume changes flow through
controller.applyVolume() and never go near the effect.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
Regression introduced in the bundle 3 refactor. After calling
prepareEmbeddedMpv, the controller wrote the response back into
this.support — but the component's session-creation effect tracks
this.support(), so updating it cleaned up the just-created session and
ran startSession again, which prepared again, set support again, and so
on. Net effect: endless "Loading stream…" spinner because every session
was disposed before MPV could finish loading the file.
The initial loadSupport() in the constructor already populates support
(including capabilities). Drop the redundant set in startSession; leave
a comment explaining why so it does not get re-added.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
The timeline and volume sliders were rendering invisibly because:
- --embedded-mpv-accent resolved to var(--mat-sys-primary) with no
fallback. If the Material 3 theme tokens did not cascade into the
component scope, the variable was empty and both the thumb and the
Firefox progress fill rendered with no color.
- The track contrast was 22% of var(--mat-sys-on-surface) which has the
same scoping fragility on top of being subtle even when it does
resolve.
- The thumb had no border, so even when accent was present it could
blend into similarly-toned popover backgrounds.
- The played portion of the timeline had no fill in Chromium because
::-webkit-slider-runnable-track does not support a native progress
state.
Fixes:
- Add hard fallbacks on every theme-token reference (accent falls back to
#7dd3fc, surface to #0f1620, outline-variant to a translucent white).
- Add --embedded-mpv-track and --embedded-mpv-thumb-ring as theme-
independent CSS variables so the slider stays legible regardless of
the parent theme provider.
- Give the thumb a 2 px white ring border + a stronger drop shadow so it
reads on any background (panel glass, dark backdrop, accent-coloured
area).
- Implement played-progress fill via a linear-gradient on
::-webkit-slider-runnable-track keyed off a CSS variable
--slider-progress, set per-slider from the template based on the
current playback position / volume. Firefox uses native
::-moz-range-progress.
- Tidy disabled state styling so live-stream timelines (no duration)
are dim but still visible.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
The renderer was reading session.id and forwarding it to the addon, but
during the loading window that id is the placeholder
"embedded-mpv-starting" set by createLoadingSession. If the user adjusted
volume, seeked, or toggled audio/subtitle/speed/aspect before the addon's
createSession returned the real id, that placeholder id reached the
addon — and the addon's getSessionOrThrow threw a raw std::runtime_error
which libc++abi terminated the process on.
Two fixes, defense in depth:
1. Renderer (session controller): use the canonical sessionId() signal,
which is null until the addon hands back a real id, as the gate for all
IPC calls. Wrap every IPC call in a guardIpc helper that swallows
addon-side throws so a torn-down session or race won't surface as an
uncaught promise rejection.
2. Native (embedded_mpv.mm): change getSessionOrThrow to take a
Napi::Env and throw Napi::Error::New(env, ...) instead of
std::runtime_error. node-addon-api converts Napi::Error to a JS
exception cleanly; the previous std::runtime_error escaped the C++
frame and aborted the process when the addon was built without
NAPI_CPP_EXCEPTIONS translation. Refactor splits findSession (returns
nullptr) from getSessionOrThrow (env-aware) so call paths that just
probe a session's existence don't pay the throw cost.
The native fix needs an addon rebuild to take effect; the renderer fix
prevents the crash trigger immediately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
Same root cause as the modal-dialog occlusion: control popovers (volume,
audio, subtitle, speed, aspect) extend upward from the controls strip into
the area covered by the MPV NSView. They render in DOM but the native view
paints over them.
Replace the simple boolean overlayActiveProvider with a richer
boundsProvider closure on the session controller. The component drives it
from both the modal overlay state and the popover menu state:
- Modal dialog open (command palette, MatDialog) -> HIDDEN_BOUNDS, MPV
fully off-screen so the dialog has the whole window.
- Popover menu open -> shrink MPV from the bottom by 300 px so the popover
region lives in DOM-receiving space; video keeps playing in the upper
region instead of disappearing entirely.
- Otherwise -> full host bounds.
Bounds-resync effect now tracks menus.anyOpen() in addition to
overlayActive() so opening or closing a popover triggers an immediate
re-sync.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
The component had grown to ~1000 lines with session lifecycle, IPC plumbing,
keyboard shortcuts, popover state, formatters, and view orchestration all
living in one file. Per CLAUDE.md's 350–400 line hard cap, split into:
- embedded-mpv-format.utils.ts (~100): pure helpers (formatTime, track
labels, volume icon/label, persisted-volume access, measureBounds) and
preset constants (SPEED_PRESETS, ASPECT_PRESETS, HIDDEN_BOUNDS).
- embedded-mpv-shortcuts.ts (~90): EmbeddedMpvShortcuts class that owns the
document keydown listener and routes through a handler interface; the
component just provides callbacks.
- embedded-mpv-ui-state.ts (~110): EmbeddedMpvMenuState (single-open
popover state machine with toggle/open/close/closeAll + anyOpen signal)
and EmbeddedMpvFeedback (transient overlay with auto-clearing flash).
- embedded-mpv-session-controller.ts (~395): component-scoped Injectable
that owns support/session/sessionId/stalled/retryToken signals, the
session-update IPC subscription, bounds-sync (resize, scroll, overlay
state), the stalled timer, and all per-session IPC operations
(togglePaused, seekBy, seekTo, applyVolume, setAudioTrack,
setSubtitleTrack, setSpeed, setAspect, retry).
- embedded-mpv-player.component.ts (now ~540, was ~1000): view-only
orchestration — view children, derived computed signals, DOM event
listeners (pointerdown/pointermove/fullscreenchange/dblclick), and three
effects (session start/teardown, overlay-active bounds sync, session
payload → volume/timeUpdate fan-out).
No behavior changes. Build clean, electron-backend tests still pass.
Component is still over the 400-line cap but the bulk of its size is now
the necessarily-coupled-to-view computed signals and constructor effects;
the remaining over-cap delta is structural to a player root.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
Native addon (apps/electron-backend/native/src/embedded_mpv.mm)
- Extend SessionSnapshot with subtitleTracks, selectedSubtitleTrackId,
playbackSpeed, aspectOverride.
- Refactor track parsing into a shared updateTracksFromNode helper that
filters by mpv "type" so audio and subtitle tracks share the code path.
- Observe sid, speed, video-aspect-override; clear sub state on
MPV_EVENT_START_FILE.
- Export setSubtitleTrack (handles trackId === -1 as "no" to disable),
setSpeed (clamped to 0.25–4.0), setAspect (passthrough string for
video-aspect-override).
- Snapshot output now includes the new fields.
Service (embedded-mpv-native.service.ts) + IPC + preload
- Mirror methods on EmbeddedMpvNativeService with capability detection: each
method throws a descriptive error if the loaded addon doesn't expose the
underlying native function (i.e. user is running an older build).
- New IPC channels EMBEDDED_MPV_SET_SUBTITLE_TRACK, _SET_SPEED, _SET_ASPECT
registered in events file and exposed via preload.
- Extend EmbeddedMpvSupport with a capabilities probe so the renderer can
hide controls for features the current addon build doesn't ship.
Renderer (embedded-mpv-player.component.{ts,html})
- Three new popovers anchored above their buttons (subtitle / speed /
aspect), gated by capabilities() and (for subtitles) by track count.
- Subtitle popover includes an Off entry; speed/aspect use fixed presets.
- Error state now surfaces the same overlay as the stalled state, with a
Retry button that bumps the existing retryNonce signal — covers #8 from
the audit.
- All session-payload defaults (loading stub, error stub, refresh fallback,
dispose payload, native createSession default) updated for the new
required fields.
NOTE: Existing addon binaries do not expose the new methods. Until the
addon is rebuilt (pnpm run serve:backend:embedded-mpv or the release
build script), capabilities will report subtitles/playbackSpeed/
aspectOverride as false and the new buttons will simply not appear.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
- Volume button now toggles mute on click; the slider opens via hover/focus
in an absolute popover anchored above the button (with hover-bridge so the
cursor can reach it without crossing a dead zone). Wheel over the volume
area adjusts volume.
- Audio track menu likewise becomes a click-toggle popover anchored to its
button instead of a panel takeover; the back-arrow / mode-panel
scaffolding is gone.
- Slider gets explicit thumb/track styling for both WebKit and Firefox so
it reads as part of the design system instead of a raw native control;
aria-valuetext on the timeline announces formatted time.
- Stalled overlay: if status remains "loading" for 30 s, surface a centered
warning with a Retry button that disposes the session and recreates it
via a retryNonce signal the playback effect tracks.
- Keypress feedback overlay: ←/→ seek, ↑/↓ volume, M mute now flash a
centered pill with the icon + delta so the action is visible (especially
important in fullscreen where the controls are hidden).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
Add the standard set of player shortcuts to match the audio/HTML video
players already in the app:
- Space, K — play/pause
- F — toggle fullscreen
- Left/Right — seek -5 s / +5 s
- Up/Down — volume +/- 5 %
- M — toggle mute (restores prior volume)
Shortcuts are ignored while focus is in an input/select/textarea or while
any MatDialog (e.g. command palette) is open, so the palette's filter and
dialog inputs keep their keys. Volume changes route through a shared
applyVolume helper so keyboard/wheel/slider all persist to localStorage
and the active session.
Also bind dblclick on the player root to toggle fullscreen, with a guard
so double-clicks on buttons/sliders don't trigger it. Note: the native
MPV NSView intercepts mouse events over the video itself, so dblclick
only fires on DOM-receiving regions (currently the controls strip).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
The command palette is a MatDialog with a transparent backdrop, and the
prior MutationObserver-only detector did not reliably catch it across
overlay-container instantiation timing — the player kept rendering at full
bounds while the palette was open and continued occluding it. Subscribe
directly to MatDialog.afterOpened / afterAllClosed for the authoritative
"any modal dialog open" signal, and keep the OverlayContainer mutation
observer as a fallback for non-dialog CDK overlays. Either signal flips
overlayActive, hiding the embedded MPV view as before.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
The native MPV NSView sits above the WebContents and is sized to the
viewport rect, so any DOM region it covers never receives pointer events.
Bundle 1 made the viewport fill the whole player, leaving no DOM strip to
catch pointermove — once the controls faded out there was no way to bring
them back via hover. Reapply the bottom inset on the viewport, scoped to
the slimmer 64 px panel, so the bottom strip is DOM-only and hover wakes
the controls. Swap the gradient for the glass surface treatment now that
the panel no longer needs to fade into video.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0