The redesign rollup in this branch replaced the Add Playlist dialog's
2-level type × subtype tabs with a single flat 5-card radiogroup, and
replaced the Settings theme list with a compact segmented control whose
options are just "Light"/"Dark"/"System" (no "theme" suffix). E2E tests
that pinned to the old roles/labels failed in CI:
- basic / xtream / stalker / self-hosted: getByRole('tab', ...) on
"Add via file upload" / "Xtream" / "Stalker" — there are no tabs in
the new dialog. Switched to getByRole('radio', { name: /M3U file/i })
and friends, matched against the new radio labels.
- settings: getByRole('radio', { name: 'System theme' }) — the new
picker uses just "System". Scoped to the [data-test-id="select-theme"]
radiogroup so it doesn't collide with the identically-labelled
cover-size options below.
The Electron `clickDialogSegmentedOption` helper grew a radio-role lookup
as its primary path and keeps the old tab/button/legacy-selector
fallbacks so a future redesign won't break every fixture again. The
"M3U" parent category becomes a no-op (the new picker has no standalone
M3U tile — callers always specialise to M3U URL/file/text immediately
after, which is what we want anyway).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
For series in the Continue Watching rail, surface which episode the user
left off on as a small "S2·E5" chip next to the card subtitle. Sources
the season/episode straight off the matched PlaybackPositionData so the
information is always in sync with the resume target.
Fixes a latent bug in the same patch: getPlaybackPositionForItem could
not resolve series whose recent_items row carried the series id (the
landing-page path), because playback_positions are keyed by the episode
id. The lookup now matches both shapes (contentXtreamId === xtreamId OR
seriesXtreamId === xtreamId) and prefers the most recently updated
episode, which also restores the resume progress bar that had been
silently dropping out for series.
Localised in all 17 non-English locales — most use language-specific
short forms (St·F in German, T·E in Spanish/Portuguese, С·Э in
Russian/Belarusian, 시즌N N화 in Korean) rather than the English S·E.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Zoom/Filter/Search Programs buttons in the Multi-EPG header
hard-coded English matTooltip strings, so they never localized — even
when the rest of the toolbar (Close, Previous/Next day) used keys. Adds
six new EPG.* keys (ZOOM_IN, ZOOM_OUT, FILTER_CHANNELS, CLOSE_FILTER,
SEARCH_PROGRAMS, CLOSE_PROGRAM_SEARCH), wires both [matTooltip] and
[attr.aria-label] through `| translate`, and fans the strings out to
all 17 non-English locales via the i18n-fill pipeline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Radio audio-player buttons (skip-prev, play/pause FAB, skip-next, mute,
volume slider) had zero matTooltip or aria-label. The UX audit flagged
this: "Skip-prev / Skip-next — what do they skip? In radio, is the
next 'track' the next station? Next in favourites? Random? Without a
tooltip the buttons trade silently." Same critique applied to mute
and play/pause for screen-reader users.
Add hover tooltips and screen-reader labels on all 5 controls. Skip
prev/next say "Previous station" / "Next station" so the behaviour is
explicit (they walk the active filtered list). Play/Pause flips its
label with state. Mute/unmute flips when isMuted() || volume === 0.
Volume slider gets its own label so the slider thumb is announced
correctly.
i18n: 7 new AUDIO_PLAYER.* keys translated across all 17 non-English
locales by per-locale agents; all 18 locales pass coverage and
placeholder integrity checks.
52/52 ui-playback tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After switching from mono to sans, the SETTINGS label still used
uppercase + 0.1em letter-spacing — an eyebrow treatment that doesn't
appear anywhere else in the workspace rail. The neighbouring items
("Dashboard", "Sources", "Global favorites") are all sentence case,
so the uppercase header stood out as a one-off.
Use sentence case "Settings" with the same typography family as the
rail items: same font, same case, small + muted so it still reads as
a quiet caption rather than competing with the clickable items below.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The v0.22 mockup uses JetBrains Mono for eyebrow labels as part of its
broader editorial design language. The previous commit ported that one
element verbatim, but the rest of the workspace shell never uses mono
for chrome — only for code-adjacent data (EPG time codes, kbd glyphs,
port numbers). So the SETTINGS rail title became a one-off mono label
in an otherwise all-sans shell.
Drop the `font-family: 'JetBrains Mono'…` declaration so the eyebrow
inherits DM Sans. Bump the weight from 500 → 600 to compensate for
mono's higher visual density. Uppercase + 0.1em letter-spacing + small
size are kept — those carry the eyebrow effect regardless of typeface.
If we ever want to port the broader mono-eyebrow system across rails
and chips, that's a separate (bigger) design decision.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`--settings-action-bar-space: 80px` added padding-bottom to the form
*after* its last child (the action bar), giving 80px of scrollable
empty space below the bar — visible when scrolling to the end as a
big dark band the page kept scrolling past the last setting.
The reservation was originally tuned for the OLD floating-chip
layout, where the bar hovered 24px above the bottom edge and content
needed to be pushed above it. In the new sticky-footer model the bar
IS the bottom edge, so no padding is needed below it.
Set to 0px. CDP verifies form.bottom === bar.bottom after the change
— the scroll now ends cleanly at the action bar.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The settings-section-scroll directive ran a 260ms Element.animate() on
whichever section became "active" as the user scrolled — three
keyframes painting an `inset 0 0 0 1px var(--settings-group-active-ring)`
ring plus a soft glow on the section block. With the new flat layout
(no card chrome, no static active ring), that pulse drew a brief 1px
border around each block as you scrolled past — the user-reported
"short border kind of highlight around blocks" effect.
The rail's left active state already announces which section you're on,
so the inline pulse is redundant. Removed the entire active-section
animation effect (the pendingScrollTarget scroll-into-view effect is
untouched — that's the click-the-nav-and-jump behaviour).
CDP verifies: all .settings-group elements report `box-shadow: none`
after scrolling. 50/50 settings tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`--settings-action-bar-offset: 24px` was tuned for the previous
floating-chip layout (border-radius:18px, margin-left:auto) so the bar
visually hovered above the bottom edge like a card. After converting
it to a full-width sticky footer the 24px gap just left a strip of
empty content peeking out below the bar — the user-reported "not
perfectly aligned to the footer" gap.
Set the offset to 0px so the bar sits flush against the bottom of the
scroll container. CDP verifies: barBottom === viewport height after
the change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The earlier Settings polish (kill triple header, compact theme picker,
sticky footer, hide playlist switcher) addressed structure. This round
addresses typography and chrome to bring the look in line with the
v0.22 mockup spec captured in redesign-screens-2.jsx.
Three changes:
1. Flat sections — no card chrome
- Each .settings-group was a rounded panel (border-radius:22px,
filled background, box-shadow, .settings-group--active ring +
glow). Combined with the per-section icon-circle next to the
title, every section looked like a feature card, not a settings
region anchor. Strip all of it: the rail's active state on the
left already announces "you are here".
- Hide .settings-group__header-icon via display:none so the icon-
circle markup in each section template stays intact while the
visual chrome goes away — saves touching 6 templates for a
CSS-only change.
2. Promote section titles to a large flat heading
- Was h3 1.08rem 700 weight nested in the card chrome. Now h3
1.5rem (24px) 600 weight with a 1px bottom-border separator,
matching the mockup's 28px h1 + sub anchor pattern (24px is a
reasonable density compromise for the denser app layout). Active-
section variant keeps the blue title accent so users scrolling
the right pane don't lose the anchor.
- .setting-item drops the `margin: 0 18px` card inset so rows
align flush with the section title — flat list rhythm.
3. Quiet mono eyebrow for the left nav title
- .panel-title was a 20px 500 weight h2 ("Settings") competing for
"biggest text on screen" against the section titles on the right.
Switch to mono 11px uppercase 0.1em letter-spaced text-secondary
— quiet rail header per the mockup. Now the section titles on
the right are unambiguously the dominant heading.
Visual confirmation via agent-browser shows a much closer match to the
mockup: subtle SETTINGS eyebrow above the rail list, large flat
"General" anchor with bottom border, compact pickers, full-width sticky
footer.
50/50 settings tests still pass; build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Remote Control nav item carried id: '@iptvnator/ui/remote-control'
— the NX library name pasted in place of the section's HTML id. The
settings-section-scroll directive resolves the target with
document.getElementById(), which returned null for that string, so
clicking "Remote" silently no-op'd. The active-state binding
([class.settings-group--active]="activeSection() === 'remote-control'")
also never lit up because the nav reported a different id than the
section template uses.
Change the nav id to 'remote-control' so it matches the section's
id="remote-control" attribute.
Added a focused regression spec (settings-options.spec.ts) that:
- asserts the desktop-only items light up only when isDesktop is true,
- reads each section component's root <section id="…"> at test time and
cross-checks the nav id set against it, so any future drift between
the two (renamed section, new section without nav entry, copy-paste
of a library path into the id) fails the build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four UX-audit fixes for the Settings page:
1. Header dedupe
- The page used to stack THREE headers vertically: the workspace rail's
"Settings" entry, a page-level <h1>Settings</h1> + subtitle, and the
General section's <h3>General</h3> + subtitle. The page-level and
section-level subtitles both used SETTINGS.GENERAL_SUBTITLE
("Change the configuration of the application"), word-for-word.
- Drop the visible page-level header entirely. Keep an a11y-only
<span class="visually-hidden"> for the data-test-id hook so the
existing settings.component.spec selector still resolves. Left rail
announces "Settings"; section header is now the page anchor.
- Add a global .visually-hidden helper to styles.scss for re-use.
2. Theme + Cover-size pickers shrink to a real segmented control
- .theme-switcher was a 3-column grid of ~78px-tall buttons making
"Choose theme" look like the most important action in Settings.
Replaced with a 30px-tall segmented control (track + thumb with
box-shadow on the selected option), matching the standard Material
"compact row" cadence everywhere else.
- Shortened all 18 locales' THEMES.* labels from "Light theme / Dark
theme / System theme" to "Light / Dark / System". The "theme" word
duplicated the section's own h3 ("Visual theme") and forced labels
to wrap into two lines in narrow columns.
3. Save bar becomes a real sticky footer
- The action bar was a `border-radius:18px; margin-left:auto;
position:sticky` chip floating bottom-right that clipped the last
form rows underneath. Now spans the full content column with a
top border separator — sits flush with the bottom edge while
still sticky on scroll. The save-button gradient also drops the
hand-rolled #3b82f6 in favour of var(--app-selection-color) so it
matches the unified blue primary used everywhere else.
4. Playlist switcher hidden on /settings
- Workspace shell header gains an isSettingsRoute input that hides
the playlist switcher block AND the leading actions group
(+ Add source / header shortcut / bulk action). Settings is a
global page — those controls were implying that switching
playlists scopes settings, which it doesn't. Wired through from
the shell facade's existing isSettingsRoute computed.
- Mock header in workspace-shell.component.spec gains the matching
input() declaration so the binding resolves under tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four UX-audit fixes for the Multi-channel EPG view:
1. Theme harmonization
- $bg-deep / $bg-surface / $bg-elevated / $bg-hover now resolve through
--app-content-bg / --app-widget-bg / --app-widget-header-bg /
--app-card-hover-bg. Multi-EPG was the only screen that switched to
near-black (#0a0a0f) instead of sitting on the app's dark surface, so
the audit described it as "visually detaching from the rest of the
app". It now inherits the same dark graphite chain.
- Cleaned up 29 remaining hard-coded rgba(139,92,246,…) purples and
rgba(0,212,170,…) teal accents that survived the earlier color
unification commit. Purple shadows/glows are gone; the cyan glow
used by the now-line is now driven by the unified $5cd6ff token.
2. Channel column widened 140px → 180px
- Long names like "13th Street Universal HD" no longer truncate to 5
characters. Logo size dropped to 24px so the name actually has room.
The label allows up to two lines and wraps cleanly on long titles.
- Responsive breakpoint mirrors the change: 100px → 140px at <768px.
3. Now-line time badge
- New `currentTimeLabel` computed signal renders an "HH:MM" pill
pinned to the top of the now-line. Recomputes on the same 60s tick
as the line position so they always stay in lockstep.
4. "Airing now" program highlight
- New `isProgramAiringNow(program)` returns true when the now-line's
x-coordinate falls inside [startPosition, startPosition + width].
Programs that match get a .is-now class → 1.5px cyan border. Users
can now see what's on every channel at a glance without tracing the
line down each row.
Toolbar harmonization (audit item 3 for this screen) intentionally
deferred — that's part of the app-wide toolbar unification work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The custom .apd-head was getting visually orphaned from the rest of the
dialog body: zero horizontal padding so the title sat flush against the
dialog edge (cards inside mat-dialog-content are inset by 24px), plus a
~44px vertical gap above the title even though the head reported
padding: 0.
The gap was Material's .mdc-dialog__title::before — a 40px-tall
invisible inline element used to baseline-align dialog titles to a
grid. Useful for single-line titles, hostile to a two-line head with
explicit subtitle.
Set .apd-head padding to 20px 24px 4px so it shares the dialog
content's 24px horizontal rhythm, then suppress the ::before ghost.
Title now sits 20px from the dialog top edge with the same left inset
as the method cards and the form fields below.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 5-card method picker was rendering at the old 560px dialog width
which left each card too narrow — titles wrapped to 2-3 lines and the
subtitles to 6, all visible in the user-reported screenshot.
Three fixes:
1. Bump the dialog width from 560px → 780px to match the v0.22 mockup.
maxWidth: 92vw keeps the responsive collapse path intact.
2. Replace the inherited "Add via URL" / "Add via file upload" /
"Add Xtreme Code" / "Add Stalker Portal" / "Import from text" tab
labels with short noun-only card titles: "M3U URL", "M3U file",
"Xtream credentials", "Stalker portal", "Raw m3u text". The dialog
itself is titled "Add playlist" so the "Add" prefix on every card
was redundant. The "Xtreme" misspelling on the old key disappears
as a side effect.
3. Lock cards to min-height: 132px so the URL card (short subtitle)
stops collapsing while the Xtream/Stalker cards (longer subtitle)
stretch — selection states now look visually consistent. Slightly
tighter padding (10/11/12 vs 12/14) to fit the new width without
feeling cramped.
i18n: 5 new METHOD_*_LABEL keys translated across all 17 locales by
per-locale agents; placeholder integrity verified across all 920+
leaf keys.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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