Commit Graph
2203 Commits
Author SHA1 Message Date
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
4grayandClaude Opus 4.7 a74b8f1b81 fix: prevent macOS sleep during embedded MPV playback
The embedded MPV player renders via libmpv into a custom Cocoa view, which
bypasses mpv's built-in screensaver inhibition. Hold an Electron
powerSaveBlocker (prevent-display-sleep) while any session is playing and
release it on pause, dispose, or shutdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
2026-05-04 23:47:33 +02:00
4gray 4a21579aff fix: clear external player paths on save
Entire-Checkpoint: f957cd9849e0
2026-05-04 23:26:56 +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 1a09e696b4 test: add refresh overlay for Xtream source menu and improve visibility checks
Entire-Checkpoint: f957cd9849e0
2026-05-04 00:52:04 +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
4grayandClaude Opus 4.7 bbbe7b4036 perf(renderer): inline splash + sync language hint + drop legacy polyfill
Three first-paint fixes for cold app start.

#1 — Inline splash in index.html
Cold start used to show a blank Material-grey background until the
~1.5MB preloaded chunk + 300KB styles.css downloaded and Angular
bootstrapped. On slow disks/networks that's 1-3+ s of "is the app
frozen?" before any pixel of UI appears.

Add a self-contained splash (inline CSS, no extra HTTP, no assets) —
"IPTVnator" wordmark + a CSS spinner — that paints immediately from
the parsed HTML. Removed in main.ts after bootstrapApplication()
resolves, gated on requestAnimationFrame so the swap happens after
AppComponent's first paint (no flash of empty background between
splash removal and the real UI).

Respects prefers-reduced-motion (spinner stops, opacity dims).

#3 — Drop legacy `global = window` polyfill
The inline <script> in <head> ("if (global === undefined) var global =
window") was a workaround for an older library expecting Node's
`global`. Verified nothing in the current bundle reads window.global
(the only matches are TypeScript `declare global { ... }` blocks and
unrelated route paths). Removes one render-blocking inline <script>
parse from the critical path.

#5 — Synchronous language hint
Settings live in IndexedDB (via @ngx-pwa/local-storage), which the
TranslateModule cannot read synchronously at bootstrap. So the first
render historically used English regardless of the user's preference,
then re-rendered every TranslatePipe in the tree once initSettings()
finished — visible flash for non-English users.

Mirror the active language to localStorage in initSettings() once the
IDB-backed settings resolve. Add getInitialLanguage() in app.config.ts
that reads that key synchronously before bootstrap and passes it to
TranslateModule.forRoot({ defaultLanguage }). First-ever boot still
falls back to English (no hint yet); every subsequent boot renders in
the saved language from the very first frame.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
2026-05-03 00:00:06 +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 177148ad5d perf(electron): defer fixPath() off the synchronous startup path
fixPath() was called as the very first statement at module load
(top-level, before app.setName, before app.whenReady), which on
macOS/Linux spawns an interactive login shell — bash/zsh -ilc 'env' —
and waits SYNCHRONOUSLY for it to print the environment back. With
oh-my-zsh / heavy .bashrc setups this is routinely 50-300ms blocking
the Electron main process before window creation can even begin.

The only purpose of fixPath in this app is to populate process.env.PATH
so that subsequently-spawned external player binaries (MPV/VLC) can be
resolved by bare name. That's a user-action path (clicking play with
external player configured), not a startup-critical one. Two callers
exist (player.events.ts) and both fall back to bare 'mpv' / 'vlc' only
after checking well-known absolute paths.

Move the call into a setImmediate scheduled at the END of
bootstrapAppEvents — after DB init, IPC handler registration, and
window load. The user-visible startup sequence no longer carries the
shell-spawn cost. By the time anyone could plausibly click an external
player, PATH is already hydrated.

Idempotent + Windows-gated (fix-path is a no-op on Windows anyway).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
2026-05-02 19:06:28 +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
4gray 6b1e4a3ba7 chore(i18n): add fill-missing tool and update .gitignore for locale management
Entire-Checkpoint: f957cd9849e0
2026-05-02 17:20:55 +02:00
4gray 8fd25ac39a build(embedded-mpv): add Homebrew build scripts for embedded MPV
Entire-Checkpoint: f957cd9849e0
2026-05-02 17:20:29 +02:00
4gray 4acb6f9853 docs(embedded-mpv): update local development instructions for Homebrew usage
Entire-Checkpoint: f957cd9849e0
2026-05-02 17:19:49 +02:00
4gray a3275fa215 chore: add missing translations strings in all i18n files
Entire-Checkpoint: f957cd9849e0
2026-05-02 17:16:50 +02:00
4gray ef402cfe43 chore(settings): remove unused hooks and permissions from settings.json
Entire-Checkpoint: f957cd9849e0
2026-05-02 17:16:01 +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 31bddbd70f ci: build embedded mpv artifacts on master
Entire-Checkpoint: 5b514fe72836
2026-05-02 09:02:05 +02:00
4gray d0b08b9f1d feat(player): add embedded-mpv player for macOS as experimental feature (#877)
Merge PR #877: feat(player): add embedded-mpv player for macOS as experimental feature
2026-05-02 03:34:46 +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 66cd3d5c4f fix(database): wrap placeholder in sql template for prepared favorite update
Drizzle's .set() expects a column-typed value or an SQL fragment, not a
bare Placeholder. The earlier prepared-statement refactor (313230ab)
passed sql.placeholder('position') directly, which compiled clean under
ts-jest's isolated-modules mode but failed the full type check during
nx build:

  TS2322: Type 'Placeholder<"position", any>' is not assignable to
          type 'number | SQL<unknown> | SQLiteColumn<...>'

Wrap the placeholder in sql<number>\`...\` so it resolves to SQL<number>
at compile time. Behavior at runtime is identical — the placeholder is
still bound at execute() time per chunk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 746d41da84ff
2026-05-02 01:00:33 +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 0bcb89aaaf docs: add TRADEMARK.md clarifying name and logo are not MIT-licensed
Adds a top-level TRADEMARK.md spelling out that the IPTVnator name and
logo are unregistered trademarks reserved to the project, separate from
the MIT-licensed source code, and documenting what forks may and may
not do plus where to report misuse. README gets a short Trademark
section pointing to the new file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 3f453cc0085a
2026-05-02 00:33:26 +02:00
4grayandClaude Opus 4.7 2b3c65932d fix(data-service): track and actually remove window message listeners
Both ElectronService.listenOn() and PwaService.listenOn() called
window.addEventListener('message', callback) but neither could remove
the listener afterwards:

- ElectronService.removeAllListeners() called a placeholder
  getListenerForCommand() that returned a fresh () => undefined function
  on every invocation — never matching the registered listener (with a
  comment confessing as much).
- PwaService.removeAllListeners() was a literal `// not implemented`
  no-op.

The result was a latent memory leak: every listenOn() call accumulated
a global window listener that nothing could remove. Calling listenOn()
twice for the same command also stacked duplicate listeners.

Track callbacks in a messageListeners Map keyed by command name. On
listenOn(), drop any existing listener for that command before adding
the new one. On removeAllListeners(type), remove either the named
listener or all of them (when type === 'all').

The fix lands proactively: grep finds no current callers in the
renderer, so today's leak is theoretical. But the API is exposed via
the abstract DataService and the placeholder comment explicitly invited
the bug. Closing the footgun is cheaper than discovering it later.

Inspired by matracey/iptvnator@df7e1dc — extends the fix to PwaService,
which had the same bug in even more obvious form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 3f453cc0085a
2026-05-02 00:29:49 +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