The Add Playlist dialog used a nested control: pick category
(M3U / Xtream / Stalker) then pick subtype (URL / file / text). That's
9 cells of which only 5 are real — Xtream-via-file and Stalker-via-text
don't exist. Users spent visible time hunting the right path through
the matrix.
Replace it with a flat 5-card method grid that matches the v0.22 mockup
exactly: each card IS a method (M3U URL, M3U file, Xtream credentials,
Stalker portal, Paste raw m3u text), shown side by side with an icon,
label, and one-line description of when to pick it.
Component state collapses from `category` + `m3uSubType` (two signals
with an entangled `playlistType()` mapping) into a single `method`
signal of type PlaylistType. `initFromType` simplifies to one line.
`playlistType()` is kept as a thin alias so the template @switch and
the action-button branches stay untouched.
The selected card is the only place that needs to change for users to
pick a different method — no more "first click type, then click
subtype, then look for the form" two-step.
i18n: 6 new HOME.ADD_PLAYLIST.* keys (subtitle + 5 method subs)
translated across all 17 locales by per-locale agents, with the
existing HOME.TABS.* keys reused for card titles. Placeholder
integrity verified.
Tests: existing clearCurrentForm scenarios still pass after the signal
refactor; added 6 new tests covering the default method (URL) and the
MAT_DIALOG_DATA.type deep-link path for each of the 5 method values.
25/25 in playlist-import-feature.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related dashboard fixes driven by the v0.22 mockup intent and the
UX-audit feedback that the rail labels lied about their data:
1. Live rail is favorites-first
- Source: globalFavoriteLiveItems() — the channels the user actually
starred. Falls back to globalRecentLiveItems() when no favorites
exist so fresh-install users still see something useful.
- Title flips with the source: "Live now on your favorites" when
pulling from favorites, "Continue with live TV" when pulling from
recent-watch history. The label is always honest about the data.
- "See all" link routes to the right collection page for the source
(/workspace/global-favorites vs /workspace/global-recent).
2. Mixed Global Favorites rail removed from the dashboard
- The rail had movies, series, live channels, and radio all sharing
one row — different card formats fighting for visual attention.
UX wiki principle: similar elements should look alike within a
scanning unit.
- Live favorites are promoted into the live rail above (with current
EPG). The full mixed catalogue is still one click away at
/workspace/global-favorites where the collection page can give it
proper per-type filters.
- Net dashboard density goes from 5 rails to 4 — closer to the
streaming-app sweet spot.
i18n: new WORKSPACE.DASHBOARD.LIVE_CONTINUE key, translated across all
17 locales by per-locale agents (placeholder integrity verified).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire the playback-position store into DashboardDataService so the hero
and Continue Watching cards can show how far through each title the user
is. Per-playlist bulk fetch via getAllPlaybackPositions (one IPC call
each, no N+1) cached in an in-memory Map keyed by playlist + content id
+ content type — supports both VOD and episode keys, since the same
xtream-id can map to either.
Hero:
- Thin 4px progress bar under the subtitle when a position is known
- "1h 04m left · 38% watched" meta line below it (formatRemainingLabel
handles sub-minute, minute, hour, and hour+minute remainders)
- Tighter padding (16 vs 20) and smaller poster (140 vs 160) per the
v0.22 mockup spec — frees ~30px of vertical space without losing
legibility
Continue Watching cards:
- 3px progress overlay pinned to the bottom of the poster art, painted
in the unified blue primary (--app-selection-color)
- Renders only when watchProgress is set, so live channels and untracked
M3U items remain unaffected
Live channels and M3U items never have positions in the schema, so the
new UI is purely additive — both surfaces degrade gracefully to the
prior layout when the lookup misses.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "Live now on your favorites" rail no longer reuses the 2:3 poster
card built for movies. TV station logos are typically 100–256px square,
so inflating them into portrait cards wastes most of the height and
hides the data users actually care about (what's airing right now).
Introduce a `layout: 'cover' | 'channel'` input on DashboardRailComponent.
The new channel layout is a wider, much shorter card: logo + channel
name + current program title + LIVE chip + progress bar + time range.
Current programs are lazy-loaded via EpgService.getCurrentProgramsForChannels
(already batched + 60s-cached at the service), keyed by the recent item's
display name — works out of the box for M3U sources whose XMLTV channels
resolve through the tvg-id → tvg-name → name fallback chain. A 30s tick
keeps the progress bar fresh between program boundaries.
The card renders gracefully without EPG enrichment (idle progress bar,
no program title) so Xtream/Stalker live items without an XMLTV side
channel still look fine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per UX audit: a single "Recently Watched" rail mixed channels and VOD
into a grid where the two card formats fought for visual attention.
Split it into two rails — "Continue watching" (movies/series) and
"Live now on your favorites" (live channels) — so each surface uses
the card format that fits its content type.
Also relabel the rail header link from "Manage all" to "See all {N}"
when the total count exceeds what's rendered, so the user knows how
many items the link expands into; falls back to "See all" otherwise.
i18n: three new keys (LIVE_RECENT, SEE_ALL, SEE_ALL_COUNT) translated
across all 17 locales by per-locale agents reusing each file's existing
glossary; placeholder integrity verified.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Swap M3 primary palette from violet to azure so checkboxes, radio
buttons, raised CTAs, and active states read as the same blue used by
the rail selection token. Cascading template + SCSS updates align the
remaining hand-rolled surfaces (Add Playlist dialog, VOD play button,
radio player, multi-EPG, empty-state CTAs) with the unified system.
LIVE stays red (broadcast role), cyan stays on EPG "now" indicator,
green stays on completed-download — semantic colors keep their meaning;
only the indiscriminate accent uses get folded into the blue primary.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(playback): handle IPTV proxy URLs without file extensions
IPTV proxy servers (Acexy, Xtream Codes, etc.) use query-string URLs
like `https://proxy.example.com/ace/getstream?infohash=abc` that return
raw MPEG-TS streams without a file extension in the path.
`getExtensionFromUrl()` was returning garbage for these URLs (e.g.
`com/ace/getstream` from splitting on dots across path segments). All
four player backends then misrouted the stream — typically to HLS.js
which failed because the response is raw MPEG-TS, not an HLS manifest.
Root fix: rewrite `getExtensionFromUrl()` to extract the extension from
the last path segment only, returning `undefined` when there is no
dot-separated extension.
Player adjustments: treat `undefined` extension as MPEG-TS (the most
common format for live IPTV proxy streams) across all four backends
(HTML5, ArtPlayer, VideoJs, web-player-view).
* test(m3u-utils): cover URL extension parsing
* fix(playback): preserve query-declared stream extensions
---------
Co-authored-by: 4gray <serega05@gmail.com>
* fix(playback): avoid redundant VideoJS source resets
* fix(playback): clean up cleared VideoJS sources
* refactor(playback): use signal APIs in VideoJS player
closes#608
- 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.
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