Commit Graph
390 Commits
Author SHA1 Message Date
weeco e751e82c07 feat(xtream): fall back to uploaded XMLTV when provider has no EPG
Live TV channels in Xtream playlists go blank whenever the provider's
get_short_epg returns nothing, even when the user has working XMLTV
URLs in Settings. The XMLTV pipeline already populates epg_programs
but only the M3U module was reading it.

Wire those uploads up as a fallback for Xtream too: when the provider
returns nothing for a channel with an epg_channel_id, look it up in
the local table. A settings toggle flips the priority for users whose
curated XMLTV is better than the provider's auto guide.

EpgQueueService.enqueue() became async to batch the XMLTV lookup once
per viewport change. To keep the queue consistent under fast scroll,
all shared-state mutations now happen behind a generation counter so
only the latest call commits. Per-method bridge gating in the new
XtreamXmltvFallbackService keeps each path working when the preload
exposes only one of the two endpoints.
2026-05-07 08:52:59 -07:00
4gray ed6302c059 feat(m3u): add channel list sorting controls
Entire-Checkpoint: f957cd9849e0
2026-05-06 00:14:52 +02:00
4grayandClaude Opus 4.7 42f718568e fix(embedded-mpv): tighten effect signal-tracking to prevent latent re-run bugs
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
2026-05-05 16:31:25 +02:00
4grayandClaude Opus 4.7 c12b660594 fix(embedded-mpv): do not restart the stream on every volume change
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
2026-05-05 10:55:09 +02:00
4grayandClaude Opus 4.7 6ab62cfd8d fix(embedded-mpv): stop re-setting support inside startSession (infinite reload loop)
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
2026-05-05 10:41:59 +02:00
4grayandClaude Opus 4.7 b5dbc33e62 fix(embedded-mpv): make slider track and thumb visible
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
2026-05-05 10:33:18 +02:00
4grayandClaude Opus 4.7 9424dedafc fix(embedded-mpv): stop crashing on IPC for not-yet-created sessions
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
2026-05-05 10:29:23 +02:00
4grayandClaude Opus 4.7 126025aa41 fix(embedded-mpv): expose popover area by shrinking MPV view, not just hiding
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
2026-05-05 10:16:33 +02:00
4grayandClaude Opus 4.7 0249a3a9f7 refactor(embedded-mpv): split player component into focused units
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
2026-05-05 10:09:31 +02:00
4grayandClaude Opus 4.7 8e3f559033 feat(embedded-mpv): subtitles, playback speed, aspect override, retry on error
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
2026-05-05 09:53:32 +02:00
4grayandClaude Opus 4.7 dea1d40c3d feat(embedded-mpv): polish bundle — popovers, mute, retry, feedback
- 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
2026-05-05 09:39:19 +02:00
4grayandClaude Opus 4.7 e2a9af37db feat(embedded-mpv): keyboard shortcuts and double-click fullscreen
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
2026-05-05 09:26:26 +02:00
4grayandClaude Opus 4.7 32779f99a4 fix(embedded-mpv): detect MatDialog overlays, not just CDK backdrops
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
2026-05-05 09:23:32 +02:00
4grayandClaude Opus 4.7 2b0287c2f1 fix(embedded-mpv): reserve controls strip so hover reveal works
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
2026-05-05 09:14:35 +02:00
4grayandClaude Opus 4.7 2b57f3a35d style(embedded-mpv): align chrome with app theme and fix viewport crop
- Switch palette and surfaces to Material 3 tokens (--mat-sys-*) so the
  player respects the app theme instead of a hardcoded cyan/sky scheme.
- Stop reserving 96 px below the viewport for the control panel; the panel
  now floats over the bottom with a gradient fade so the video fills the
  frame (especially in fullscreen).
- Drop control panel height from 96 to 64 px (88 px on narrow widths).
- Replace the corner "Loading stream in MPV..." pill with a centered
  spinner + concise label during load; move the transient status toast
  to the top-right and add role="status" aria-live.
- Add a red badge with pulsing dot for live streams in place of the plain
  "LIVE" text label.
- Apply font-variant-numeric: tabular-nums to time/volume readouts so
  digits stop jittering as playback advances.
- Slightly loosen transport/actions gaps for a less cramped rhythm and
  use ease-out easing on control fade.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
2026-05-05 09:10:58 +02:00
4grayandClaude Opus 4.7 1c499856ab fix: hide embedded MPV view while modal overlays are open
The embedded MPV player is a native NSView that sits above the WebContents
layer, so DOM dialogs (command palette, MatDialog, etc.) always paint behind
it regardless of z-index. Track CDK overlay backdrops via OverlayContainer +
MutationObserver and send off-screen bounds to the native view while any
backdrop is present, restoring real bounds on close. Playback continues
uninterrupted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
2026-05-05 08:57:59 +02:00
4grayandClaude Opus 4.7 e192cba776 feat(player): add VLC reuse-instance setting (#893)
VLC was unconditionally spawned per click — VLC's own single-instance
preference fails because the per-launch RC args defeat its D-Bus
forwarder. Mirror the existing MPV reuse pattern so users can opt in to
driving one tracked VLC via its RC interface (clear + add) instead of
opening a new window every stream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
2026-05-05 00:55:04 +02:00
4gray 6cbf2a1cfb feat(m3u): add context menus to recent channels
Entire-Checkpoint: f957cd9849e0
2026-05-05 00:27:15 +02:00
4grayandClaude Opus 4.7 008bafbaae fix(recently-viewed): persist clear-all for M3U and Stalker playlists
Batch removal fanned out per-item read-modify-write calls against the
playlist's recentlyViewed JSON column via Promise.all, racing each other so
only the last write persisted. Group non-Xtream items by playlistId and use
a new removeFromPlaylistRecentlyViewedBatch helper that filters all
identities in a single read-filter-write per playlist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
2026-05-04 23:59:58 +02:00
4gray 2efdfea0c1 fix: preserve file paths for local playlist refresh
Refs https://github.com/4gray/iptvnator/issues/891

Entire-Checkpoint: f957cd9849e0
2026-05-04 10:06:39 +02:00
4gray 568c03fe0e fix: reset catalog pagination on search
Entire-Checkpoint: f957cd9849e0
2026-05-04 09:12:41 +02:00
4gray d0f590d8fe refactor: improve Xtream refresh functionality with localization support
- Introduced new localization keys for Xtream refresh actions in multiple languages (ar, ary, by, de, el, en, es, fr, it, ja, ko, nl, pl, pt, ru, tr, zh, zhtw).
- Implemented refresh preparation state management in PlaylistRefreshActionService.
- Enhanced WorkspaceShellXtreamImportService to handle refresh preparation states and display appropriate labels.
- Updated tests to cover new refresh preparation scenarios and ensure correct overlay display during refresh operations.

Entire-Checkpoint: f957cd9849e0
2026-05-04 00:51:29 +02:00
4gray e36c953865 feat(sidebar): implement shared collapse state for live-TV sidebar across components
Entire-Checkpoint: f957cd9849e0
2026-05-03 23:26:52 +02:00
4gray 15b6ba985a feat(live-tv): collapsible sidebar in live tv views
Entire-Checkpoint: f957cd9849e0
2026-05-02 23:53:05 +02:00
4grayandClaude Opus 4.7 cf30c129f1 perf(ui): lazy-load content-card images + OnPush on sources filters panel
Two small focused changes from an Angular perf audit.

content-card lazy loading
ContentCard is the building block for catalog grids and rails — Xtream
recently-added (3 rails × 20 = 60 cards on first paint), Xtream/Stalker
search results, unified favorites/recent grids. The poster <img> had
no loading attribute, so every off-screen card eagerly fetched its
poster from the Xtream/Stalker server on initial render. Same fix
already in place on the dashboard rail's separate <img>; mirror it on
the shared ContentCard so every consumer benefits.

Add loading="lazy" decoding="async" to both <img> tags (real poster
and the default-poster fallback). Browser defers off-screen requests;
no JS changes.

OnPush on workspace-sources-filters-panel
The sources filters panel renders one row per filter option and
displays {{ getTypeCount(option.id) }} per row — that method is a
plain Map lookup but fires every CD cycle under default change
detection. The component is signal-only (computed + selectSignal, no
subscribes) so the OnPush conversion is purely additive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
2026-05-02 17:58:59 +02:00
4grayandClaude Opus 4.7 29fe8b905c perf(database): partial covering index for visible categories
Three queries filter on categories.hidden = false:

- getGlobalRecentlyAdded — every dashboard load (now sorts by added DESC
  on the indexed content side after the prior CAST removal)
- searchContent — Xtream search with excludeHidden
- globalSearch — global Xtream search with excludeHidden

All three start from content and join into categories on the
category_id PK. The hidden filter is then applied per-row, requiring a
row lookup for every result candidate — even before LIMIT.

Add a partial covering index on categories: idx_categories_visible(id,
playlist_id, type) WHERE hidden = 0. Two wins:

- Covering: SQLite serves the join (id) plus filters (playlist_id,
  type) and the hidden = 0 predicate directly from the index without
  ever touching the categories row.
- Partial: hidden categories are absent from the index, so they're
  pruned before any work happens — no row lookup, no comparison.

Schema mirrored in Drizzle so the ORM stays the single source of
truth. CREATE INDEX IF NOT EXISTS so existing DBs pick it up on next
startup with no migration needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
2026-05-02 10:27:38 +02:00
4grayandClaude Opus 4.7 13acbe7e3d perf(dashboard): memoize M3U favorite parses + stream per-playlist updates
Two compounding wins for the dashboard's M3U favorites path.

#3 — Memoize loadM3uPlaylistFavorites
The function calls getPlaylistById() which deserializes the entire
M3U playlist payload (potentially 90K channels) just to read the
favorites field and the matching items. On every dashboard mount, it
re-paid that cost for every M3U playlist with favorites — even though
nothing relevant had changed.

Add a per-playlist cache keyed by playlist ID with a fingerprint of
{updateDate, favorites JSON}. A cache hit skips the heavyweight
playlist read entirely; a refresh (updateDate change) or favorites
add/remove naturally invalidates without explicit busting. First
mount still pays the parse; subsequent dashboard visits and
re-renders are effectively free.

#4 — Stream per-playlist favorites into the rail
reloadM3uGlobalFavorites used Promise.all over loadM3uPlaylistFavorites
and wrote one flat array at the end, so a single slow M3U playlist
pinned the entire M3U favorites contribution behind it.

Replace the writable m3uGlobalFavorites signal-of-array with a
WritableSignal<Map<playlistId, items[]>> + computed flatten. Each
playlist's contribution lands in the map as soon as ITS load resolves
(usually instant via the new cache), so the favorites rail re-renders
incrementally — Xtream favorites first, then each M3U playlist's
favorites appear as it streams in.

Cache + map entries for playlists that no longer have favorites are
pruned at the start of each reload, so removed playlists don't leave
stale state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: fb28be89e785
2026-05-02 09:18:08 +02:00
4grayandClaude Opus 4.7 d67150d38f perf(dashboard): per-rail skeletons + drop CAST that blocked content index
Two independent fixes that compound: the dashboard now renders progressively
as each rail's data resolves, and the slowest of those rails (Xtream
recently-added) no longer scans the entire content table.

Per-rail skeletons (template restructure):
The old @if (!ready()) gate hid the whole dashboard until ALL FOUR loading
flags resolved (playlistsLoaded, globalRecentLoaded, globalFavoritesLoaded,
xtreamRecentlyAddedLoaded). The slowest one pinned the entire skeleton up
for the full tail latency — visibly seconds of "loading" even when 3 of 4
rails could have rendered immediately.

Replace with per-rail conditionals:
- Hero: renders the moment globalRecentItems[0] is available; skeleton
  shows only while data.globalRecentLoading() is true and no item exists.
- Each rail: shows real content if its cards are non-empty, its own
  skeleton if its dedicated loading flag is true, nothing otherwise.
- The Xtream recently-added rail's skeleton is gated on having Xtream
  playlists at all, so M3U-only users never see a skeleton for it.

The loading signals were already exposed on DashboardDataService
(globalRecentLoading, globalFavoritesLoading, xtreamRecentlyAddedLoading)
but went unused because of the monolithic gate. Same skeleton markup is
reused per rail; no styling changes.

Drop CAST(added AS INTEGER) in getGlobalRecentlyAdded:
The query ordered by sql<number>\`CAST(content.added AS INTEGER)\`. SQLite
cannot use an index on a column wrapped in a function, so the existing
idx_content_type_added index was bypassed and the planner did a full table
scan + sort on content (10k–100k+ rows for a typical Xtream catalog) on
every dashboard load.

Sort by schema.content.added directly. Xtream stores Unix-epoch timestamps
as 10-digit numeric strings (anything since 2001-09-09), so lexicographic
and numeric sort are equivalent. The (type, added) index now drives the
ORDER BY too — full table scan becomes an index range scan + LIMIT 20.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 1e3392724bc8
2026-05-02 09:09:10 +02:00
4gray ec2f6a702a chore: resolve master conflicts for embedded mpv branch
Entire-Checkpoint: 5b514fe72836
2026-05-02 02:19:54 +02:00
4grayandClaude Opus 4.7 880163ca7a perf(portal-status): hoist 30s TTL cache + in-flight dedup into shared service
PortalStatusService previously did one IPC + HTTPS round-trip per call,
forcing every consumer to roll its own cache (or, more often, not).
The result: opening the homepage rendered N playlist-item components
that each fired their own check, then opening the playlist switcher
fired N more for the same portals.

Move the cache and dedup into the service:

- 30 s TTL cache keyed by `${serverUrl}|${username}|${password}`. Same
  credentials = same cache entry, regardless of which playlist row
  triggered it.
- In-flight dedup via Map<key, Promise<PortalStatus>>. Two callers
  hitting the same portal in the same tick share one network request
  instead of racing.
- New `getCachedStatus()` for sync read (used by playlist-switcher to
  hydrate the UI on menu open without awaiting).
- New `clearStatusCache()` for log-out / debug flows.

Add `{ skipCache: true }` opt-out for the Xtream import dialog's
"Test Connection" button — that's a user-initiated check that must
return fresh truth, not a 30 s old cached result.

Net result: in the common flow (homepage → switcher), the switcher
opens with cached status indicators instantly. The single in-flight
dedup prevents the playlist-item ngOnInit + switcher onMenuOpened from
racing for the same portal.

Removed the component-local cache from playlist-switcher; service is
now the single source of truth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: a635db375527
2026-05-02 00:57:08 +02:00
4grayandClaude Opus 4.7 e888841b21 refactor(ui): unify live-tv channel skeleton across m3u, xtream, stalker
The loading placeholders for live channel rows were inconsistent and
visibly heavier than the real channel-list-item: each module rendered
its own skeleton with a colored card surface, larger padding/radius,
mismatched logo border-radius, and only a single tiny right-side dot
instead of the real 34×34 action buttons. Stalker had no initial-load
skeleton at all.

Extract a shared app-channel-list-item-skeleton that mirrors
channel-list-item 1:1 (transparent surface, 8/10/8/12 padding, 12px
radius, 44×44 logo with 10px radius, optional EPG progress placeholder,
0–3 button placeholders) plus a thin app-channel-list-skeleton wrapper
that renders N rows with deterministic width variance, and route every
live-tv loading state through it (m3u channel-list-loading-state,
xtream portal-channels-list, stalker live-stream-layout via a derived
isInitialChannelsLoading signal, and the unified favorites/recent
collection page). Drops the bespoke channel-skeleton-shimmer keyframes
in favor of the shared skeleton-shimmer animation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 58a2d50756dd
2026-05-02 00:52:38 +02:00
4grayandClaude Opus 4.7 888a2dd80a perf(playlist-switcher): stream Xtream portal status checks instead of blocking on Promise.all
Opening the playlist switcher fired one XTREAM_REQUEST IPC + HTTPS
round-trip per Xtream playlist and only updated the UI after ALL of
them resolved. A single slow or hung portal pinned every status dot
in the menu to the misleading red 'unavailable' state for the full
tail latency (often several seconds, sometimes longer).

Four changes land together:

1. Stream results — each portal's dot updates via signal.update() the
   moment ITS request resolves, independent of the slowest one. The
   previous Promise.all wrote a single Map at the end; now the Map
   grows incrementally.

2. New 'checking' status — extends PortalStatus with a pulsing-dot
   visual so users see "we're working on it" instead of red dots
   that look like failures. Respects prefers-reduced-motion.

3. 30-second TTL cache — opening, closing, and reopening the menu
   within 30s reuses prior status results and skips the IPC entirely.
   Cache survives across menu opens but is per-component instance
   (a global cache is a possible follow-up).

4. AbortController cancellation — closing the menu (or destroying the
   component) cancels in-flight checks so a slow portal can't write
   stale results into the next round. Solves the 'rapidly open/close
   the menu and watch dots flicker' problem.

The actual IPC layer wasn't changed — the wins come purely from
streaming, caching, and not lying to the user about portal state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 58a2d50756dd
2026-05-02 00:51:46 +02:00
4gray c14b5d5aff fix(xtream): search all vod and series items
Entire-Checkpoint: 3f453cc0085a
2026-05-02 00:35:37 +02:00
4grayandClaude Opus 4.7 6303986c72 perf(channel-list): replace per-tick channel cloning with side-car EPG map
The three channel-list views (all-channels, groups, favorites) each had
an enrichedXxx computed signal that, on every progressTick (~30 s),
spread-cloned every channel in scope to attach { epgProgram, logo,
progressPercentage }. On a 90 K-channel M3U playlist, all-channels-view
allocated ~90 K objects per tick — persistent young-gen pressure and
visible GC stutter on large lists.

Replace the pattern with a side-car ChannelEpgMetadata map keyed by EPG
lookup key, holding only entries for channels that actually have EPG
data (typically a small fraction of the playlist). Templates iterate
the raw channel array and look up { epgProgram, progressPercentage }
inline. Logo resolution moves to a per-row method call — under OnPush
+ virtual scroll only ~50 visible rows check at a time.

groups-view's selectedGroupChannels also lost its progressTick
dependency: sorting + filtering only reruns when the selected group or
sort mode changes, no longer every 30 s.

The EnrichedChannel interface is replaced with a smaller
ChannelEpgMetadata. Specs that asserted on the cloned `.logo` field now
call `getLogoForChannel(channel)`.

Inspired by matracey/iptvnator@8d78cc0, @b9e6e82, @296d61e — ported as
a single coherent change. Their version missed our second site
(selectedGroupChannels) and didn't address the per-tick logo cloning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: e400bd558b10
2026-05-02 00:06:16 +02:00
4grayandClaude Opus 4.7 d1af5f0510 perf(epg): batch current-programs lookup into a single IPC + SQL query
Channel-list rendering called EpgService.getCurrentProgramsForChannels(),
which forkJoined N getChannelPrograms() Observables — each firing its own
IPC round-trip and its own SQL query. For a 500-channel visible window on
first scroll, that was 500 IPC calls and 500 SELECTs hammering the EPG
table.

Add GET_CURRENT_PROGRAMS_BATCH IPC handler that takes the channel-id
array and runs a single SELECT with WHERE channel_id IN (...) AND
start <= now AND stop >= now. The renderer-side cache and TTL behavior
are preserved; only the network of IPC calls collapses to one. A
fallback path keeps the old per-channel behavior if the preload lacks
the new endpoint.

Per-channel display-name fallback (NOCASE id, then NOCASE display name)
is preserved from handleGetChannelPrograms so behavior matches the
existing single-channel handler.

Inspired by matracey/iptvnator@d25a7e8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 6719280e397b
2026-05-01 23:45:10 +02:00
4grayandClaude Opus 4.7 94a0756424 fix(ui): unify the three side-by-side empty states in m3u groups view
Three placeholders were rendering at once when a search filter cleared
both the groups rail and the channel list, and they each used a
different visual treatment:

1. Groups rail (.groups-nav-empty) was bare 0.82rem muted text with no
   icon and no hint, and routed through CHANNELS.NO_SEARCH_RESULTS
   ("No channels found") which is wrong copy for the *groups* column.
2. Channel list (.groups-content-empty-state) was the canonical L4
   icon + title + hint pattern (correct).
3. Player area (<app-portal-empty-state>) was a 72px icon at 0.2
   opacity, which read as ghosted on dark theme.

Bring the groups rail into the L4 family scaled down for the narrow
column: 28px search_off / folder_off icon at 0.5 opacity, 0.85rem
title, 0.78rem hint at 0.65 opacity, centered. Add a CHANNELS.NO_GROUPS_FOUND
i18n key so the copy reflects the column ("No groups found").

Bump portal-empty-state's icon opacity from 0.2 -> 0.35 and title
opacity from 0.5 -> 0.6 so the player-area placeholder feels present
rather than abandoned.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 6719280e397b
2026-05-01 23:43:36 +02:00
4gray 42bbbe71d8 feat(playlist-import): add clear actions to import forms
Entire-Checkpoint: 6719280e397b
2026-05-01 23:42:35 +02:00
4grayandClaude Opus 4.7 472ac66185 fix(portal): align channel-list no-results placeholders with L4 pattern
The Xtream and Stalker portal channel-list panels both rendered their
"No channels found" empty state with a 64x64 gradient-tile wrapper
around a 32px icon, which was a one-off treatment that didn't match
the inline empty-state pattern used everywhere else at the same level
(EPG list, groups view, all-channels view): a bare 48px Material icon
at 0.5 opacity, 1.05rem title, 0.85rem 0.65-opacity hint.

Drop the .empty-icon-wrapper tile, switch to the canonical sizes, and
reuse the empty-state-icon / empty-state-title / empty-state-hint
class names so the visual is consistent across all panel-level empty
states.

Also replace the hardcoded EN string in the Stalker variant ("We
couldn't find anything matching ...") with the existing
CHANNELS.TRY_DIFFERENT_SEARCH i18n key, matching the Xtream branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: e3e3d80db476
2026-05-01 23:26:15 +02:00
4grayandClaude Opus 4.7 2f7406054d perf(ui): enable OnPush across hot list-rendering components
Audit found 10 list-rendering components (each >150 lines, all using
signal-based state) on default change detection. Default CD re-checks
every binding on every parent CD cycle (mouse moves, EPG progress
ticks, etc.); under OnPush these only re-check when their own signals
change or when explicitly markedForCheck.

All 10 use signals exclusively for state (no manual subscribe-and-mutate
patterns), so the conversion is straightforward and safe.

Components:
- recent-playlists (740L) — homepage playlist list with drag-drop
- vod-details-route (552L) — Xtream VOD detail page
- playlist-switcher (509L) — workspace shell playlist switcher
- season-container (444L) — Stalker/Xtream series episodes (DoCheck preserved)
- stalker-search (362L) — Stalker search results
- search-results (350L) — Xtream search results
- category-management-dialog (199L) — manage Xtream categories
- recently-added (162L) — Xtream recently-added rail
- category-content-view (Catalog) — category browse page
- grid-list (shared) — generic grid renderer used across portals

Tests: 124 passed across all touched lib projects.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 2e1a36e2f0f6
2026-05-01 23:24:10 +02:00
4grayandClaude Opus 4.7 c44e4e6f83 fix(portal): scale down NO_SEARCH_RESULTS icon
The previous clamp(120, 18vw, 180) made the search_off icon visually
compete with the SVG illustrations used for ERROR / EMPTY_CATEGORY,
which is wrong — a no-search-results state is a transient filter view
and should recede compared to a true page-level error.

Drop to clamp(72, 11vw, 112) at 0.45 opacity so the icon is clearly
present but smaller and lighter than the SVG illustrations above it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 2e1a36e2f0f6
2026-05-01 23:24:03 +02:00
4grayandClaude Opus 4.7 d1e4c0d306 fix(portal): show search-aware empty state when category search yields no results
When a search filter inside a category produces zero results, the grid
@empty fallback used to render the EMPTY_CATEGORY view ("No content in
this category" with the empty-tv illustration). That message was
misleading — the category itself wasn't empty, only the search filter
excluded everything.

grid-list now takes a searchTerm input and branches the @empty fallback:
when a search is active it renders the NO_SEARCH_RESULTS view with the
parameterised "No results found for {term}" title, otherwise it falls
back to EMPTY_CATEGORY as before. category-content-view derives the
searchTerm signal from the ?q= query param and passes it through.

Also: bump the NO_SEARCH_RESULTS icon from a 64px mat-icon to a clamp()
sized icon (120-180px) at 0.5 opacity so it visually balances with the
SVG illustrations used by the other viewTypes, swap the icon to
search_off (struck-through magnifier) so it reads as "no results"
rather than "search now", and fix the EN i18n typo
("change you search request" -> "change your search request").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 2e1a36e2f0f6
2026-05-01 23:17:22 +02:00
4grayandClaude Opus 4.7 d6a91d79e9 perf(bundle): dynamic-import iptv-playlist-parser to drop 130KB validator from eager bundle
iptv-playlist-parser statically imports the entire validator library
(~130KB across 113 files). Because PlaylistsService is eager (re-exported
through the 'services' barrel that AppComponent imports), validator was
landing in the cold-start preloaded chunk even though parse() only runs
on user-triggered playlist imports (FILE/URL/TEXT add).

Switch handlePlaylistParsing() to dynamic-import the parser. The two
callers (playlist-backup.service and the parsePlaylist$ NgRx effect)
needed minor adjustments — the effect now uses mergeMap(from(...)) to
flatten the Promise back into the action stream.

Cold-start preloaded chunk: 1511KB -> 1374KB (-9%).
Validator now sits in a separate ~129KB chunk that loads only when
the user adds a playlist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 3ec205264762
2026-05-01 23:10:10 +02:00
4grayandClaude Opus 4.7 39ddaa2f9c perf(recents): batch Xtream remove-recent-item into a single IPC call
Clearing all recents of a given content type fired N concurrent IPC calls,
each opening its own implicit transaction in the recently_viewed table.
For users with hundreds of recently-watched VOD/series rows this added
real overhead even though the UI updates optimistically.

Add a new DB_REMOVE_RECENT_ITEMS_BATCH path end-to-end:
- removeRecentItemsBatch() Drizzle op: one transaction, one prepared
  statement reused per row
- Wire through worker → IPC handler → preload → window.electron typings
  → DatabaseService
- UnifiedRecentDataService.removeRecentItemsBatch() groups items by
  source. Xtream items go through the new batch IPC. M3U/Stalker items
  still go per-playlist because they update a JSON column on the
  playlist row, not the recently_viewed table — but they now run in
  parallel with the Xtream batch via a single Promise.all.
- Single call site updated: unified-collection-page "Clear all of type"
  confirmation handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 4bfb62f988b7
2026-05-01 23:00:32 +02:00
4grayandClaude Opus 4.7 21fe51e73b fix(ui): redesign EPG no-data empty state to match app pattern
Replace the dashed-bordered "Ooops" placeholder in the EPG side panel
(rendered when the selected date has no programmes) with the icon +
title + hint pattern already used by groups view, season container,
and portal empty states.

The dashed border was a visual outlier — every other empty state in
the codebase is borderless, and a dashed stroke conventionally signals
a drop-zone or "add new" affordance, neither of which applies to a
passive informational state. The new layout uses a 48px event_busy
icon at 0.5 opacity, a 1.05rem title, and a 0.85rem hint at 0.65
opacity, centered in the panel via flex.

Soften the EN microcopy ("Ooops, EPG is not available for the selected
date" -> "No program guide", which also fixes the "Ooops" typo) and
add a NO_GUIDE_HINT key with localized strings across all 18 locales,
nudging the user toward the date arrows or playlist refresh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 4bfb62f988b7
2026-05-01 22:55:17 +02:00
4grayandClaude Opus 4.7 0d79466c84 perf(ui): enable OnPush on portal channels list, fix EPG track keys
PortalChannelsListComponent renders a virtual-scrolled channel list (Xtream
live/VOD/series, can be 1000+ items). It was using default change detection,
so every CD cycle in the parent tree (EPG ticks, progress updates) re-checked
every binding. Switch to OnPush — the existing cdr.detectChanges() calls
already cover the async update paths, and signal-based state marks for check
automatically.

Replace track $index with track program.start in two EPG @for loops
(epg-list, multi-epg-container search results). $index causes Angular to
re-create DOM nodes whenever the list reorders (e.g. on EPG refresh or
timezone correction). program.start is the ISO datetime — unique per
program within a channel and stable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 3d6901072049
2026-05-01 21:18:22 +02:00
4grayandClaude Opus 4.7 9ecf4d44d3 perf(database): add composite indexes for hot ORDER BY paths
The existing single-column indexes force a sort step for the most common
list queries. These three composite indexes let SQLite scan in index
order and skip the sort entirely:

- recently_viewed(playlist_id, viewed_at DESC):
  getRecentItems(): WHERE playlist_id = ? ORDER BY viewed_at DESC LIMIT 100
- favorites(playlist_id, position, added_at DESC):
  getFavorites/getGlobalFavorites/getAllGlobalFavorites():
  WHERE playlist_id = ? ORDER BY position ASC, added_at DESC
- playback_positions(playlist_id, updated_at DESC):
  getRecentPlaybackPositions(): WHERE playlist_id = ? ORDER BY updated_at DESC LIMIT 20

CREATE INDEX IF NOT EXISTS so existing DBs pick up the new indexes on
next startup with no migration required. Mirrored in the Drizzle schema
using sql\`col DESC\` so the ORM stays the source of truth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 3d6901072049
2026-05-01 21:11:44 +02:00
4grayandClaude Opus 4.7 eedfc498e7 perf(database): run PRAGMA optimize before closing connections
SQLite's recommended approach for keeping query plans current: cheap when
nothing needs analyzing, runs incremental ANALYZE on tables/indexes that
have grown significantly since the last run. Wrapped in try/catch since
optimize is advisory and must never block connection close.

Applied to both the main connection and the EPG worker connection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 3d6901072049
2026-05-01 21:09:24 +02:00
4grayandClaude Opus 4.7 e33cb70dec perf(database): add SQLite performance pragmas (synchronous, cache, mmap)
Pair the existing journal_mode=WAL with the companion pragmas it needs to
actually pay off, and reduce read latency on hot query paths.

- synchronous=NORMAL: ~2-10x faster writes when paired with WAL. Safe — only
  risks losing the last committed txn on power loss; DB stays consistent.
- cache_size=-64000: 64MB page cache (default is 2MB).
- temp_store=MEMORY: keep sort/group temp tables in RAM.
- mmap_size=268435456: 256MB memory-mapped I/O for reads.

Applied to both connection sites: the main read-write/read-only connection
in libs/shared/database and the EPG worker connection in electron-backend.
The per-connection pragmas (cache_size, temp_store, mmap_size) apply to the
read-only agent-backend connection too.

Inspired by matracey/iptvnator@4ad8f88; ported manually since the file has
diverged significantly and the worker connection didn't exist in that fork.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 3d6901072049
2026-05-01 21:02:52 +02:00
4gray 1af287205f perf: optimize playlist favorite lookups
Use Map and Set lookups for M3U favorite aggregation, dashboard global favorite mapping, and channel-list favorite/group resolution.

Remove the duplicate NgxIndexedDB provider registration and add regression coverage for duplicate URL and earliest-match behavior.

Entire-Checkpoint: c2cea9c530e6
2026-05-01 20:44:30 +02:00
4gray 6ef9e8c4b0 fix(web): lazy load route feature boundaries
Entire-Checkpoint: c2cea9c530e6
2026-05-01 20:38:14 +02:00