- pruneEphemeralMaps now uses getCached() so TTL-expired entries also
drop their XMLTV preview / channel-id mapping, instead of clinging
to them until the next explicit cache read.
- portal-channels-list attaches a .catch() to the now-async enqueue so
any future internal rejection surfaces in the console rather than
bubbling silently as an unhandled promise rejection.
- XtreamXmltvFallbackService binds the bridge receiver when calling
the extracted function reference, in case a preload wrapper relies
on `this`.
Adds a regression test for the TTL-aware prune.
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.
Covers the embedded MPV player on macOS, toggleable Live TV sidebars,
command palette upgrades, and module fixes. Credits the Telegram tip
about using IINA as the external player and links the related GitHub
issues.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Capture the audit findings and refactor outcome in the canonical embedded-mpv
architecture doc so the next person to extend the player does not re-introduce
the same class of regression.
New "Renderer Architecture And Reactivity" section covers:
- The post-refactor file layout (format utils, shortcuts, overlay-visibility,
ui-state, session-controller, view component) and what each owns.
- The bounds compositing strategy: HIDDEN_BOUNDS for modal overlays,
partial bottom cutout for control popovers, full host rect otherwise —
plus why (NSView paints over WebContents, z-index is unwinnable).
- The reactivity rule learned from the audit: any signal read inside an
effect that feeds a one-shot side effect must be wrapped in untracked().
Only re-trigger sources stay tracked.
- A short ledger of the four bugs from the audit (support write loop,
volume-tracked session restart, scheduleControlsHide transitive deps,
stalled-tracker 2 Hz no-op) so they do not get reintroduced.
- IPC safety notes: gate on sessionId() not session()?.id; guardIpc
swallows addon-side throws.
- Pointer to the powerSaveBlocker integration.
Also documents the bundle 3 native surface additions (sid, speed,
video-aspect-override observers and exports, capability probe via
EmbeddedMpvSupport.capabilities) so renderers can feature-detect against
older addon binaries.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
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
- 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
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
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
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
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