From a27a29fcf2e0fd1b38140a6db1b6203ef5fcf72c Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 19 May 2026 00:12:34 +0200 Subject: [PATCH 01/24] refactor(ui): unify primary color to blue across all surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/web/src/m3-theme.scss | 24 +++++++----- .../add-playlist-dialog.component.html | 12 +++--- .../empty-state/empty-state.component.scss | 10 ++--- .../playlist-info.component.html | 4 +- .../feature/src/lib/downloads.component.html | 4 +- .../confirm-dialog.component.ts | 4 +- .../multi-epg-container.component.scss | 20 +++++----- .../audio-player/audio-player.component.scss | 23 ++++++----- .../external-player-info-dialog.component.ts | 2 +- libs/ui/styles/_detail-view-actions.scss | 38 +++++++++++-------- libs/ui/styles/_detail-view.scss | 31 ++++++++------- 11 files changed, 97 insertions(+), 75 deletions(-) diff --git a/apps/web/src/m3-theme.scss b/apps/web/src/m3-theme.scss index 60b860acc..4f8ac3325 100644 --- a/apps/web/src/m3-theme.scss +++ b/apps/web/src/m3-theme.scss @@ -15,11 +15,17 @@ $my-typography: mat.m2-define-typography-config( @include mat.typography-hierarchy($my-typography); // ─── M3 Themes ─────────────────────────────────────────────────────────────── +// One primary color across the app. Azure is the closest M3 prebuilt to the +// product's "active/selection" blue (#2f7bff / #78adff), so checkboxes, radio +// buttons, raised CTAs, etc. now read as the same blue as rail selection. +// +// Cyan stays on tertiary so the EPG "now" indicator (which uses Material +// tertiary surfaces) keeps its existing visual role. $light-theme: mat.define-theme( ( color: ( theme-type: light, - primary: mat.$violet-palette, + primary: mat.$azure-palette, tertiary: mat.$cyan-palette, ), typography: ( @@ -33,7 +39,7 @@ $dark-theme: mat.define-theme( ( color: ( theme-type: dark, - primary: mat.$violet-palette, + primary: mat.$azure-palette, tertiary: mat.$cyan-palette, ), typography: ( @@ -102,17 +108,17 @@ html { --app-eyebrow-color: #7c8495; // Provider accent colors - --app-provider-xtream: #6366f1; + --app-provider-xtream: #2f7bff; --app-provider-stalker: #10b981; --app-provider-m3u: #d97706; // Hero --app-accent-gradient: linear-gradient( 135deg, - rgba(99, 102, 241, 0.08) 0%, - rgba(168, 85, 247, 0.05) 100% + rgba(47, 123, 255, 0.08) 0%, + rgba(92, 214, 255, 0.05) 100% ); - --app-hero-surface: rgba(99, 102, 241, 0.04); + --app-hero-surface: rgba(47, 123, 255, 0.04); .dark-theme { @include mat.all-component-colors($dark-theme); @@ -178,15 +184,15 @@ html { --app-eyebrow-color: #6b7494; // eyebrow labels // Provider accents (slightly brighter for dark bg) - --app-provider-xtream: #818cf8; + --app-provider-xtream: #78adff; --app-provider-stalker: #34d399; --app-provider-m3u: #fbbf24; // Hero section --app-accent-gradient: linear-gradient( 135deg, - rgba(99, 102, 241, 0.12) 0%, - rgba(168, 85, 247, 0.07) 100% + rgba(120, 173, 255, 0.12) 0%, + rgba(92, 214, 255, 0.07) 100% ); --app-hero-surface: rgba(30, 35, 48, 0.9); } diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html index 25fd20642..e1cb858d0 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html @@ -90,7 +90,7 @@ @case ('url') { - diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.scss b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.scss index 6b4724f7b..05885b7ba 100644 --- a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.scss +++ b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.scss @@ -6,10 +6,12 @@ $bg-hover: #24243a; $border-subtle: rgba(255, 255, 255, 0.06); $border-medium: rgba(255, 255, 255, 0.12); -// Accent colors - TV broadcast vibes -$accent-primary: #00d4aa; // Cyan/teal for time -$accent-secondary: #8b5cf6; // Purple for programs -$accent-tertiary: #f472b6; // Pink for highlights +// Accent colors — unified with the rest of the app: +// cyan is reserved for the EPG "now" indicator, blue is the app primary, +// purple/pink had no semantic role and are dropped per the UX audit. +$accent-primary: #5cd6ff; // Cyan — reserved for "now" indicator +$accent-secondary: #4f8eff; // Blue — programs (matches --app-selection-color) +$accent-tertiary: #4f8eff; // Blue — highlights $accent-warning: #fbbf24; // Text colors @@ -20,17 +22,17 @@ $text-muted: rgba(248, 250, 252, 0.4); // Gradients $gradient-program: linear-gradient( 135deg, - rgba(139, 92, 246, 0.15) 0%, - rgba(139, 92, 246, 0.05) 100% + rgba(79, 142, 255, 0.15) 0%, + rgba(79, 142, 255, 0.05) 100% ); $gradient-program-hover: linear-gradient( 135deg, - rgba(139, 92, 246, 0.3) 0%, - rgba(139, 92, 246, 0.15) 100% + rgba(79, 142, 255, 0.3) 0%, + rgba(79, 142, 255, 0.15) 100% ); $gradient-header: linear-gradient( 180deg, - rgba(0, 212, 170, 0.08) 0%, + rgba(92, 214, 255, 0.08) 0%, transparent 100% ); diff --git a/libs/ui/playback/src/lib/audio-player/audio-player.component.scss b/libs/ui/playback/src/lib/audio-player/audio-player.component.scss index b1618faec..148235b42 100644 --- a/libs/ui/playback/src/lib/audio-player/audio-player.component.scss +++ b/libs/ui/playback/src/lib/audio-player/audio-player.component.scss @@ -155,10 +155,13 @@ background: color-mix(in srgb, var(--mat-sys-surface-container) 60%, transparent); backdrop-filter: blur(6px); + // LIVE stays in the app's red "broadcasting now" role across every + // surface (channel rows, EPG now-bar, badges). The station's extracted + // dominant color is only used as a soft aura on the artwork. &.live { - color: var(--radio-accent, var(--app-selection-color, var(--mat-sys-primary))); - border-color: color-mix(in srgb, var(--radio-accent, var(--app-selection-color)) 52%, transparent); - background: color-mix(in srgb, var(--radio-accent, var(--app-selection-color)) 14%, transparent); + color: var(--app-live-color, #ff8f8f); + border-color: color-mix(in srgb, var(--app-live-color, #ff8f8f) 52%, transparent); + background: color-mix(in srgb, var(--app-live-color, #ff8f8f) 14%, transparent); } } @@ -199,9 +202,11 @@ } } +// Play / Pause uses the unified app primary, not the station's dominant color +// — same blue as every other CTA so radio doesn't feel like a different app. .play-btn { - --mdc-fab-container-color: var(--radio-accent, var(--app-selection-color, var(--mat-sys-primary))); - --mat-fab-container-color: var(--radio-accent, var(--app-selection-color, var(--mat-sys-primary))); + --mdc-fab-container-color: var(--app-selection-color, var(--mat-sys-primary)); + --mat-fab-container-color: var(--app-selection-color, var(--mat-sys-primary)); --mdc-fab-icon-color: #fff; --mat-fab-foreground-color: #fff; --mat-fab-icon-color: #fff; @@ -238,10 +243,10 @@ .vol-slider { flex: 1; - --mdc-slider-active-track-color: var(--radio-accent, var(--app-selection-color, var(--mat-sys-primary))); - --mdc-slider-handle-color: var(--radio-accent, var(--app-selection-color, var(--mat-sys-primary))); - --mat-slider-active-track-color: var(--radio-accent, var(--app-selection-color, var(--mat-sys-primary))); - --mat-slider-handle-color: var(--radio-accent, var(--app-selection-color, var(--mat-sys-primary))); + --mdc-slider-active-track-color: var(--app-selection-color, var(--mat-sys-primary)); + --mdc-slider-handle-color: var(--app-selection-color, var(--mat-sys-primary)); + --mat-slider-active-track-color: var(--app-selection-color, var(--mat-sys-primary)); + --mat-slider-handle-color: var(--app-selection-color, var(--mat-sys-primary)); --mdc-slider-inactive-track-color: var(--mat-sys-outline-variant); --mdc-slider-handle-width: 12px; --mdc-slider-handle-height: 12px; diff --git a/libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts b/libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts index 61f8d1eae..5ee4e0a0e 100644 --- a/libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts +++ b/libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts @@ -37,7 +37,7 @@ import { TranslateModule } from '@ngx-translate/core'; >Don't show anymore - diff --git a/libs/ui/styles/_detail-view-actions.scss b/libs/ui/styles/_detail-view-actions.scss index 867a4cac0..46cfe6e58 100644 --- a/libs/ui/styles/_detail-view-actions.scss +++ b/libs/ui/styles/_detail-view-actions.scss @@ -29,6 +29,10 @@ } } +// Secondary detail-view actions (Favorite, Download). Per UX audit: only Play +// should read as the primary CTA, so these stay ghost/neutral. State accents +// (favorite=red destructive, download=green completed) keep their semantic +// meaning but ride on transparent fills so they never out-shout Play. @mixin secondary-action-button { display: inline-flex; align-items: center; @@ -37,15 +41,16 @@ padding: 0 24px; font-size: 1rem; font-weight: 600; - background: rgba(109, 109, 110, 0.7); - color: #fff; - border: none; + background: rgba(255, 255, 255, 0.08); + color: var(--text-primary, #fff); + border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 16px; cursor: pointer; transition: all 0.2s ease; &:hover { - background: rgba(109, 109, 110, 0.5); + background: rgba(255, 255, 255, 0.14); + border-color: rgba(255, 255, 255, 0.2); } mat-icon { @@ -57,36 +62,37 @@ @mixin favorite-button-state { &--active { - background: rgba(229, 9, 20, 0.8); + background: rgba(255, 91, 110, 0.16); + border-color: rgba(255, 91, 110, 0.4); + color: #ff8f8f; &:hover { - background: rgba(229, 9, 20, 0.6); + background: rgba(255, 91, 110, 0.24); } } } @mixin download-button-state { - background: rgba(33, 150, 243, 0.8); - - &:hover { - background: rgba(33, 150, 243, 0.6); - } + // Idle download stays neutral — extends secondary-action-button. &--completed { - background: rgba(76, 175, 80, 0.8); + background: rgba(74, 222, 128, 0.16); + border-color: rgba(74, 222, 128, 0.4); + color: #4ade80; &:hover { - background: rgba(76, 175, 80, 0.6); + background: rgba(74, 222, 128, 0.24); } } &--downloading { - background: rgba(109, 109, 110, 0.5); + background: rgba(255, 255, 255, 0.04); + border-color: rgba(255, 255, 255, 0.08); cursor: not-allowed; - opacity: 0.7; + opacity: 0.6; &:hover { - background: rgba(109, 109, 110, 0.5); + background: rgba(255, 255, 255, 0.04); } } } diff --git a/libs/ui/styles/_detail-view.scss b/libs/ui/styles/_detail-view.scss index 98b012e2b..e4b248f00 100644 --- a/libs/ui/styles/_detail-view.scss +++ b/libs/ui/styles/_detail-view.scss @@ -24,18 +24,21 @@ --text-primary: #1a1a1a; --text-secondary: rgba(26, 26, 26, 0.75); --text-muted: rgba(26, 26, 26, 0.5); - --accent-color: #b81d24; + // Single primary CTA color — matches --app-selection-color so the + // Play button reads as the same blue used everywhere else for + // "active / call to action". + --accent-color: #2f7bff; --tag-bg: rgba(0, 0, 0, 0.06); --tag-border: rgba(0, 0, 0, 0.12); --play-idle-bg: linear-gradient( 135deg, - #fffaf2 0%, - #ffffff 52%, - #f7ecdf 100% + #2f7bff 0%, + #3b86ff 52%, + #4a90ff 100% ); - --play-idle-fg: #161310; - --play-idle-border: rgba(88, 48, 18, 0.12); - --play-idle-shadow: 0 18px 38px rgba(91, 56, 24, 0.14); + --play-idle-fg: #ffffff; + --play-idle-border: rgba(47, 123, 255, 0.4); + --play-idle-shadow: 0 18px 38px rgba(47, 123, 255, 0.32); --play-launching-bg: linear-gradient( 135deg, #ffd36f 0%, @@ -74,19 +77,19 @@ --text-primary: #fff; --text-secondary: rgba(255, 255, 255, 0.7); --text-muted: rgba(255, 255, 255, 0.5); - --accent-color: #e50914; + --accent-color: #78adff; --tag-bg: rgba(255, 255, 255, 0.1); --tag-border: rgba(255, 255, 255, 0.2); --poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.5); --play-idle-bg: linear-gradient( 135deg, - #fff5e6 0%, - #f6efe5 58%, - #e7dac9 100% + #2f7bff 0%, + #4f8eff 58%, + #78adff 100% ); - --play-idle-fg: #15110d; - --play-idle-border: rgba(255, 255, 255, 0.18); - --play-idle-shadow: 0 20px 42px rgba(0, 0, 0, 0.36); + --play-idle-fg: #ffffff; + --play-idle-border: rgba(120, 173, 255, 0.32); + --play-idle-shadow: 0 20px 42px rgba(47, 123, 255, 0.32); --play-launching-bg: linear-gradient( 135deg, #ffdd79 0%, From a048c18763c73540e31df4775845da8128cb0436 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 19 May 2026 00:18:46 +0200 Subject: [PATCH 02/24] refactor(dashboard): split recently-watched into VOD and live rails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/web/src/assets/i18n/ar.json | 3 + apps/web/src/assets/i18n/ary.json | 3 + apps/web/src/assets/i18n/by.json | 3 + apps/web/src/assets/i18n/de.json | 3 + apps/web/src/assets/i18n/el.json | 3 + apps/web/src/assets/i18n/en.json | 3 + apps/web/src/assets/i18n/es.json | 3 + apps/web/src/assets/i18n/fr.json | 3 + apps/web/src/assets/i18n/it.json | 3 + apps/web/src/assets/i18n/ja.json | 3 + apps/web/src/assets/i18n/ko.json | 3 + apps/web/src/assets/i18n/nl.json | 3 + apps/web/src/assets/i18n/pl.json | 3 + apps/web/src/assets/i18n/pt.json | 3 + apps/web/src/assets/i18n/ru.json | 3 + apps/web/src/assets/i18n/tr.json | 3 + apps/web/src/assets/i18n/zh.json | 3 + apps/web/src/assets/i18n/zhtw.json | 3 + .../src/lib/dashboard-data.service.spec.ts | 69 +++++++++++++++++++ .../src/lib/dashboard-data.service.ts | 13 ++++ .../lib/rails/dashboard-rail.component.html | 17 ++++- .../workspace-dashboard-rails.component.html | 31 +++++++-- .../workspace-dashboard-rails.component.ts | 15 +++- 23 files changed, 187 insertions(+), 12 deletions(-) diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 8a658b05c..d54afbc30 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -823,11 +823,14 @@ "CATEGORY_SORT_ARIA": "ترتيب الفئات", "DASHBOARD": { "CONTINUE_WATCHING": "متابعة المشاهدة", + "LIVE_RECENT": "يُبث الآن على المفضلة لديك", "UNTITLED_SOURCE": "مصدر بدون عنوان", "GLOBAL_FAVORITES": "المفضلة العامة", "REMOVE_FROM_HISTORY": "إزالة من السجل", "RECENT_SOURCES": "المصادر المستخدمة مؤخرًا", "MANAGE_ALL": "إدارة الكل", + "SEE_ALL": "عرض الكل", + "SEE_ALL_COUNT": "عرض الكل ({{count}})", "RECENTLY_WATCHED": "ما تمت مشاهدته مؤخرًا", "XTREAM_RECENTLY_ADDED": "أُضيفت مؤخرًا على Xtream", "NO_HISTORY": "لم يتم العثور على عناصر لهذا الفلتر.", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 3faa6a9ca..115179669 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -823,11 +823,14 @@ "CATEGORY_SORT_ARIA": "ترتيب التصنيفات", "DASHBOARD": { "CONTINUE_WATCHING": "كمل المشاهدة", + "LIVE_RECENT": "مباشر دابا على المفضلة ديالك", "UNTITLED_SOURCE": "مصدر بلا عنوان", "GLOBAL_FAVORITES": "المفضلة العامة", "REMOVE_FROM_HISTORY": "حيد من السجل", "RECENT_SOURCES": "المصادر المستعملة مؤخراً", "MANAGE_ALL": "تدبير الكل", + "SEE_ALL": "شوف الكل", + "SEE_ALL_COUNT": "شوف الكل ({{count}})", "RECENTLY_WATCHED": "متشافين مؤخراً", "XTREAM_RECENTLY_ADDED": "تزادو مؤخراً على Xtream", "NO_HISTORY": "ما تلقاو حتى عناصر لهاد الفلتر.", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index a5d4b725e..1aea919c2 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -823,11 +823,14 @@ "CATEGORY_SORT_ARIA": "Сартаваць катэгорыі", "DASHBOARD": { "CONTINUE_WATCHING": "Працягнуць прагляд", + "LIVE_RECENT": "Цяпер у эфіры на выбраных", "UNTITLED_SOURCE": "Крыніца без назвы", "GLOBAL_FAVORITES": "Усе фаварыты", "REMOVE_FROM_HISTORY": "Выдаліць з гісторыі", "RECENT_SOURCES": "Нядаўна выкарыстаныя крыніцы", "MANAGE_ALL": "Кіраваць усімі", + "SEE_ALL": "Паказаць усё", + "SEE_ALL_COUNT": "Паказаць усё ({{count}})", "RECENTLY_WATCHED": "Нядаўна прагледжаныя", "XTREAM_RECENTLY_ADDED": "Нядаўна дададзеныя на Xtream", "NO_HISTORY": "Не знойдзена элементаў для гэтага фільтра.", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index d6f72b073..f57c06dc9 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -823,11 +823,14 @@ "CATEGORY_SORT_ARIA": "Kategorien sortieren", "DASHBOARD": { "CONTINUE_WATCHING": "Weiter schauen", + "LIVE_RECENT": "Jetzt live auf Ihren Favoriten", "UNTITLED_SOURCE": "Unbenannte Quelle", "GLOBAL_FAVORITES": "Globale Favoriten", "REMOVE_FROM_HISTORY": "Aus Verlauf entfernen", "RECENT_SOURCES": "Zuletzt verwendete Quellen", "MANAGE_ALL": "Alle verwalten", + "SEE_ALL": "Alle anzeigen", + "SEE_ALL_COUNT": "Alle {{count}} anzeigen", "RECENTLY_WATCHED": "Zuletzt angesehen", "XTREAM_RECENTLY_ADDED": "Neu auf Xtream", "NO_HISTORY": "Keine Elemente für diesen Filter gefunden.", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 4e0769554..3cc46be6b 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -823,11 +823,14 @@ "CATEGORY_SORT_ARIA": "Ταξινόμηση κατηγοριών", "DASHBOARD": { "CONTINUE_WATCHING": "Συνέχιση παρακολούθησης", + "LIVE_RECENT": "Ζωντανά τώρα στα αγαπημένα σας", "UNTITLED_SOURCE": "Πηγή χωρίς τίτλο", "GLOBAL_FAVORITES": "Βασικά Αγαπημένα", "REMOVE_FROM_HISTORY": "Αφαίρεση από το ιστορικό", "RECENT_SOURCES": "Πρόσφατα χρησιμοποιημένες πηγές", "MANAGE_ALL": "Διαχείριση όλων", + "SEE_ALL": "Δείτε όλα", + "SEE_ALL_COUNT": "Δείτε όλα {{count}}", "RECENTLY_WATCHED": "Πρόσφατα προβληθέντα", "XTREAM_RECENTLY_ADDED": "Πρόσφατα προστέθηκαν στο Xtream", "NO_HISTORY": "Δεν βρέθηκαν στοιχεία για αυτό το φίλτρο.", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index ee8a4a470..af8b29725 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -823,11 +823,14 @@ "CATEGORY_SORT_ARIA": "Sort categories", "DASHBOARD": { "CONTINUE_WATCHING": "Continue Watching", + "LIVE_RECENT": "Live now on your favorites", "UNTITLED_SOURCE": "Untitled source", "GLOBAL_FAVORITES": "Global Favorites", "REMOVE_FROM_HISTORY": "Remove from history", "RECENT_SOURCES": "Recently Used Sources", "MANAGE_ALL": "Manage all", + "SEE_ALL": "See all", + "SEE_ALL_COUNT": "See all {{count}}", "RECENTLY_WATCHED": "Recently Watched", "XTREAM_RECENTLY_ADDED": "Recently Added on Xtream", "NO_HISTORY": "No items found for this filter.", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 29b095996..0b4e30d8b 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -823,11 +823,14 @@ "CATEGORY_SORT_ARIA": "Ordenar categorías", "DASHBOARD": { "CONTINUE_WATCHING": "Continuar viendo", + "LIVE_RECENT": "Ahora en vivo en tus favoritos", "UNTITLED_SOURCE": "Fuente sin título", "GLOBAL_FAVORITES": "Favoritos globales", "REMOVE_FROM_HISTORY": "Quitar del historial", "RECENT_SOURCES": "Fuentes usadas recientemente", "MANAGE_ALL": "Gestionar todo", + "SEE_ALL": "Ver todo", + "SEE_ALL_COUNT": "Ver todos ({{count}})", "RECENTLY_WATCHED": "Vistos recientemente", "XTREAM_RECENTLY_ADDED": "Añadidos recientemente en Xtream", "NO_HISTORY": "No se encontraron elementos para este filtro.", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 42d3ad995..594ec08d6 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -823,11 +823,14 @@ "CATEGORY_SORT_ARIA": "Trier les catégories", "DASHBOARD": { "CONTINUE_WATCHING": "Reprendre la lecture", + "LIVE_RECENT": "En direct sur vos favoris", "UNTITLED_SOURCE": "Source sans titre", "GLOBAL_FAVORITES": "Favoris globaux", "REMOVE_FROM_HISTORY": "Retirer de l'historique", "RECENT_SOURCES": "Sources récemment utilisées", "MANAGE_ALL": "Tout gérer", + "SEE_ALL": "Voir tout", + "SEE_ALL_COUNT": "Voir les {{count}}", "RECENTLY_WATCHED": "Récemment regardés", "XTREAM_RECENTLY_ADDED": "Récemment ajoutés sur Xtream", "NO_HISTORY": "Aucun élément trouvé pour ce filtre.", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 3d9b4fc9d..4c4b453cc 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -823,11 +823,14 @@ "CATEGORY_SORT_ARIA": "Ordina categorie", "DASHBOARD": { "CONTINUE_WATCHING": "Continua a guardare", + "LIVE_RECENT": "Live ora sui tuoi preferiti", "UNTITLED_SOURCE": "Sorgente senza titolo", "GLOBAL_FAVORITES": "Preferiti globali", "REMOVE_FROM_HISTORY": "Rimuovi dalla cronologia", "RECENT_SOURCES": "Sorgenti usate di recente", "MANAGE_ALL": "Gestisci tutto", + "SEE_ALL": "Vedi tutto", + "SEE_ALL_COUNT": "Vedi tutti ({{count}})", "RECENTLY_WATCHED": "Visti di recente", "XTREAM_RECENTLY_ADDED": "Aggiunti di recente su Xtream", "NO_HISTORY": "Nessun elemento corrisponde a questo filtro.", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index d8c77a915..22376cb2c 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -823,11 +823,14 @@ "CATEGORY_SORT_ARIA": "カテゴリを並び替え", "DASHBOARD": { "CONTINUE_WATCHING": "視聴を再開", + "LIVE_RECENT": "お気に入りで現在放送中", "UNTITLED_SOURCE": "無題のソース", "GLOBAL_FAVORITES": "グローバルお気に入り", "REMOVE_FROM_HISTORY": "履歴から削除", "RECENT_SOURCES": "最近使用したソース", "MANAGE_ALL": "すべて管理", + "SEE_ALL": "すべて表示", + "SEE_ALL_COUNT": "すべて表示 ({{count}})", "RECENTLY_WATCHED": "最近視聴した項目", "XTREAM_RECENTLY_ADDED": "Xtreamに最近追加された項目", "NO_HISTORY": "このフィルターに該当する項目はありません。", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 635855e55..60f45614c 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -823,11 +823,14 @@ "CATEGORY_SORT_ARIA": "카테고리 정렬", "DASHBOARD": { "CONTINUE_WATCHING": "이어서 시청", + "LIVE_RECENT": "즐겨찾기에서 지금 라이브 중인 채널입니다", "UNTITLED_SOURCE": "제목 없는 소스", "GLOBAL_FAVORITES": "전체 즐겨찾기", "REMOVE_FROM_HISTORY": "기록에서 제거", "RECENT_SOURCES": "최근에 사용한 소스", "MANAGE_ALL": "모두 관리", + "SEE_ALL": "모두 보기", + "SEE_ALL_COUNT": "{{count}}개 모두 보기", "RECENTLY_WATCHED": "최근에 시청한 항목", "XTREAM_RECENTLY_ADDED": "Xtream에 최근 추가됨", "NO_HISTORY": "이 필터에 해당하는 항목이 없습니다.", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 40c46f5d4..1459ff7c6 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -823,11 +823,14 @@ "CATEGORY_SORT_ARIA": "Categorieën sorteren", "DASHBOARD": { "CONTINUE_WATCHING": "Verder kijken", + "LIVE_RECENT": "Nu live op je favorieten", "UNTITLED_SOURCE": "Naamloze bron", "GLOBAL_FAVORITES": "Algemene favorieten", "REMOVE_FROM_HISTORY": "Verwijderen uit geschiedenis", "RECENT_SOURCES": "Recent gebruikte bronnen", "MANAGE_ALL": "Alles beheren", + "SEE_ALL": "Alles bekijken", + "SEE_ALL_COUNT": "Alle {{count}} bekijken", "RECENTLY_WATCHED": "Recent bekeken", "XTREAM_RECENTLY_ADDED": "Recent toegevoegd op Xtream", "NO_HISTORY": "Geen items gevonden voor dit filter.", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 1726294b4..255f40a58 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -823,11 +823,14 @@ "CATEGORY_SORT_ARIA": "Sortuj kategorie", "DASHBOARD": { "CONTINUE_WATCHING": "Kontynuuj oglądanie", + "LIVE_RECENT": "Na żywo w Twoich ulubionych", "UNTITLED_SOURCE": "Źródło bez tytułu", "GLOBAL_FAVORITES": "Globalne ulubione", "REMOVE_FROM_HISTORY": "Usuń z historii", "RECENT_SOURCES": "Ostatnio używane źródła", "MANAGE_ALL": "Zarządzaj wszystkimi", + "SEE_ALL": "Zobacz wszystkie", + "SEE_ALL_COUNT": "Zobacz wszystkie ({{count}})", "RECENTLY_WATCHED": "Ostatnio oglądane", "XTREAM_RECENTLY_ADDED": "Ostatnio dodane w Xtream", "NO_HISTORY": "Brak elementów dla tego filtra.", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 79d31dbc2..0a300c541 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -823,11 +823,14 @@ "CATEGORY_SORT_ARIA": "Ordenar categorias", "DASHBOARD": { "CONTINUE_WATCHING": "Continuar assistindo", + "LIVE_RECENT": "Ao vivo agora nos seus favoritos", "UNTITLED_SOURCE": "Fonte sem título", "GLOBAL_FAVORITES": "Favoritos globais", "REMOVE_FROM_HISTORY": "Remover do histórico", "RECENT_SOURCES": "Fontes usadas recentemente", "MANAGE_ALL": "Gerenciar tudo", + "SEE_ALL": "Ver todos", + "SEE_ALL_COUNT": "Ver todos ({{count}})", "RECENTLY_WATCHED": "Assistidos recentemente", "XTREAM_RECENTLY_ADDED": "Adicionados recentemente no Xtream", "NO_HISTORY": "Nenhum item encontrado para este filtro.", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 80dcdaf6c..fea00cf11 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -823,11 +823,14 @@ "CATEGORY_SORT_ARIA": "Сортировать категории", "DASHBOARD": { "CONTINUE_WATCHING": "Продолжить просмотр", + "LIVE_RECENT": "Сейчас в эфире на избранных каналах", "UNTITLED_SOURCE": "Источник без названия", "GLOBAL_FAVORITES": "Глобальные избранные", "REMOVE_FROM_HISTORY": "Удалить из истории", "RECENT_SOURCES": "Недавно использованные источники", "MANAGE_ALL": "Управлять всем", + "SEE_ALL": "Смотреть все", + "SEE_ALL_COUNT": "Смотреть все {{count}}", "RECENTLY_WATCHED": "Недавно просмотренные", "XTREAM_RECENTLY_ADDED": "Недавно добавленные на Xtream", "NO_HISTORY": "Для этого фильтра элементы не найдены.", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 6911a1c88..bfd011a33 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -823,11 +823,14 @@ "CATEGORY_SORT_ARIA": "Kategorileri sırala", "DASHBOARD": { "CONTINUE_WATCHING": "İzlemeye Devam Et", + "LIVE_RECENT": "Favorilerinizde şu an canlı", "UNTITLED_SOURCE": "Adsız kaynak", "GLOBAL_FAVORITES": "Tüm Favoriler", "REMOVE_FROM_HISTORY": "Geçmişten kaldır", "RECENT_SOURCES": "Son Kullanılan Kaynaklar", "MANAGE_ALL": "Tümünü yönet", + "SEE_ALL": "Tümünü gör", + "SEE_ALL_COUNT": "{{count}} öğenin tümünü gör", "RECENTLY_WATCHED": "Son İzlenenler", "XTREAM_RECENTLY_ADDED": "Xtream'de Yeni Eklenenler", "NO_HISTORY": "Bu filtre için öğe bulunamadı.", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 7016ca2eb..51cc0a575 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -823,11 +823,14 @@ "CATEGORY_SORT_ARIA": "排序分类", "DASHBOARD": { "CONTINUE_WATCHING": "继续观看", + "LIVE_RECENT": "收藏频道正在播放", "UNTITLED_SOURCE": "未命名源", "GLOBAL_FAVORITES": "全局收藏", "REMOVE_FROM_HISTORY": "从历史记录中移除", "RECENT_SOURCES": "最近使用的源", "MANAGE_ALL": "管理全部", + "SEE_ALL": "查看全部", + "SEE_ALL_COUNT": "查看全部 {{count}} 个", "RECENTLY_WATCHED": "最近观看", "XTREAM_RECENTLY_ADDED": "Xtream 最近添加", "NO_HISTORY": "没有项目匹配此筛选。", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 4f362b502..ca421de4b 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -823,11 +823,14 @@ "CATEGORY_SORT_ARIA": "排序分類", "DASHBOARD": { "CONTINUE_WATCHING": "繼續觀看", + "LIVE_RECENT": "我的最愛正在直播", "UNTITLED_SOURCE": "未命名的來源", "GLOBAL_FAVORITES": "全域我的最愛", "REMOVE_FROM_HISTORY": "從歷史記錄中移除", "RECENT_SOURCES": "最近使用的來源", "MANAGE_ALL": "管理全部", + "SEE_ALL": "查看全部", + "SEE_ALL_COUNT": "查看全部 {{count}} 個", "RECENTLY_WATCHED": "最近觀看", "XTREAM_RECENTLY_ADDED": "Xtream 最近新增", "NO_HISTORY": "找不到符合此篩選條件的項目。", diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index 350f89d1e..cc01eef00 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -419,6 +419,75 @@ describe('DashboardDataService', () => { ); }); + it('splits global recent items into VOD and live computed signals so the dashboard can render two rails', async () => { + // Seed an Xtream movie and an Xtream series alongside the M3U live + // channel already present in defaultPlaylists, then verify the + // splits land on the right computed signal. + dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([ + { + id: 401, + category_id: 18, + title: 'Recent Movie', + rating: '7.8', + viewed_at: '2026-04-21T10:00:00.000Z', + poster_url: 'https://example.com/recent-movie.png', + xtream_id: 7001, + type: 'movie', + playlist_id: 'xtream-1', + playlist_name: 'Xtream Playlist', + }, + { + id: 402, + category_id: 19, + title: 'Recent Series', + rating: '8.1', + viewed_at: '2026-04-22T10:00:00.000Z', + poster_url: 'https://example.com/recent-series.png', + xtream_id: 7002, + type: 'series', + playlist_id: 'xtream-1', + playlist_name: 'Xtream Playlist', + }, + ]); + + playlistsSignal.set([ + ...playlistsSignal(), + { + _id: 'xtream-1', + title: 'Xtream Playlist', + count: 1, + importDate: '2026-01-01T00:00:00.000Z', + autoRefresh: false, + serverUrl: 'https://example.com', + }, + ]); + + await service.reloadGlobalRecentItems(); + + const vodTitles = service + .globalRecentVodItems() + .map((item) => item.title); + const liveTitles = service + .globalRecentLiveItems() + .map((item) => item.title); + + expect(vodTitles).toEqual( + expect.arrayContaining(['Recent Movie', 'Recent Series']) + ); + expect(vodTitles).not.toContain('Channel One'); + + expect(liveTitles).toContain('Channel One'); + expect(liveTitles).not.toEqual( + expect.arrayContaining(['Recent Movie', 'Recent Series']) + ); + + // Splits stay mutually exclusive — sum of partitions equals the whole. + expect( + service.globalRecentVodItems().length + + service.globalRecentLiveItems().length + ).toBe(service.globalRecentItems().length); + }); + it('prioritizes recently used Xtream sources over newer imported M3U sources', async () => { playlistsSignal.set([ { diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index c06a46ca8..fe351d3ab 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -196,6 +196,19 @@ export class DashboardDataService { .slice(0, 200) ); + // Split the recent list by content type so the dashboard can render + // movies/series ("Continue watching") separately from live channels + // ("Live now on your favorites"). Two card formats — one rail each. + readonly globalRecentVodItems = computed(() => + this.globalRecentItems().filter( + (item) => item.type === 'movie' || item.type === 'series' + ) + ); + + readonly globalRecentLiveItems = computed(() => + this.globalRecentItems().filter((item) => item.type === 'live') + ); + readonly stalkerGlobalFavorites = computed(() => { this.languageTick(); return buildStalkerFavoriteItems( diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html index cf93a9f76..ce0ace52f 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html @@ -14,9 +14,20 @@ [routerLink]="link" [attr.data-test-id]="testId() ? testId() + '-manage-all' : null" > - {{ - 'WORKSPACE.DASHBOARD.MANAGE_ALL' | translate - }} + + @if ((totalCount() ?? items().length) > items().length) { + {{ + 'WORKSPACE.DASHBOARD.SEE_ALL_COUNT' + | translate + : { + count: + totalCount() ?? items().length, + } + }} + } @else { + {{ 'WORKSPACE.DASHBOARD.SEE_ALL' | translate }} + } + chevron_right } diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html index 9555f7741..db4f839ad 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html @@ -102,16 +102,33 @@ } - - @if (recentlyWatchedCards().length > 0) { + + @if (continueWatchingCards().length > 0) { - } @else if (data.globalRecentLoading()) { + } + + + @if (liveOnFavoritesCards().length > 0) { + + } + + @if ( + continueWatchingCards().length === 0 && + liveOnFavoritesCards().length === 0 && + data.globalRecentLoading() + ) { } - - @if (favoriteCards().length > 0) { - - } @else if (data.globalFavoritesLoading()) { - - } + @if (sourceCards().length > 0) { diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index 67baa9f34..ea2419443 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -403,11 +403,43 @@ export class WorkspaceDashboardRailsComponent { .map((item) => this.toRecentCard(item)) ); - readonly liveOnFavoritesCards = computed(() => - this.data + // Live rail source — favorited live channels take precedence so the EPG + // enrichment lands on the channels the user actually cares about. When + // no favorites exist (fresh install, or a user who hasn't starred any + // channel yet), fall back to recently-watched live so the rail still + // has something to show. `liveRailSource` drives the title swap. + readonly liveRailSource = computed<'favorites' | 'recent'>(() => + this.data.globalFavoriteLiveItems().length > 0 + ? 'favorites' + : 'recent' + ); + + readonly liveOnFavoritesCards = computed(() => { + if (this.liveRailSource() === 'favorites') { + return this.data + .globalFavoriteLiveItems() + .slice(0, RAIL_ITEM_LIMIT) + .map((item) => this.toFavoriteCard(item)); + } + return this.data .globalRecentLiveItems() .slice(0, RAIL_ITEM_LIMIT) - .map((item) => this.toRecentCard(item)) + .map((item) => this.toRecentCard(item)); + }); + + // Title key flips with the source — "Live now on your favorites" when + // we're rendering favorites, "Continue with live TV" when we're falling + // back to recently-watched. Always honest about what the user is seeing. + readonly liveRailTitleKey = computed(() => + this.liveRailSource() === 'favorites' + ? 'WORKSPACE.DASHBOARD.LIVE_RECENT' + : 'WORKSPACE.DASHBOARD.LIVE_CONTINUE' + ); + + readonly liveRailTotalCount = computed(() => + this.liveRailSource() === 'favorites' + ? this.data.globalFavoriteLiveItems().length + : this.data.globalRecentLiveItems().length ); // Best-effort EPG lookup keyed by the channel's display name. This works From 210baa2e75b24bf2f7d478ab0effbc72d9380b87 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 19 May 2026 01:09:43 +0200 Subject: [PATCH 06/24] =?UTF-8?q?refactor(add-playlist):=20replace=202-lev?= =?UTF-8?q?el=20type=C3=97subtype=20with=20flat=205-card=20picker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/web/src/assets/i18n/ar.json | 8 + apps/web/src/assets/i18n/ary.json | 8 + apps/web/src/assets/i18n/by.json | 8 + apps/web/src/assets/i18n/de.json | 8 + apps/web/src/assets/i18n/el.json | 8 + apps/web/src/assets/i18n/en.json | 8 + apps/web/src/assets/i18n/es.json | 8 + apps/web/src/assets/i18n/fr.json | 8 + apps/web/src/assets/i18n/it.json | 8 + apps/web/src/assets/i18n/ja.json | 8 + apps/web/src/assets/i18n/ko.json | 8 + apps/web/src/assets/i18n/nl.json | 8 + apps/web/src/assets/i18n/pl.json | 8 + apps/web/src/assets/i18n/pt.json | 8 + apps/web/src/assets/i18n/ru.json | 8 + apps/web/src/assets/i18n/tr.json | 8 + apps/web/src/assets/i18n/zh.json | 8 + apps/web/src/assets/i18n/zhtw.json | 8 + .../add-playlist-dialog.component.html | 60 +++---- .../add-playlist-dialog.component.scss | 162 ++++++++++++++---- .../add-playlist-dialog.component.spec.ts | 61 +++++-- .../add-playlist-dialog.component.ts | 92 +++++----- 22 files changed, 398 insertions(+), 121 deletions(-) diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index c8affaae4..451e8e65b 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -128,6 +128,14 @@ "AUTO_REFRESH_UPDATE_SUCCESS": "نجاح! تم تحديث قوائم التشغيل بنجاح", "AUTO_REFRESH_ENABLED": "تم تفعيل التحديث التلقائي" }, + "ADD_PLAYLIST": { + "PICK_METHOD": "اختر طريقة الاتصال، ويمكنك إعادة تسميتها لاحقًا.", + "METHOD_URL_SUB": "ألصق رابطًا لملف .m3u أو .m3u8 بعيد", + "METHOD_FILE_SUB": "حمّل قائمة تشغيل من جهازك", + "METHOD_XTREAM_SUB": "اتصل باستخدام المضيف واسم المستخدم وكلمة المرور", + "METHOD_STALKER_SUB": "عنوان MAC ورابط البوابة", + "METHOD_TEXT_SUB": "للقوائم العابرة أو عندما يتوفر لديك النص فقط" + }, "FILE_UPLOAD": { "DRAG_DROP": "اسحب وأفلت الملفات هنا", "OR": "أو", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 2f77bec25..3152339a4 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -128,6 +128,14 @@ "AUTO_REFRESH_UPDATE_SUCCESS": "نجاح! قوائم التشغيل تحدثات بنجاح", "AUTO_REFRESH_ENABLED": "التحديث التلقائي مفعل" }, + "ADD_PLAYLIST": { + "PICK_METHOD": "اختار كيفاش بغيتي تربط. تقدر تبدل الاسم من بعد.", + "METHOD_URL_SUB": "لصق رابط ل ملف .m3u ولا .m3u8 بعيد", + "METHOD_FILE_SUB": "زيد قائمة تشغيل من الجهاز ديالك", + "METHOD_XTREAM_SUB": "ربط بالخادم، اسم المستخدم وكلمة المرور", + "METHOD_STALKER_SUB": "عنوان MAC ورابط البوابة", + "METHOD_TEXT_SUB": "للقوائم المؤقتة ولا إلى عندك غير النص" + }, "FILE_UPLOAD": { "DRAG_DROP": "جر وأفلت الملفات هنا", "OR": "أو", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 093ecfdcc..a4881a1ec 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -128,6 +128,14 @@ "AUTO_REFRESH_UPDATE_SUCCESS": "Гатова! Плэйлісты былі паспяхова абноўлены", "AUTO_REFRESH_ENABLED": "Аўтаабнаўленне ўключана" }, + "ADD_PLAYLIST": { + "PICK_METHOD": "Выберыце, як вы хочаце падключыцца. Перайменаваць можна пазней.", + "METHOD_URL_SUB": "Устаўце спасылку на выдалены .m3u або .m3u8", + "METHOD_FILE_SUB": "Загрузіце плэйліст са свайго камп'ютара", + "METHOD_XTREAM_SUB": "Падключэнне праз хост, імя карыстальніка і пароль", + "METHOD_STALKER_SUB": "MAC-адрас і URL партала", + "METHOD_TEXT_SUB": "Для разавых спісаў або калі ў вас ёсць толькі тэкст" + }, "FILE_UPLOAD": { "DRAG_DROP": "Перацягніце файл у гэтую вобласць", "OR": "або", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 35e04c7e9..257aa0d7e 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -128,6 +128,14 @@ "AUTO_REFRESH_UPDATE_SUCCESS": "Erfolg! Die Playlists wurden erfolgreich aktualisiert", "AUTO_REFRESH_ENABLED": "Auto-Refresh aktiviert" }, + "ADD_PLAYLIST": { + "PICK_METHOD": "Wählen Sie aus, wie Sie sich verbinden möchten. Sie können den Namen später ändern.", + "METHOD_URL_SUB": "Fügen Sie einen Link zu einer .m3u- oder .m3u8-Datei ein", + "METHOD_FILE_SUB": "Laden Sie eine Wiedergabeliste von Ihrem Computer hoch", + "METHOD_XTREAM_SUB": "Verbinden mit Host, Benutzername und Passwort", + "METHOD_STALKER_SUB": "MAC-Adresse und Portal-URL", + "METHOD_TEXT_SUB": "Für einmalige Listen oder wenn Sie nur Text haben" + }, "FILE_UPLOAD": { "DRAG_DROP": "Ziehen Sie Dateien per Drag & Drop", "OR": "oder", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 1c37a158d..c0920bff8 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -128,6 +128,14 @@ "AUTO_REFRESH_UPDATE_SUCCESS": "Επιτυχία! Οι λίστες αναπαραγωγής ενημερώθηκαν με επιτυχία", "AUTO_REFRESH_ENABLED": "Η αυτόματη ανανέωση είναι ενεργοποιημένη" }, + "ADD_PLAYLIST": { + "PICK_METHOD": "Επιλέξτε τρόπο σύνδεσης. Μπορείτε να τη μετονομάσετε αργότερα.", + "METHOD_URL_SUB": "Επικολλήστε έναν σύνδεσμο σε απομακρυσμένο .m3u ή .m3u8", + "METHOD_FILE_SUB": "Ανεβάστε μια λίστα αναπαραγωγής από τον υπολογιστή σας", + "METHOD_XTREAM_SUB": "Σύνδεση με διακομιστή, όνομα χρήστη και κωδικό πρόσβασης", + "METHOD_STALKER_SUB": "MAC address και URL πύλης", + "METHOD_TEXT_SUB": "Για λίστες μίας χρήσης ή όταν έχετε μόνο κείμενο" + }, "FILE_UPLOAD": { "DRAG_DROP": "Σύρετε και αποθέστε αρχεία εδώ", "OR": "or", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index d3a739b52..e97319201 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -128,6 +128,14 @@ "AUTO_REFRESH_UPDATE_SUCCESS": "Success! The playlists were successfully updated", "AUTO_REFRESH_ENABLED": "Auto-refresh enabled" }, + "ADD_PLAYLIST": { + "PICK_METHOD": "Pick how you want to connect. You can rename it later.", + "METHOD_URL_SUB": "Paste a link to a remote .m3u or .m3u8", + "METHOD_FILE_SUB": "Upload a playlist from your computer", + "METHOD_XTREAM_SUB": "Connect with host, username and password", + "METHOD_STALKER_SUB": "MAC address and portal URL", + "METHOD_TEXT_SUB": "For one-off lists or when you only have text" + }, "FILE_UPLOAD": { "DRAG_DROP": "Drop an .m3u or .m3u8 file here", "OR": "or", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 1d98ff72d..299aebf11 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -128,6 +128,14 @@ "AUTO_REFRESH_UPDATE_SUCCESS": "¡Listo! Las listas de reproducción se actualizaron correctamente", "AUTO_REFRESH_ENABLED": "Auto-actualización activada" }, + "ADD_PLAYLIST": { + "PICK_METHOD": "Elige cómo quieres conectar. Puedes renombrarla más tarde.", + "METHOD_URL_SUB": "Pega un enlace a un archivo .m3u o .m3u8 remoto", + "METHOD_FILE_SUB": "Sube una lista de reproducción desde tu computadora", + "METHOD_XTREAM_SUB": "Conecta con host, usuario y contraseña", + "METHOD_STALKER_SUB": "Dirección MAC y URL del portal", + "METHOD_TEXT_SUB": "Para listas puntuales o cuando solo tienes texto" + }, "FILE_UPLOAD": { "DRAG_DROP": "Arrastre & suelte archivos aquí", "OR": "o", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 4164c6b39..5cd91e573 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -128,6 +128,14 @@ "AUTO_REFRESH_UPDATE_SUCCESS": "Succès ! Les listes de lecture ont été mises à jour avec succès", "AUTO_REFRESH_ENABLED": "Actualisation automatique activée" }, + "ADD_PLAYLIST": { + "PICK_METHOD": "Choisissez comment vous souhaitez vous connecter. Vous pourrez renommer la liste plus tard.", + "METHOD_URL_SUB": "Collez un lien vers un fichier .m3u ou .m3u8 distant", + "METHOD_FILE_SUB": "Téléversez une liste de lecture depuis votre ordinateur", + "METHOD_XTREAM_SUB": "Connectez-vous avec hôte, nom d'utilisateur et mot de passe", + "METHOD_STALKER_SUB": "Adresse MAC et URL du portail", + "METHOD_TEXT_SUB": "Pour les listes ponctuelles ou quand vous n'avez que du texte" + }, "FILE_UPLOAD": { "DRAG_DROP": "Glissez un fichier ici", "OR": "ou", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 88524018b..c18d2f6d3 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -128,6 +128,14 @@ "AUTO_REFRESH_UPDATE_SUCCESS": "Le playlist sono state aggiornate correttamente", "AUTO_REFRESH_ENABLED": "Aggiornamento automatico attivo" }, + "ADD_PLAYLIST": { + "PICK_METHOD": "Scegli come connetterti. Puoi rinominarla in seguito.", + "METHOD_URL_SUB": "Incolla un link a un file .m3u o .m3u8 remoto", + "METHOD_FILE_SUB": "Carica una playlist dal tuo computer", + "METHOD_XTREAM_SUB": "Connetti con host, nome utente e password", + "METHOD_STALKER_SUB": "Indirizzo MAC e URL del portale", + "METHOD_TEXT_SUB": "Per liste occasionali o quando hai solo il testo" + }, "FILE_UPLOAD": { "DRAG_DROP": "Trascina e rilascia qui i file", "OR": "o", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 46e9283fc..d4220a6dd 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -128,6 +128,14 @@ "AUTO_REFRESH_UPDATE_SUCCESS": "成功!プレイリストが正常に更新されました", "AUTO_REFRESH_ENABLED": "自動更新が有効です" }, + "ADD_PLAYLIST": { + "PICK_METHOD": "接続方法を選択してください。名前は後から変更できます。", + "METHOD_URL_SUB": "リモートの.m3uまたは.m3u8へのリンクを貼り付け", + "METHOD_FILE_SUB": "コンピューターからプレイリストをアップロード", + "METHOD_XTREAM_SUB": "ホスト、ユーザー名、パスワードで接続", + "METHOD_STALKER_SUB": "MACアドレスとポータルURL", + "METHOD_TEXT_SUB": "一度限りのリストやテキストしかない場合に" + }, "FILE_UPLOAD": { "DRAG_DROP": "ここにファイルをドラッグ&ドロップ", "OR": "または", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 7f0858fe4..3b5e8a7b2 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -128,6 +128,14 @@ "AUTO_REFRESH_UPDATE_SUCCESS": "성공! 재생목록이 성공적으로 업데이트되었습니다", "AUTO_REFRESH_ENABLED": "자동 새로 고침 활성화됨" }, + "ADD_PLAYLIST": { + "PICK_METHOD": "연결 방식을 선택하세요. 이름은 나중에 변경할 수 있습니다.", + "METHOD_URL_SUB": "원격 .m3u 또는 .m3u8 링크를 붙여넣으세요", + "METHOD_FILE_SUB": "내 컴퓨터에서 재생목록을 업로드하세요", + "METHOD_XTREAM_SUB": "호스트, 사용자 이름, 비밀번호로 연결하세요", + "METHOD_STALKER_SUB": "MAC 주소와 포털 URL", + "METHOD_TEXT_SUB": "일회성 목록이나 텍스트만 있을 때 사용하세요" + }, "FILE_UPLOAD": { "DRAG_DROP": "파일을 드래그 앤 드롭", "OR": "또는", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 2a086f612..a8e4f6b0d 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -128,6 +128,14 @@ "AUTO_REFRESH_UPDATE_SUCCESS": "Gelukt! De afspeellijsten zijn succesvol bijgewerkt", "AUTO_REFRESH_ENABLED": "Automatisch vernieuwen ingeschakeld" }, + "ADD_PLAYLIST": { + "PICK_METHOD": "Kies hoe je verbinding wilt maken. Je kunt het later hernoemen.", + "METHOD_URL_SUB": "Plak een link naar een externe .m3u of .m3u8", + "METHOD_FILE_SUB": "Upload een afspeellijst vanaf je computer", + "METHOD_XTREAM_SUB": "Maak verbinding met host, gebruikersnaam en wachtwoord", + "METHOD_STALKER_SUB": "MAC-adres en portal-URL", + "METHOD_TEXT_SUB": "Voor eenmalige lijsten of als je alleen tekst hebt" + }, "FILE_UPLOAD": { "DRAG_DROP": "Sleep bestanden hierheen", "OR": "of", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 218b610ae..3b6d4176c 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -128,6 +128,14 @@ "AUTO_REFRESH_UPDATE_SUCCESS": "Sukces! Listy odtwarzania zostały pomyślnie zaktualizowane", "AUTO_REFRESH_ENABLED": "Automatyczne odświeżanie włączone" }, + "ADD_PLAYLIST": { + "PICK_METHOD": "Wybierz sposób połączenia. Nazwę można zmienić później.", + "METHOD_URL_SUB": "Wklej link do zdalnego pliku .m3u lub .m3u8", + "METHOD_FILE_SUB": "Prześlij listę odtwarzania z komputera", + "METHOD_XTREAM_SUB": "Połącz przy użyciu hosta, nazwy użytkownika i hasła", + "METHOD_STALKER_SUB": "Adres MAC i URL portalu", + "METHOD_TEXT_SUB": "Do jednorazowych list lub gdy dostępny jest tylko tekst" + }, "FILE_UPLOAD": { "DRAG_DROP": "Przeciągnij i upuść pliki tutaj", "OR": "lub", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index a8066d7d9..04cbbc308 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -128,6 +128,14 @@ "AUTO_REFRESH_UPDATE_SUCCESS": "Sucesso! As playlists foram atualizadas com sucesso", "AUTO_REFRESH_ENABLED": "Atualização automática ativada" }, + "ADD_PLAYLIST": { + "PICK_METHOD": "Escolha como você quer se conectar. Você pode renomeá-la depois.", + "METHOD_URL_SUB": "Cole um link para um arquivo .m3u ou .m3u8 remoto", + "METHOD_FILE_SUB": "Envie uma playlist do seu computador", + "METHOD_XTREAM_SUB": "Conecte-se com host, nome de usuário e senha", + "METHOD_STALKER_SUB": "Endereço MAC e URL do portal", + "METHOD_TEXT_SUB": "Para listas pontuais ou quando você só tem o texto" + }, "FILE_UPLOAD": { "DRAG_DROP": "Arraste e solte os arquivos aqui", "OR": "ou", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 301da391e..e32f8a2fc 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -128,6 +128,14 @@ "AUTO_REFRESH_UPDATE_SUCCESS": "Успешно! Плейлисты были успешно обновлены", "AUTO_REFRESH_ENABLED": "Автообновление включено" }, + "ADD_PLAYLIST": { + "PICK_METHOD": "Выберите способ подключения. Переименовать плейлист можно позже.", + "METHOD_URL_SUB": "Вставьте ссылку на удалённый файл .m3u или .m3u8", + "METHOD_FILE_SUB": "Загрузите плейлист с вашего компьютера", + "METHOD_XTREAM_SUB": "Подключение через сервер, имя пользователя и пароль", + "METHOD_STALKER_SUB": "MAC-адрес и URL портала", + "METHOD_TEXT_SUB": "Для разовых плейлистов или когда есть только текст" + }, "FILE_UPLOAD": { "DRAG_DROP": "Перетащите файл в эту область", "OR": "или", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index db31ba810..993229767 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -128,6 +128,14 @@ "AUTO_REFRESH_UPDATE_SUCCESS": "Başarılı! Oynatma listeleri başarıyla güncellendi", "AUTO_REFRESH_ENABLED": "Otomatik yenileme etkin" }, + "ADD_PLAYLIST": { + "PICK_METHOD": "Nasıl bağlanmak istediğinizi seçin. Daha sonra yeniden adlandırabilirsiniz.", + "METHOD_URL_SUB": "Uzak bir .m3u veya .m3u8 bağlantısını yapıştırın", + "METHOD_FILE_SUB": "Bilgisayarınızdan bir oynatma listesi yükleyin", + "METHOD_XTREAM_SUB": "Sunucu, kullanıcı adı ve parola ile bağlanın", + "METHOD_STALKER_SUB": "MAC adresi ve portal URL'si", + "METHOD_TEXT_SUB": "Tek seferlik listeler veya yalnızca metniniz olduğunda" + }, "FILE_UPLOAD": { "DRAG_DROP": "Dosyaları buraya sürükleyip bırakın", "OR": "veya", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 9d9f612fc..66e2fd089 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -128,6 +128,14 @@ "AUTO_REFRESH_UPDATE_SUCCESS": "成功!播放列表已成功更新", "AUTO_REFRESH_ENABLED": "已启用自动刷新" }, + "ADD_PLAYLIST": { + "PICK_METHOD": "选择您想要的连接方式。稍后可以重命名。", + "METHOD_URL_SUB": "粘贴远程 .m3u 或 .m3u8 的链接", + "METHOD_FILE_SUB": "从您的电脑上传播放列表", + "METHOD_XTREAM_SUB": "使用主机、用户名和密码连接", + "METHOD_STALKER_SUB": "MAC 地址和门户 URL", + "METHOD_TEXT_SUB": "用于一次性列表,或仅有文本时" + }, "FILE_UPLOAD": { "DRAG_DROP": "将文件拖放到此处", "OR": "或", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 971cb0b2e..4b554a0ce 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -128,6 +128,14 @@ "AUTO_REFRESH_UPDATE_SUCCESS": "成功!播放清單已成功更新", "AUTO_REFRESH_ENABLED": "已啟用自動重新整理" }, + "ADD_PLAYLIST": { + "PICK_METHOD": "請選擇您要使用的連線方式,之後仍可重新命名。", + "METHOD_URL_SUB": "貼上遠端 .m3u 或 .m3u8 的連結", + "METHOD_FILE_SUB": "從您的電腦上傳播放清單", + "METHOD_XTREAM_SUB": "使用主機位址、帳號和密碼連線", + "METHOD_STALKER_SUB": "MAC 位址與入口網址", + "METHOD_TEXT_SUB": "適合一次性清單,或您只有文字內容時使用" + }, "FILE_UPLOAD": { "DRAG_DROP": "在此處拖放檔案", "OR": "或者", diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html index e1cb858d0..c78c7c66f 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html @@ -1,49 +1,41 @@
-

- {{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }} -

+
+

+ {{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }} +

+

+ {{ 'HOME.ADD_PLAYLIST.PICK_METHOD' | translate }} +

+
- @for (opt of categoryOptions; track opt.value) { + @for (opt of methodOptions; track opt.value) { }
- @if (category() === 'm3u') { -
- @for (opt of subtypeOptions; track opt.value) { - - } -
- } -
@switch (playlistType()) { @case ('file') { diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.scss b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.scss index a2ba853f4..3662ac6e7 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.scss +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.scss @@ -17,53 +17,92 @@ ); } -// ─── Segmented control ────────────────────────────────────────────────────── -.apd-segmented { - display: grid; - grid-auto-flow: column; - grid-auto-columns: 1fr; - background: var(--apd-segment-track); - border-radius: 12px; - padding: 4px; - gap: 2px; - margin-bottom: 14px; - - &--sub { - background: color-mix( - in srgb, - var(--apd-segment-track) 85%, - transparent - ); - } +// ─── Dialog head (title + helper subtitle) ────────────────────────────────── +.apd-head { + display: flex; + flex-direction: column; + gap: 4px; + margin: 0 0 8px; + padding: 0; } -.apd-segment { - appearance: none; - border: 0; - background: transparent; - color: var(--apd-body); - padding: 8px 14px; - border-radius: 9px; - font: inherit; +.apd-title { + margin: 0; + font-size: 20px; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--apd-heading); +} + +.apd-sub { + margin: 0; font-size: 13px; - font-weight: 500; + color: var(--apd-body); + line-height: 1.45; +} + +// ─── Method grid (5 flat cards instead of 2-level segmented control) ──────── +// One card per source method. Replaces the prior 3×3 type×subtype matrix +// which had 9 cells of which only 5 were real (Xtream-via-file, etc. didn't +// exist). Each card is self-describing — icon + label + one-line sub. +.apd-method-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; + margin: 4px 0 16px; +} + +.apd-method { + appearance: none; + background: var(--apd-segment-track); + border: 1px solid var(--apd-border); + border-radius: 10px; + padding: 12px 12px 14px; + display: flex; + flex-direction: column; + gap: 6px; cursor: pointer; - line-height: 1.3; + text-align: left; + font: inherit; + color: var(--apd-heading); + min-width: 0; transition: background-color 160ms ease, - color 160ms ease, + border-color 160ms ease, box-shadow 160ms ease; &:hover:not(.active) { - color: var(--apd-heading); + background: var(--apd-segment-active); + border-color: color-mix(in srgb, var(--apd-accent) 32%, transparent); + + .apd-method__icon { + background: color-mix( + in srgb, + var(--apd-accent) 14%, + transparent + ); + color: var(--apd-accent); + } } &.active { - background: var(--apd-segment-active); - color: var(--apd-heading); - box-shadow: - 0 1px 2px rgba(0, 0, 0, 0.25), - inset 0 0 0 1px color-mix(in srgb, white 6%, transparent); + background: color-mix(in srgb, var(--apd-accent) 12%, transparent); + border-color: var(--apd-accent); + box-shadow: 0 1px 2px + color-mix(in srgb, var(--apd-accent) 32%, transparent); + + .apd-method__icon { + background: color-mix( + in srgb, + var(--apd-accent) 22%, + transparent + ); + color: var(--apd-accent); + } + + .apd-method__title { + color: var(--apd-accent); + } } &:focus-visible { @@ -72,6 +111,59 @@ } } +.apd-method__icon { + width: 32px; + height: 32px; + display: grid; + place-items: center; + border-radius: 8px; + background: color-mix( + in srgb, + var(--apd-surface-raised) 75%, + transparent + ); + color: var(--apd-muted); + transition: + background-color 160ms ease, + color 160ms ease; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + line-height: 18px; + } +} + +.apd-method__title { + font-size: 13px; + font-weight: 600; + line-height: 1.25; + color: var(--apd-heading); + transition: color 160ms ease; +} + +.apd-method__sub { + font-size: 11.5px; + color: var(--apd-muted); + line-height: 1.4; +} + +// On narrow widths the 5-column grid wraps to 2 columns to stay legible. +// 480px keeps the dialog usable down to small modals; below that it stacks +// fully. +@media (max-width: 720px) { + .apd-method-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 480px) { + .apd-method-grid { + grid-template-columns: minmax(0, 1fr); + } +} + // ─── Form area ────────────────────────────────────────────────────────────── .apd-form-area { min-height: 0; diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.spec.ts b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.spec.ts index 35d82a259..ff90595af 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.spec.ts +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.spec.ts @@ -178,18 +178,55 @@ describe('AddPlaylistDialogComponent', () => { expect(component.isClearDisabled()).toBeTruthy(); }); + it('defaults to the URL method when no deep-link type is provided', () => { + // Constructor-injected MAT_DIALOG_DATA is null in this spec setup, + // so the dialog should land on its default first-row method. + expect(component.method()).toBe('url'); + expect(component.playlistType()).toBe('url'); + }); + + it.each([ + ['url' as PlaylistType, 'url' as PlaylistType], + ['file' as PlaylistType, 'file' as PlaylistType], + ['text' as PlaylistType, 'text' as PlaylistType], + ['xtream' as PlaylistType, 'xtream' as PlaylistType], + ['stalker' as PlaylistType, 'stalker' as PlaylistType], + ])( + 'opens directly on the %s method when MAT_DIALOG_DATA.type === %s (deep-link from rail / shell action)', + (deepLinkType, expectedMethod) => { + // Rebuild the component with a deep-link to confirm the + // flat-method signal honours the legacy { type } payload shape. + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + { provide: DataService, useValue: dataService }, + { provide: MatDialogRef, useValue: dialogRef }, + { provide: Store, useValue: store }, + { provide: MatSnackBar, useValue: { open: jest.fn() } }, + { + provide: TranslateService, + useValue: { + instant: jest.fn((value: string) => value), + }, + }, + { + provide: MAT_DIALOG_DATA, + useValue: { type: deepLinkType }, + }, + ], + }); + const deepLinked = TestBed.runInInjectionContext( + () => new AddPlaylistDialogComponent() + ); + + expect(deepLinked.method()).toBe(expectedMethod); + expect(deepLinked.playlistType()).toBe(expectedMethod); + } + ); + function selectType(type: PlaylistType): void { - if (type === 'xtream') { - component.category.set('xtream'); - return; - } - - if (type === 'stalker') { - component.category.set('stalker'); - return; - } - - component.category.set('m3u'); - component.m3uSubType.set(type); + // The dialog now uses a single `method` signal across all 5 source + // types — no more category × subtype matrix. + component.method.set(type); } }); diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts index 1a6202b8e..fa6d86330 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts @@ -12,6 +12,7 @@ import { MatDialogModule, MatDialogRef, } from '@angular/material/dialog'; +import { MatIcon } from '@angular/material/icon'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Store } from '@ngrx/store'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; @@ -25,17 +26,16 @@ import { TextImportComponent } from '../text-import/text-import.component'; import { UrlUploadComponent } from '../url-upload/url-upload.component'; import { XtreamCodeImportComponent } from '../xtream-code-import/xtream-code-import.component'; -type PlaylistCategory = 'm3u' | Extract; -type M3uSubType = Extract; - -interface CategoryOption { - value: PlaylistCategory; - label: string; -} - -interface SubtypeOption { - value: M3uSubType; +/** + * Flat 5-method option model — replaces the prior category × subtype matrix + * (M3U/Xtream/Stalker × URL/File/Text) which created 9 combinations of which + * only 5 were real. Now each entry IS a method, no nesting. + */ +export interface PlaylistMethodOption { + value: PlaylistType; + icon: string; labelKey: string; + subKey: string; } @Component({ @@ -43,6 +43,7 @@ interface SubtypeOption { FileUploadComponent, MatButtonModule, MatDialogModule, + MatIcon, StalkerPortalImportComponent, TextImportComponent, TranslateModule, @@ -70,42 +71,53 @@ export class AddPlaylistDialogComponent { readonly xtreamImport = viewChild(XtreamCodeImportComponent); readonly stalkerImport = viewChild(StalkerPortalImportComponent); - readonly category = signal('m3u'); - readonly m3uSubType = signal('url'); + readonly method = signal('url'); - readonly categoryOptions: CategoryOption[] = [ - { value: 'm3u', label: 'M3U' }, - { value: 'xtream', label: 'Xtream' }, - { value: 'stalker', label: 'Stalker' }, + // Order matches the v0.22 mockup left-to-right: URL first (Most common), + // then File, Xtream credentials, Stalker portal, raw text paste. Each + // entry stands on its own — no nested subtypes. + readonly methodOptions: PlaylistMethodOption[] = [ + { + value: 'url', + icon: 'public', + labelKey: 'HOME.TABS.URL_UPLOAD', + subKey: 'HOME.ADD_PLAYLIST.METHOD_URL_SUB', + }, + { + value: 'file', + icon: 'folder_open', + labelKey: 'HOME.TABS.FILE_UPLOAD', + subKey: 'HOME.ADD_PLAYLIST.METHOD_FILE_SUB', + }, + { + value: 'xtream', + icon: 'vpn_key', + labelKey: 'HOME.TABS.XTREME_IMPORT', + subKey: 'HOME.ADD_PLAYLIST.METHOD_XTREAM_SUB', + }, + { + value: 'stalker', + icon: 'cast', + labelKey: 'HOME.TABS.STALKER_PORTAL_IMPORT', + subKey: 'HOME.ADD_PLAYLIST.METHOD_STALKER_SUB', + }, + { + value: 'text', + icon: 'subject', + labelKey: 'HOME.TABS.TEXT_IMPORT', + subKey: 'HOME.ADD_PLAYLIST.METHOD_TEXT_SUB', + }, ]; - readonly subtypeOptions: SubtypeOption[] = [ - { value: 'url', labelKey: 'HOME.TABS.URL_UPLOAD' }, - { value: 'file', labelKey: 'HOME.TABS.FILE_UPLOAD' }, - { value: 'text', labelKey: 'HOME.TABS.TEXT_IMPORT' }, - ]; - - readonly playlistType = computed(() => { - const cat = this.category(); - if (cat === 'xtream') return 'xtream'; - if (cat === 'stalker') return 'stalker'; - return this.m3uSubType(); - }); + /** + * Backwards-compatible alias. The template's @switch and the action + * buttons key off this; keeping the name avoids churn in 5 case branches. + */ + readonly playlistType = computed(() => this.method()); constructor() { if (this.data?.type) { - this.initFromType(this.data.type); - } - } - - private initFromType(type: PlaylistType): void { - if (type === 'xtream') { - this.category.set('xtream'); - } else if (type === 'stalker') { - this.category.set('stalker'); - } else { - this.category.set('m3u'); - this.m3uSubType.set(type as M3uSubType); + this.method.set(this.data.type); } } From 810d9170f0672809ef24d1a726fced054eb88c0e Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 19 May 2026 01:15:18 +0200 Subject: [PATCH 07/24] fix(add-playlist): widen dialog and use card-friendly labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../services/workspace-shell-actions.service.ts | 7 ++++++- apps/web/src/assets/i18n/ar.json | 5 +++++ apps/web/src/assets/i18n/ary.json | 5 +++++ apps/web/src/assets/i18n/by.json | 5 +++++ apps/web/src/assets/i18n/de.json | 5 +++++ apps/web/src/assets/i18n/el.json | 5 +++++ apps/web/src/assets/i18n/en.json | 5 +++++ apps/web/src/assets/i18n/es.json | 5 +++++ apps/web/src/assets/i18n/fr.json | 5 +++++ apps/web/src/assets/i18n/it.json | 5 +++++ apps/web/src/assets/i18n/ja.json | 5 +++++ apps/web/src/assets/i18n/ko.json | 5 +++++ apps/web/src/assets/i18n/nl.json | 5 +++++ apps/web/src/assets/i18n/pl.json | 5 +++++ apps/web/src/assets/i18n/pt.json | 5 +++++ apps/web/src/assets/i18n/ru.json | 5 +++++ apps/web/src/assets/i18n/tr.json | 5 +++++ apps/web/src/assets/i18n/zh.json | 5 +++++ apps/web/src/assets/i18n/zhtw.json | 5 +++++ .../add-playlist-dialog.component.scss | 9 +++++++-- .../add-playlist-dialog.component.ts | 15 +++++++++------ 21 files changed, 112 insertions(+), 9 deletions(-) diff --git a/apps/web/src/app/services/workspace-shell-actions.service.ts b/apps/web/src/app/services/workspace-shell-actions.service.ts index bf379c6ea..47d3ed7a1 100644 --- a/apps/web/src/app/services/workspace-shell-actions.service.ts +++ b/apps/web/src/app/services/workspace-shell-actions.service.ts @@ -18,7 +18,12 @@ export class AppWorkspaceShellActionsService implements WorkspaceShellActions { void import('@iptvnator/playlist/import/feature').then( ({ AddPlaylistDialogComponent }) => { this.dialog.open(AddPlaylistDialogComponent, { - width: '560px', + // Width sized for the 5-card method picker plus the + // selected method's form below. Matches the v0.22 + // mockup; falls back to viewport-clamped width on + // narrow screens so the grid can collapse via the + // SCSS responsive breakpoints. + width: '780px', maxWidth: '92vw', data: type ? { type } : {}, }); diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 451e8e65b..6fc137424 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -130,10 +130,15 @@ }, "ADD_PLAYLIST": { "PICK_METHOD": "اختر طريقة الاتصال، ويمكنك إعادة تسميتها لاحقًا.", + "METHOD_URL_LABEL": "رابط M3U", "METHOD_URL_SUB": "ألصق رابطًا لملف .m3u أو .m3u8 بعيد", + "METHOD_FILE_LABEL": "ملف M3U", "METHOD_FILE_SUB": "حمّل قائمة تشغيل من جهازك", + "METHOD_XTREAM_LABEL": "بيانات اعتماد Xtream", "METHOD_XTREAM_SUB": "اتصل باستخدام المضيف واسم المستخدم وكلمة المرور", + "METHOD_STALKER_LABEL": "بوابة Stalker", "METHOD_STALKER_SUB": "عنوان MAC ورابط البوابة", + "METHOD_TEXT_LABEL": "نص m3u الخام", "METHOD_TEXT_SUB": "للقوائم العابرة أو عندما يتوفر لديك النص فقط" }, "FILE_UPLOAD": { diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 3152339a4..b5a8c3cf0 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -130,10 +130,15 @@ }, "ADD_PLAYLIST": { "PICK_METHOD": "اختار كيفاش بغيتي تربط. تقدر تبدل الاسم من بعد.", + "METHOD_URL_LABEL": "رابط M3U", "METHOD_URL_SUB": "لصق رابط ل ملف .m3u ولا .m3u8 بعيد", + "METHOD_FILE_LABEL": "ملف M3U", "METHOD_FILE_SUB": "زيد قائمة تشغيل من الجهاز ديالك", + "METHOD_XTREAM_LABEL": "بيانات Xtream", "METHOD_XTREAM_SUB": "ربط بالخادم، اسم المستخدم وكلمة المرور", + "METHOD_STALKER_LABEL": "بوابة Stalker", "METHOD_STALKER_SUB": "عنوان MAC ورابط البوابة", + "METHOD_TEXT_LABEL": "نص m3u خام", "METHOD_TEXT_SUB": "للقوائم المؤقتة ولا إلى عندك غير النص" }, "FILE_UPLOAD": { diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index a4881a1ec..b06d6758a 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -130,10 +130,15 @@ }, "ADD_PLAYLIST": { "PICK_METHOD": "Выберыце, як вы хочаце падключыцца. Перайменаваць можна пазней.", + "METHOD_URL_LABEL": "M3U URL", "METHOD_URL_SUB": "Устаўце спасылку на выдалены .m3u або .m3u8", + "METHOD_FILE_LABEL": "M3U-файл", "METHOD_FILE_SUB": "Загрузіце плэйліст са свайго камп'ютара", + "METHOD_XTREAM_LABEL": "Xtream уліковыя даныя", "METHOD_XTREAM_SUB": "Падключэнне праз хост, імя карыстальніка і пароль", + "METHOD_STALKER_LABEL": "Stalker партал", "METHOD_STALKER_SUB": "MAC-адрас і URL партала", + "METHOD_TEXT_LABEL": "Тэкст M3U", "METHOD_TEXT_SUB": "Для разавых спісаў або калі ў вас ёсць толькі тэкст" }, "FILE_UPLOAD": { diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 257aa0d7e..885152535 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -130,10 +130,15 @@ }, "ADD_PLAYLIST": { "PICK_METHOD": "Wählen Sie aus, wie Sie sich verbinden möchten. Sie können den Namen später ändern.", + "METHOD_URL_LABEL": "M3U-URL", "METHOD_URL_SUB": "Fügen Sie einen Link zu einer .m3u- oder .m3u8-Datei ein", + "METHOD_FILE_LABEL": "M3U-Datei", "METHOD_FILE_SUB": "Laden Sie eine Wiedergabeliste von Ihrem Computer hoch", + "METHOD_XTREAM_LABEL": "Xtream-Zugangsdaten", "METHOD_XTREAM_SUB": "Verbinden mit Host, Benutzername und Passwort", + "METHOD_STALKER_LABEL": "Stalker-Portal", "METHOD_STALKER_SUB": "MAC-Adresse und Portal-URL", + "METHOD_TEXT_LABEL": "M3U-Text", "METHOD_TEXT_SUB": "Für einmalige Listen oder wenn Sie nur Text haben" }, "FILE_UPLOAD": { diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index c0920bff8..58afa845a 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -130,10 +130,15 @@ }, "ADD_PLAYLIST": { "PICK_METHOD": "Επιλέξτε τρόπο σύνδεσης. Μπορείτε να τη μετονομάσετε αργότερα.", + "METHOD_URL_LABEL": "M3U URL", "METHOD_URL_SUB": "Επικολλήστε έναν σύνδεσμο σε απομακρυσμένο .m3u ή .m3u8", + "METHOD_FILE_LABEL": "Αρχείο M3U", "METHOD_FILE_SUB": "Ανεβάστε μια λίστα αναπαραγωγής από τον υπολογιστή σας", + "METHOD_XTREAM_LABEL": "Διαπιστευτήρια Xtream", "METHOD_XTREAM_SUB": "Σύνδεση με διακομιστή, όνομα χρήστη και κωδικό πρόσβασης", + "METHOD_STALKER_LABEL": "Πύλη Stalker", "METHOD_STALKER_SUB": "MAC address και URL πύλης", + "METHOD_TEXT_LABEL": "Ακατέργαστο κείμενο m3u", "METHOD_TEXT_SUB": "Για λίστες μίας χρήσης ή όταν έχετε μόνο κείμενο" }, "FILE_UPLOAD": { diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index e97319201..7f4b17947 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -130,10 +130,15 @@ }, "ADD_PLAYLIST": { "PICK_METHOD": "Pick how you want to connect. You can rename it later.", + "METHOD_URL_LABEL": "M3U URL", "METHOD_URL_SUB": "Paste a link to a remote .m3u or .m3u8", + "METHOD_FILE_LABEL": "M3U file", "METHOD_FILE_SUB": "Upload a playlist from your computer", + "METHOD_XTREAM_LABEL": "Xtream credentials", "METHOD_XTREAM_SUB": "Connect with host, username and password", + "METHOD_STALKER_LABEL": "Stalker portal", "METHOD_STALKER_SUB": "MAC address and portal URL", + "METHOD_TEXT_LABEL": "Raw m3u text", "METHOD_TEXT_SUB": "For one-off lists or when you only have text" }, "FILE_UPLOAD": { diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 299aebf11..c43bd4d08 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -130,10 +130,15 @@ }, "ADD_PLAYLIST": { "PICK_METHOD": "Elige cómo quieres conectar. Puedes renombrarla más tarde.", + "METHOD_URL_LABEL": "URL de M3U", "METHOD_URL_SUB": "Pega un enlace a un archivo .m3u o .m3u8 remoto", + "METHOD_FILE_LABEL": "Archivo M3U", "METHOD_FILE_SUB": "Sube una lista de reproducción desde tu computadora", + "METHOD_XTREAM_LABEL": "Credenciales de Xtream", "METHOD_XTREAM_SUB": "Conecta con host, usuario y contraseña", + "METHOD_STALKER_LABEL": "Portal Stalker", "METHOD_STALKER_SUB": "Dirección MAC y URL del portal", + "METHOD_TEXT_LABEL": "Texto m3u sin formato", "METHOD_TEXT_SUB": "Para listas puntuales o cuando solo tienes texto" }, "FILE_UPLOAD": { diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 5cd91e573..6b573bf0e 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -130,10 +130,15 @@ }, "ADD_PLAYLIST": { "PICK_METHOD": "Choisissez comment vous souhaitez vous connecter. Vous pourrez renommer la liste plus tard.", + "METHOD_URL_LABEL": "URL M3U", "METHOD_URL_SUB": "Collez un lien vers un fichier .m3u ou .m3u8 distant", + "METHOD_FILE_LABEL": "Fichier M3U", "METHOD_FILE_SUB": "Téléversez une liste de lecture depuis votre ordinateur", + "METHOD_XTREAM_LABEL": "Identifiants Xtream", "METHOD_XTREAM_SUB": "Connectez-vous avec hôte, nom d'utilisateur et mot de passe", + "METHOD_STALKER_LABEL": "Portail Stalker", "METHOD_STALKER_SUB": "Adresse MAC et URL du portail", + "METHOD_TEXT_LABEL": "Texte m3u brut", "METHOD_TEXT_SUB": "Pour les listes ponctuelles ou quand vous n'avez que du texte" }, "FILE_UPLOAD": { diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index c18d2f6d3..fbefedf2a 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -130,10 +130,15 @@ }, "ADD_PLAYLIST": { "PICK_METHOD": "Scegli come connetterti. Puoi rinominarla in seguito.", + "METHOD_URL_LABEL": "URL M3U", "METHOD_URL_SUB": "Incolla un link a un file .m3u o .m3u8 remoto", + "METHOD_FILE_LABEL": "File M3U", "METHOD_FILE_SUB": "Carica una playlist dal tuo computer", + "METHOD_XTREAM_LABEL": "Credenziali Xtream", "METHOD_XTREAM_SUB": "Connetti con host, nome utente e password", + "METHOD_STALKER_LABEL": "Portale Stalker", "METHOD_STALKER_SUB": "Indirizzo MAC e URL del portale", + "METHOD_TEXT_LABEL": "Testo m3u grezzo", "METHOD_TEXT_SUB": "Per liste occasionali o quando hai solo il testo" }, "FILE_UPLOAD": { diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index d4220a6dd..8159150ef 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -130,10 +130,15 @@ }, "ADD_PLAYLIST": { "PICK_METHOD": "接続方法を選択してください。名前は後から変更できます。", + "METHOD_URL_LABEL": "M3U URL", "METHOD_URL_SUB": "リモートの.m3uまたは.m3u8へのリンクを貼り付け", + "METHOD_FILE_LABEL": "M3Uファイル", "METHOD_FILE_SUB": "コンピューターからプレイリストをアップロード", + "METHOD_XTREAM_LABEL": "Xtream認証情報", "METHOD_XTREAM_SUB": "ホスト、ユーザー名、パスワードで接続", + "METHOD_STALKER_LABEL": "Stalkerポータル", "METHOD_STALKER_SUB": "MACアドレスとポータルURL", + "METHOD_TEXT_LABEL": "M3Uテキスト", "METHOD_TEXT_SUB": "一度限りのリストやテキストしかない場合に" }, "FILE_UPLOAD": { diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 3b5e8a7b2..49fa7ac26 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -130,10 +130,15 @@ }, "ADD_PLAYLIST": { "PICK_METHOD": "연결 방식을 선택하세요. 이름은 나중에 변경할 수 있습니다.", + "METHOD_URL_LABEL": "M3U URL", "METHOD_URL_SUB": "원격 .m3u 또는 .m3u8 링크를 붙여넣으세요", + "METHOD_FILE_LABEL": "M3U 파일", "METHOD_FILE_SUB": "내 컴퓨터에서 재생목록을 업로드하세요", + "METHOD_XTREAM_LABEL": "Xtream 자격 증명", "METHOD_XTREAM_SUB": "호스트, 사용자 이름, 비밀번호로 연결하세요", + "METHOD_STALKER_LABEL": "Stalker 포털", "METHOD_STALKER_SUB": "MAC 주소와 포털 URL", + "METHOD_TEXT_LABEL": "원본 m3u 텍스트", "METHOD_TEXT_SUB": "일회성 목록이나 텍스트만 있을 때 사용하세요" }, "FILE_UPLOAD": { diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index a8e4f6b0d..d5e31f633 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -130,10 +130,15 @@ }, "ADD_PLAYLIST": { "PICK_METHOD": "Kies hoe je verbinding wilt maken. Je kunt het later hernoemen.", + "METHOD_URL_LABEL": "M3U-URL", "METHOD_URL_SUB": "Plak een link naar een externe .m3u of .m3u8", + "METHOD_FILE_LABEL": "M3U-bestand", "METHOD_FILE_SUB": "Upload een afspeellijst vanaf je computer", + "METHOD_XTREAM_LABEL": "Xtream-inloggegevens", "METHOD_XTREAM_SUB": "Maak verbinding met host, gebruikersnaam en wachtwoord", + "METHOD_STALKER_LABEL": "Stalker-portaal", "METHOD_STALKER_SUB": "MAC-adres en portal-URL", + "METHOD_TEXT_LABEL": "Ruwe m3u-tekst", "METHOD_TEXT_SUB": "Voor eenmalige lijsten of als je alleen tekst hebt" }, "FILE_UPLOAD": { diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 3b6d4176c..d9067313d 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -130,10 +130,15 @@ }, "ADD_PLAYLIST": { "PICK_METHOD": "Wybierz sposób połączenia. Nazwę można zmienić później.", + "METHOD_URL_LABEL": "Adres URL M3U", "METHOD_URL_SUB": "Wklej link do zdalnego pliku .m3u lub .m3u8", + "METHOD_FILE_LABEL": "Plik M3U", "METHOD_FILE_SUB": "Prześlij listę odtwarzania z komputera", + "METHOD_XTREAM_LABEL": "Dane logowania Xtream", "METHOD_XTREAM_SUB": "Połącz przy użyciu hosta, nazwy użytkownika i hasła", + "METHOD_STALKER_LABEL": "Portal Stalker", "METHOD_STALKER_SUB": "Adres MAC i URL portalu", + "METHOD_TEXT_LABEL": "Surowy tekst m3u", "METHOD_TEXT_SUB": "Do jednorazowych list lub gdy dostępny jest tylko tekst" }, "FILE_UPLOAD": { diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 04cbbc308..415663cfb 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -130,10 +130,15 @@ }, "ADD_PLAYLIST": { "PICK_METHOD": "Escolha como você quer se conectar. Você pode renomeá-la depois.", + "METHOD_URL_LABEL": "URL M3U", "METHOD_URL_SUB": "Cole um link para um arquivo .m3u ou .m3u8 remoto", + "METHOD_FILE_LABEL": "Arquivo M3U", "METHOD_FILE_SUB": "Envie uma playlist do seu computador", + "METHOD_XTREAM_LABEL": "Credenciais Xtream", "METHOD_XTREAM_SUB": "Conecte-se com host, nome de usuário e senha", + "METHOD_STALKER_LABEL": "Portal Stalker", "METHOD_STALKER_SUB": "Endereço MAC e URL do portal", + "METHOD_TEXT_LABEL": "Texto m3u bruto", "METHOD_TEXT_SUB": "Para listas pontuais ou quando você só tem o texto" }, "FILE_UPLOAD": { diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index e32f8a2fc..dd7e840ef 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -130,10 +130,15 @@ }, "ADD_PLAYLIST": { "PICK_METHOD": "Выберите способ подключения. Переименовать плейлист можно позже.", + "METHOD_URL_LABEL": "M3U URL", "METHOD_URL_SUB": "Вставьте ссылку на удалённый файл .m3u или .m3u8", + "METHOD_FILE_LABEL": "Файл M3U", "METHOD_FILE_SUB": "Загрузите плейлист с вашего компьютера", + "METHOD_XTREAM_LABEL": "Учётные данные Xtream", "METHOD_XTREAM_SUB": "Подключение через сервер, имя пользователя и пароль", + "METHOD_STALKER_LABEL": "Stalker портал", "METHOD_STALKER_SUB": "MAC-адрес и URL портала", + "METHOD_TEXT_LABEL": "M3U текстом", "METHOD_TEXT_SUB": "Для разовых плейлистов или когда есть только текст" }, "FILE_UPLOAD": { diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 993229767..a7d4e824e 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -130,10 +130,15 @@ }, "ADD_PLAYLIST": { "PICK_METHOD": "Nasıl bağlanmak istediğinizi seçin. Daha sonra yeniden adlandırabilirsiniz.", + "METHOD_URL_LABEL": "M3U URL", "METHOD_URL_SUB": "Uzak bir .m3u veya .m3u8 bağlantısını yapıştırın", + "METHOD_FILE_LABEL": "M3U dosyası", "METHOD_FILE_SUB": "Bilgisayarınızdan bir oynatma listesi yükleyin", + "METHOD_XTREAM_LABEL": "Xtream kimlik bilgileri", "METHOD_XTREAM_SUB": "Sunucu, kullanıcı adı ve parola ile bağlanın", + "METHOD_STALKER_LABEL": "Stalker portalı", "METHOD_STALKER_SUB": "MAC adresi ve portal URL'si", + "METHOD_TEXT_LABEL": "Ham m3u metni", "METHOD_TEXT_SUB": "Tek seferlik listeler veya yalnızca metniniz olduğunda" }, "FILE_UPLOAD": { diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 66e2fd089..b1f1798d3 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -130,10 +130,15 @@ }, "ADD_PLAYLIST": { "PICK_METHOD": "选择您想要的连接方式。稍后可以重命名。", + "METHOD_URL_LABEL": "M3U URL", "METHOD_URL_SUB": "粘贴远程 .m3u 或 .m3u8 的链接", + "METHOD_FILE_LABEL": "M3U 文件", "METHOD_FILE_SUB": "从您的电脑上传播放列表", + "METHOD_XTREAM_LABEL": "Xtream 凭据", "METHOD_XTREAM_SUB": "使用主机、用户名和密码连接", + "METHOD_STALKER_LABEL": "Stalker 门户", "METHOD_STALKER_SUB": "MAC 地址和门户 URL", + "METHOD_TEXT_LABEL": "原始 m3u 文本", "METHOD_TEXT_SUB": "用于一次性列表,或仅有文本时" }, "FILE_UPLOAD": { diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 4b554a0ce..c0aa33b49 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -130,10 +130,15 @@ }, "ADD_PLAYLIST": { "PICK_METHOD": "請選擇您要使用的連線方式,之後仍可重新命名。", + "METHOD_URL_LABEL": "M3U URL", "METHOD_URL_SUB": "貼上遠端 .m3u 或 .m3u8 的連結", + "METHOD_FILE_LABEL": "M3U 檔案", "METHOD_FILE_SUB": "從您的電腦上傳播放清單", + "METHOD_XTREAM_LABEL": "Xtream 帳號", "METHOD_XTREAM_SUB": "使用主機位址、帳號和密碼連線", + "METHOD_STALKER_LABEL": "Stalker 入口網站", "METHOD_STALKER_SUB": "MAC 位址與入口網址", + "METHOD_TEXT_LABEL": "M3U 原始文字", "METHOD_TEXT_SUB": "適合一次性清單,或您只有文字內容時使用" }, "FILE_UPLOAD": { diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.scss b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.scss index 3662ac6e7..07f44751b 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.scss +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.scss @@ -57,15 +57,20 @@ background: var(--apd-segment-track); border: 1px solid var(--apd-border); border-radius: 10px; - padding: 12px 12px 14px; + padding: 10px 11px 12px; display: flex; flex-direction: column; - gap: 6px; + gap: 5px; cursor: pointer; text-align: left; font: inherit; color: var(--apd-heading); min-width: 0; + // Locks every card to the same height regardless of which has the + // longest subtitle. Without this the URL card (short text) collapses + // and the Stalker/Xtream cards (longer text) push tall — selection + // states then look visually inconsistent. + min-height: 132px; transition: background-color 160ms ease, border-color 160ms ease, diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts index fa6d86330..d989f2355 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts @@ -75,36 +75,39 @@ export class AddPlaylistDialogComponent { // Order matches the v0.22 mockup left-to-right: URL first (Most common), // then File, Xtream credentials, Stalker portal, raw text paste. Each - // entry stands on its own — no nested subtypes. + // entry stands on its own — no nested subtypes. Labels are short and + // sentence-cased; the "Add via …" / "Add Xtreme Code" wording from the + // old tab labels is redundant inside a dialog already titled "Add + // playlist". readonly methodOptions: PlaylistMethodOption[] = [ { value: 'url', icon: 'public', - labelKey: 'HOME.TABS.URL_UPLOAD', + labelKey: 'HOME.ADD_PLAYLIST.METHOD_URL_LABEL', subKey: 'HOME.ADD_PLAYLIST.METHOD_URL_SUB', }, { value: 'file', icon: 'folder_open', - labelKey: 'HOME.TABS.FILE_UPLOAD', + labelKey: 'HOME.ADD_PLAYLIST.METHOD_FILE_LABEL', subKey: 'HOME.ADD_PLAYLIST.METHOD_FILE_SUB', }, { value: 'xtream', icon: 'vpn_key', - labelKey: 'HOME.TABS.XTREME_IMPORT', + labelKey: 'HOME.ADD_PLAYLIST.METHOD_XTREAM_LABEL', subKey: 'HOME.ADD_PLAYLIST.METHOD_XTREAM_SUB', }, { value: 'stalker', icon: 'cast', - labelKey: 'HOME.TABS.STALKER_PORTAL_IMPORT', + labelKey: 'HOME.ADD_PLAYLIST.METHOD_STALKER_LABEL', subKey: 'HOME.ADD_PLAYLIST.METHOD_STALKER_SUB', }, { value: 'text', icon: 'subject', - labelKey: 'HOME.TABS.TEXT_IMPORT', + labelKey: 'HOME.ADD_PLAYLIST.METHOD_TEXT_LABEL', subKey: 'HOME.ADD_PLAYLIST.METHOD_TEXT_SUB', }, ]; From d59fa7465021136d01c6109da56e3d40d70dbb4b Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 19 May 2026 01:16:49 +0200 Subject: [PATCH 08/24] fix(add-playlist): align dialog head with content padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../add-playlist-dialog.component.scss | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.scss b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.scss index 07f44751b..799fe67c0 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.scss +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.scss @@ -18,12 +18,21 @@ } // ─── Dialog head (title + helper subtitle) ────────────────────────────────── +// `mat-dialog-title` carries Material's `.mdc-dialog__title` defaults, which +// include a 40px-tall `::before` ghost element for baseline alignment plus a +// 6px/9px asymmetric padding. Both fight our custom two-line head — kill the +// ghost and apply explicit padding so the title shares the same 24px +// horizontal inset as `mat-dialog-content` below. .apd-head { display: flex; flex-direction: column; gap: 4px; - margin: 0 0 8px; - padding: 0; + margin: 0; + padding: 20px 24px 4px; + + &::before { + display: none; + } } .apd-title { From 7399fbfa9eb91dbc775b1af40cd7dfd2824d4b13 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 19 May 2026 01:21:10 +0200 Subject: [PATCH 09/24] refactor(multi-epg): match app theme, widen channels, label the now-line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../multi-epg-container.component.html | 25 ++- .../multi-epg-container.component.scss | 176 ++++++++++++------ .../multi-epg-container.component.ts | 28 +++ 3 files changed, 168 insertions(+), 61 deletions(-) diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.html b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.html index 06ecb3ca9..9242ff3ef 100644 --- a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.html +++ b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.html @@ -123,16 +123,16 @@ @for (item of filteredChannels(); track item.id; let i = $index) { - + @@ -141,7 +141,6 @@ } {{ getChannelName(item) }} @@ -191,6 +190,7 @@ [attr.transform]="'translate(' + program.startPosition + ',' + (barHeight * i + barHeight) + ')'" class="program" [class.highlighted]="highlightedProgramKey() === getProgramKey(program)" + [class.is-now]="isProgramAiringNow(program)" (click)="showDescription(program)" > } - + + + + {{ currentTimeLabel() }} + diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.scss b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.scss index 05885b7ba..94bf9af24 100644 --- a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.scss +++ b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.scss @@ -1,23 +1,31 @@ -// Color palette - Broadcast control room aesthetic -$bg-deep: #0a0a0f; -$bg-surface: #12121a; -$bg-elevated: #1a1a24; -$bg-hover: #24243a; -$border-subtle: rgba(255, 255, 255, 0.06); -$border-medium: rgba(255, 255, 255, 0.12); +// Color palette — every value resolves through the app's theme tokens so +// Multi-EPG looks like part of IPTVnator instead of a separate broadcast +// utility. Previously hard-coded #0a0a0f / #12121a / #1a1a24 made the grid +// switch to near-black while the rest of the app sat on --app-content-bg, +// which the UX audit called out as the screen "visually detaching" from +// the rest of the app. +$bg-deep: var(--app-content-bg); +$bg-surface: var(--app-widget-bg); +$bg-elevated: var(--app-widget-header-bg); +$bg-hover: var(--app-card-hover-bg); +$border-subtle: var(--app-widget-header-border); +$border-medium: var(--app-separator); // Accent colors — unified with the rest of the app: -// cyan is reserved for the EPG "now" indicator, blue is the app primary, -// purple/pink had no semantic role and are dropped per the UX audit. +// cyan stays on the "now" indicator only; blue is the primary; the old +// $8b5cf6 purple and $00d4aa teal are dropped per the UX audit. $accent-primary: #5cd6ff; // Cyan — reserved for "now" indicator -$accent-secondary: #4f8eff; // Blue — programs (matches --app-selection-color) -$accent-tertiary: #4f8eff; // Blue — highlights +$accent-now-glow: rgba(92, 214, 255, 0.45); // Cyan glow for the now-line +$accent-secondary: var(--app-selection-color); // Blue — programs +$accent-tertiary: var(--app-selection-color); // Blue — highlights $accent-warning: #fbbf24; -// Text colors -$text-primary: #f8fafc; -$text-secondary: rgba(248, 250, 252, 0.7); -$text-muted: rgba(248, 250, 252, 0.4); +// Text colors — derived from app heading/body so light and dark themes +// stay in sync without a separate `.light-theme` override at the bottom +// (which was duplicating responsibility with the same token chain). +$text-primary: var(--app-heading-color); +$text-secondary: var(--app-body-color); +$text-muted: var(--app-muted-color); // Gradients $gradient-program: linear-gradient( @@ -56,11 +64,11 @@ $gradient-header: linear-gradient( } &::-webkit-scrollbar-thumb { - background: rgba(139, 92, 246, 0.4); + background: rgba(47, 123, 255, 0.4); border-radius: 4px; &:hover { - background: rgba(139, 92, 246, 0.6); + background: rgba(47, 123, 255, 0.6); } } } @@ -89,8 +97,8 @@ $gradient-header: linear-gradient( background: linear-gradient( 90deg, transparent 0%, - rgba(0, 212, 170, 0.3) 20%, - rgba(139, 92, 246, 0.3) 80%, + rgba(92, 214, 255, 0.3) 20%, + rgba(47, 123, 255, 0.3) 80%, transparent 100% ); } @@ -104,7 +112,7 @@ $gradient-header: linear-gradient( -webkit-app-region: no-drag; &:not([disabled]):hover { - background: rgba(139, 92, 246, 0.15); + background: rgba(47, 123, 255, 0.15); } &[disabled] { @@ -138,7 +146,7 @@ $gradient-header: linear-gradient( transition: all 0.2s ease; &:focus-within { - border-color: rgba(139, 92, 246, 0.5); + border-color: rgba(47, 123, 255, 0.5); background: rgba(255, 255, 255, 0.08); } @@ -241,7 +249,7 @@ $gradient-header: linear-gradient( transition: all 0.2s ease; &:focus-within { - border-color: rgba(139, 92, 246, 0.5); + border-color: rgba(47, 123, 255, 0.5); background: rgba(255, 255, 255, 0.08); } @@ -328,7 +336,7 @@ $gradient-header: linear-gradient( } &::-webkit-scrollbar-thumb { - background: rgba(139, 92, 246, 0.3); + background: rgba(47, 123, 255, 0.3); border-radius: 3px; } @@ -446,11 +454,11 @@ $gradient-header: linear-gradient( @keyframes highlightPulse { 0%, 100% { - filter: drop-shadow(0 0 8px rgba(0, 212, 170, 0.6)); + filter: drop-shadow(0 0 8px rgba(92, 214, 255, 0.6)); } 50% { - filter: drop-shadow(0 0 20px rgba(0, 212, 170, 0.9)) - drop-shadow(0 0 40px rgba(0, 212, 170, 0.4)); + filter: drop-shadow(0 0 20px rgba(92, 214, 255, 0.9)) + drop-shadow(0 0 40px rgba(92, 214, 255, 0.4)); } } @@ -498,12 +506,14 @@ $gradient-header: linear-gradient( text-transform: uppercase; // Glowing text effect - text-shadow: 0 0 20px rgba(0, 212, 170, 0.3); + text-shadow: 0 0 20px rgba(92, 214, 255, 0.3); } -// Channel column +// Channel column — bumped from 140px to 180px per the UX audit so long +// channel names ("13th Street Universal HD", "ARD alpha", etc.) stop being +// truncated to 5 characters. #channels-column { - width: 140px; + width: 180px; flex: none; border-right: 1px solid $border-medium; background: $bg-surface; @@ -529,8 +539,8 @@ $gradient-header: linear-gradient( } &.active rect { - fill: rgba(139, 92, 246, 0.2); - stroke: rgba(139, 92, 246, 0.4); + fill: rgba(47, 123, 255, 0.2); + stroke: rgba(47, 123, 255, 0.4); } &.active .channel-name { @@ -542,25 +552,40 @@ $gradient-header: linear-gradient( .channel-name { color: $text-primary; padding: 8px 12px; - font-size: 11px; + font-size: 12.5px; font-weight: 500; display: flex; align-items: center; + gap: 10px; height: 100%; overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - letter-spacing: 0.02em; + letter-spacing: 0.01em; + line-height: 1.2; img { + // Smaller logo (24px) per audit recommendation so the channel + // name has room to breathe in the 180px column. + width: 24px !important; + height: 24px !important; + margin-right: 0 !important; + flex: 0 0 auto; border-radius: 4px; background: rgba(255, 255, 255, 0.05); padding: 2px; + object-fit: contain; } span { + // Allow up to two lines so "13th Street Universal HD" and + // similar long names wrap instead of disappearing into an + // ellipsis after 5 characters. + min-width: 0; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; + word-break: break-word; } } @@ -572,7 +597,7 @@ $gradient-header: linear-gradient( // EPG container #epg-container { - width: calc(100vw - 140px); + width: calc(100vw - 180px); overflow-x: scroll; overflow-y: clip; background: $bg-deep; @@ -588,11 +613,11 @@ $gradient-header: linear-gradient( } &::-webkit-scrollbar-thumb { - background: rgba(139, 92, 246, 0.4); + background: rgba(47, 123, 255, 0.4); border-radius: 4px; &:hover { - background: rgba(139, 92, 246, 0.6); + background: rgba(47, 123, 255, 0.6); } } } @@ -633,13 +658,13 @@ $gradient-header: linear-gradient( &:hover rect { fill: $bg-hover; - stroke: rgba(139, 92, 246, 0.4); - filter: drop-shadow(0 0 8px rgba(139, 92, 246, 0.2)); + stroke: rgba(47, 123, 255, 0.4); + filter: drop-shadow(0 0 8px rgba(47, 123, 255, 0.2)); } // Highlighted program (from search) &.highlighted rect { - fill: rgba(0, 212, 170, 0.25); + fill: rgba(92, 214, 255, 0.25); stroke: $accent-primary; stroke-width: 2px; animation: highlightPulse 1s ease-in-out 3; @@ -653,12 +678,12 @@ $gradient-header: linear-gradient( // Active channel row highlighting .active-row .program rect { - fill: rgba(139, 92, 246, 0.15); - stroke: rgba(139, 92, 246, 0.3); + fill: rgba(47, 123, 255, 0.15); + stroke: rgba(47, 123, 255, 0.3); } .active-row .program:hover rect { - fill: rgba(139, 92, 246, 0.25); + fill: rgba(47, 123, 255, 0.25); } .program-title { @@ -683,12 +708,45 @@ $gradient-header: linear-gradient( line[stroke='red'] { stroke: $accent-primary !important; stroke-width: 2px; - filter: drop-shadow(0 0 8px rgba(0, 212, 170, 0.6)) - drop-shadow(0 0 16px rgba(0, 212, 170, 0.3)); + filter: drop-shadow(0 0 8px rgba(92, 214, 255, 0.6)) + drop-shadow(0 0 16px rgba(92, 214, 255, 0.3)); // Animated pulse animation: timePulse 2s ease-in-out infinite; } + + // Now-line time badge — a small filled pill sitting on top of the time + // header that reads "HH:MM". Keeps the cyan visual language of the line + // itself so the relationship is unmistakable. + .now-label { + pointer-events: none; + + rect { + fill: $accent-primary; + stroke: none; + filter: drop-shadow(0 0 6px $accent-now-glow); + } + + text { + fill: var(--app-shell-bg, #0d0f12); + font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, + monospace; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.04em; + } + } + + // Currently-airing program — a thin cyan border on every program block + // whose time window contains the now-line. Audit recommended this to + // make "what's airing right now" scannable without finding the line + // first then tracing it down each row. + .program.is-now { + rect { + stroke: $accent-primary; + stroke-width: 1.5px; + } + } } // Pulse animation for time line @@ -696,13 +754,13 @@ $gradient-header: linear-gradient( 0%, 100% { opacity: 1; - filter: drop-shadow(0 0 8px rgba(0, 212, 170, 0.6)) - drop-shadow(0 0 16px rgba(0, 212, 170, 0.3)); + filter: drop-shadow(0 0 8px rgba(92, 214, 255, 0.6)) + drop-shadow(0 0 16px rgba(92, 214, 255, 0.3)); } 50% { opacity: 0.8; - filter: drop-shadow(0 0 12px rgba(0, 212, 170, 0.8)) - drop-shadow(0 0 24px rgba(0, 212, 170, 0.4)); + filter: drop-shadow(0 0 12px rgba(92, 214, 255, 0.8)) + drop-shadow(0 0 24px rgba(92, 214, 255, 0.4)); } } @@ -810,7 +868,7 @@ rect { &:hover rect { fill: #f1f5f9; - stroke: rgba(139, 92, 246, 0.4); + stroke: rgba(47, 123, 255, 0.4); } } @@ -819,8 +877,8 @@ rect { } line[stroke='red'] { - stroke: #059669 !important; - filter: drop-shadow(0 0 6px rgba(5, 150, 105, 0.4)); + stroke: $accent-primary !important; + filter: drop-shadow(0 0 6px $accent-now-glow); } } } @@ -833,16 +891,22 @@ rect { } #channels-column { - width: 100px; + width: 140px; .channel-name { - font-size: 10px; + font-size: 11.5px; padding: 6px 8px; + gap: 8px; + + img { + width: 20px !important; + height: 20px !important; + } } } #epg-container { - width: calc(100vw - 100px); + width: calc(100vw - 140px); } .today-date { diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts index 5e2d740c8..5ef6564d0 100644 --- a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts +++ b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts @@ -145,6 +145,19 @@ export class MultiEpgContainerComponent return (now.getHours() + now.getMinutes() / 60) * this.hourWidth(); }); + // "16:32"-style label rendered as a badge above the now-line. Recomputes + // on the same 60-second tick that drives `currentTimeLine` (both depend + // on hourWidth's update pulse from `ngOnInit`). + readonly currentTimeLabel = computed(() => { + // Subscribe to the same tick source as currentTimeLine. + this.hourWidth(); + const now = new Date(); + return `${now.getHours().toString().padStart(2, '0')}:${now + .getMinutes() + .toString() + .padStart(2, '0')}`; + }); + // Constants readonly timeHeader = Array.from({ length: 24 }, (_, i) => i); readonly barHeight = 50; @@ -228,6 +241,21 @@ export class MultiEpgContainerComponent return `${program.start}|${program?.title?.toString() ?? ''}`; } + /** + * Returns true when the now-line falls within this program's rendered + * span — used to add a `.is-now` class so the cyan border lights up + * every airing program at once. The template reads `currentTimeLine()` + * (a signal) here so the highlight refreshes on the same 60-second tick + * as the line itself. + */ + isProgramAiringNow(program: EnrichedProgram): boolean { + const nowX = this.currentTimeLine(); + return ( + nowX >= program.startPosition && + nowX <= program.startPosition + program.width + ); + } + async requestPrograms(): Promise { if (!window.electron) { console.warn('Multi-EPG not available: Electron not detected'); From 115911d2a61ec66885044cbd35001338cfbcdc27 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 19 May 2026 01:33:40 +0200 Subject: [PATCH 10/24] refactor(settings): kill triple header, compact theme picker, real footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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

Settings

+ subtitle, and the General section's

General

+ 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 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) --- .../src/app/settings/settings.component.html | 20 +-- .../src/app/settings/settings.component.scss | 117 ++++++++++-------- apps/web/src/assets/i18n/ar.json | 6 +- apps/web/src/assets/i18n/ary.json | 6 +- apps/web/src/assets/i18n/by.json | 6 +- apps/web/src/assets/i18n/el.json | 6 +- apps/web/src/assets/i18n/en.json | 6 +- apps/web/src/assets/i18n/es.json | 6 +- apps/web/src/assets/i18n/fr.json | 6 +- apps/web/src/assets/i18n/it.json | 6 +- apps/web/src/assets/i18n/ja.json | 6 +- apps/web/src/assets/i18n/ko.json | 6 +- apps/web/src/assets/i18n/nl.json | 6 +- apps/web/src/assets/i18n/pl.json | 6 +- apps/web/src/assets/i18n/pt.json | 6 +- apps/web/src/assets/i18n/ru.json | 6 +- apps/web/src/assets/i18n/tr.json | 6 +- apps/web/src/assets/i18n/zh.json | 4 +- apps/web/src/assets/i18n/zhtw.json | 4 +- apps/web/src/styles.scss | 17 +++ .../workspace-shell-header.component.html | 100 ++++++++------- .../workspace-shell-header.component.ts | 9 ++ .../workspace-shell.component.html | 1 + .../workspace-shell.component.spec.ts | 1 + 24 files changed, 207 insertions(+), 156 deletions(-) diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index a23a5308b..314e151e4 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -14,18 +14,20 @@ class="settings-layout" novalidate > + @if (!isDialog) { -
+ -
-

- {{ 'SETTINGS.TITLE' | translate }} -

-

{{ 'SETTINGS.GENERAL_SUBTITLE' | translate }}

-
-
+ {{ 'SETTINGS.TITLE' | translate }} +
}
diff --git a/apps/web/src/app/settings/settings.component.scss b/apps/web/src/app/settings/settings.component.scss index 5fcd5c475..4c6c0db57 100644 --- a/apps/web/src/app/settings/settings.component.scss +++ b/apps/web/src/app/settings/settings.component.scss @@ -235,50 +235,59 @@ } } +// Compact segmented control — replaces the previous 3 buttons each ~78px +// tall. Per the UX audit, "Light/Dark/System" was reading as the most +// important setting on the page because of its sheer footprint. Now the +// row reads at the same weight as the other compact rows (Language, +// Startup behavior, etc.), restoring the visual hierarchy. .theme-switcher { - width: min(100%, 540px); - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 8px; + width: min(100%, 360px); + display: inline-flex; + padding: 3px; + gap: 2px; + background: color-mix( + in srgb, + var(--mat-sys-surface-container-low) 80%, + transparent + ); + border: 1px solid + color-mix(in srgb, var(--mat-sys-outline-variant) 60%, transparent); + border-radius: 10px; } .theme-switcher__option { + flex: 1 1 0; display: inline-flex; align-items: center; justify-content: center; - gap: 8px; - min-height: 54px; - padding: 12px 14px; - border: 1px solid - color-mix(in srgb, var(--mat-sys-outline-variant) 70%, transparent); - border-radius: 16px; - background: color-mix( - in srgb, - var(--mat-sys-surface-container-low) 88%, - transparent - ); + gap: 6px; + min-height: 30px; + padding: 4px 12px; + border: 0; + border-radius: 7px; + background: transparent; color: var(--mat-sys-on-surface-variant); font: inherit; - font-size: 0.9rem; - font-weight: 600; - letter-spacing: -0.01em; + font-size: 0.82rem; + font-weight: 500; + letter-spacing: 0; cursor: pointer; transition: - border-color 0.18s ease, - background 0.18s ease, - box-shadow 0.18s ease, - color 0.18s ease; + background 0.16s ease, + color 0.16s ease, + box-shadow 0.16s ease; mat-icon { - width: 18px; - height: 18px; - font-size: 18px; + width: 16px; + height: 16px; + font-size: 16px; + line-height: 16px; } &:hover { background: color-mix( in srgb, - var(--mat-sys-surface-container-high) 86%, + var(--mat-sys-surface-container-highest) 70%, transparent ); color: var(--mat-sys-on-surface); @@ -286,31 +295,19 @@ &:focus-visible { outline: none; - box-shadow: 0 0 0 3px - color-mix(in srgb, var(--app-selection-color) 18%, transparent); + box-shadow: 0 0 0 2px + color-mix(in srgb, var(--app-selection-color) 50%, transparent); } } .theme-switcher__option--selected { - border-color: var(--settings-group-active-ring); - background: linear-gradient( - 135deg, - color-mix( - in srgb, - var(--app-selection-surface-strong) 72%, - var(--mat-sys-surface-container-high) - ), - color-mix( - in srgb, - var(--app-selection-surface) 72%, - var(--mat-sys-surface-container) - ) - ); + background: var(--app-widget-bg, var(--mat-sys-surface)); color: var(--app-selection-color); + font-weight: 600; box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.18), inset 0 0 0 1px - color-mix(in srgb, var(--app-selection-border) 54%, transparent), - 0 10px 18px -22px var(--settings-group-active-glow); + color-mix(in srgb, var(--app-selection-border) 36%, transparent); } .epg-sources { @@ -708,21 +705,31 @@ ); } +// Sticky-footer Save bar — full content width so it never overlaps form +// rows. The previous chip-style bar (border-radius:18px, margin-left:auto) +// floated in the bottom-right corner and clipped fields underneath; the +// UX audit called this out as the worst of three available patterns. +// Now the bar acts as a proper footer: spans the column, separator line +// on top, sits flush with the bottom edge while still sticking on scroll. .action-bar { position: sticky; bottom: calc( var(--settings-action-bar-offset) + var(--settings-safe-bottom) ); - margin-left: auto; + margin-left: 0; + margin-right: 0; z-index: 100; display: flex; justify-content: flex-end; align-items: center; gap: 12px; flex-wrap: wrap; - padding: 10px 14px; - border: 1px solid var(--settings-action-bar-border); - border-radius: 18px; + padding: 12px 18px; + border-top: 1px solid var(--settings-action-bar-border); + border-left: 0; + border-right: 0; + border-bottom: 0; + border-radius: 0; background: var(--settings-action-bar-bg); backdrop-filter: blur(12px); box-shadow: var(--settings-action-bar-shadow); @@ -735,16 +742,21 @@ box-shadow: none; border-radius: 16px; padding: 12px; + border-top: 1px solid var(--settings-action-bar-border); justify-content: space-between; } .settings-save-button.mat-mdc-button-base { - background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); - color: #fff; + // Use the unified blue primary (--app-selection-color) instead of the + // old hand-rolled #3b82f6 gradient that didn't match the rest of the + // app's CTAs after the color unification commit. + background: var(--app-selection-color); + color: var(--app-selection-on-color, #fff); font-weight: 500; padding: 0 20px; border-radius: 10px; - box-shadow: 0 4px 14px rgba(59, 130, 246, 0.28); + box-shadow: 0 4px 14px + color-mix(in srgb, var(--app-selection-color) 28%, transparent); transition: transform 0.2s ease, box-shadow 0.2s ease, @@ -756,7 +768,8 @@ &:hover:not([disabled]) { transform: translateY(-1px); - box-shadow: 0 6px 18px rgba(59, 130, 246, 0.38); + box-shadow: 0 6px 18px + color-mix(in srgb, var(--app-selection-color) 38%, transparent); } &:active:not([disabled]) { diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 6fc137424..cd2980df2 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -375,9 +375,9 @@ "PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again." }, "THEMES": { - "DARK_THEME": "السمة الداكنة", - "LIGHT_THEME": "السمة الفاتحة", - "SYSTEM_THEME": "سمة النظام" + "DARK_THEME": "داكن", + "LIGHT_THEME": "فاتح", + "SYSTEM_THEME": "النظام" }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index b5a8c3cf0..35ae7c48a 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -375,9 +375,9 @@ "PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again." }, "THEMES": { - "DARK_THEME": "المظهر الداكن", - "LIGHT_THEME": "المظهر الفاتح", - "SYSTEM_THEME": "مظهر النظام" + "DARK_THEME": "داكن", + "LIGHT_THEME": "فاتح", + "SYSTEM_THEME": "النظام" }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index b06d6758a..5921f839a 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -375,9 +375,9 @@ "PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again." }, "THEMES": { - "DARK_THEME": "Цёмная тэма", - "LIGHT_THEME": "Светлая тэма", - "SYSTEM_THEME": "Сістэмная тэма" + "DARK_THEME": "Цёмная", + "LIGHT_THEME": "Светлая", + "SYSTEM_THEME": "Сістэмная" }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 58afa845a..69ec3d7ec 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -375,9 +375,9 @@ "PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again." }, "THEMES": { - "DARK_THEME": "Σκούρο θέμα", - "LIGHT_THEME": "Ανοιχτόχρωμο θέμα", - "SYSTEM_THEME": "Θέμα συστήματος" + "DARK_THEME": "Σκούρο", + "LIGHT_THEME": "Ανοιχτό", + "SYSTEM_THEME": "Σύστημα" }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 7f4b17947..f708e035c 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -375,9 +375,9 @@ "PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again." }, "THEMES": { - "DARK_THEME": "Dark theme", - "LIGHT_THEME": "Light theme", - "SYSTEM_THEME": "System theme" + "DARK_THEME": "Dark", + "LIGHT_THEME": "Light", + "SYSTEM_THEME": "System" }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index c43bd4d08..3de1b6ec4 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -375,9 +375,9 @@ "PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again." }, "THEMES": { - "DARK_THEME": "Tema oscuro", - "LIGHT_THEME": "Tema claro", - "SYSTEM_THEME": "Tema del sistema" + "DARK_THEME": "Oscuro", + "LIGHT_THEME": "Claro", + "SYSTEM_THEME": "Sistema" }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 6b573bf0e..97b37ee9d 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -375,9 +375,9 @@ "PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again." }, "THEMES": { - "DARK_THEME": "Thème foncé", - "LIGHT_THEME": "Thème clair", - "SYSTEM_THEME": "Thème du système" + "DARK_THEME": "Sombre", + "LIGHT_THEME": "Clair", + "SYSTEM_THEME": "Système" }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index fbefedf2a..ed86645e7 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -375,9 +375,9 @@ "PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again." }, "THEMES": { - "DARK_THEME": "Tema scuro", - "LIGHT_THEME": "Tema chiaro", - "SYSTEM_THEME": "Tema di sistema" + "DARK_THEME": "Scuro", + "LIGHT_THEME": "Chiaro", + "SYSTEM_THEME": "Sistema" }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 8159150ef..9ea348d08 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -375,9 +375,9 @@ "PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again." }, "THEMES": { - "DARK_THEME": "ダークテーマ", - "LIGHT_THEME": "ライトテーマ", - "SYSTEM_THEME": "システムテーマ" + "DARK_THEME": "ダーク", + "LIGHT_THEME": "ライト", + "SYSTEM_THEME": "システム" }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 49fa7ac26..98212cdc1 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -375,9 +375,9 @@ "PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again." }, "THEMES": { - "DARK_THEME": "어두운 테마", - "LIGHT_THEME": "밝은 테마", - "SYSTEM_THEME": "시스템 테마" + "DARK_THEME": "어둡게", + "LIGHT_THEME": "밝게", + "SYSTEM_THEME": "시스템" }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index d5e31f633..4c7d2a961 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -375,9 +375,9 @@ "PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again." }, "THEMES": { - "DARK_THEME": "Donker thema", - "LIGHT_THEME": "Licht thema", - "SYSTEM_THEME": "Systeemthema" + "DARK_THEME": "Donker", + "LIGHT_THEME": "Licht", + "SYSTEM_THEME": "Systeem" }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index d9067313d..fe97670d9 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -375,9 +375,9 @@ "PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again." }, "THEMES": { - "DARK_THEME": "Ciemny motyw", - "LIGHT_THEME": "Jasny motyw", - "SYSTEM_THEME": "Motyw systemowy" + "DARK_THEME": "Ciemny", + "LIGHT_THEME": "Jasny", + "SYSTEM_THEME": "Systemowy" }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 415663cfb..433e20503 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -375,9 +375,9 @@ "PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again." }, "THEMES": { - "DARK_THEME": "Tema escuro", - "LIGHT_THEME": "Tema claro", - "SYSTEM_THEME": "Tema do sistema" + "DARK_THEME": "Escuro", + "LIGHT_THEME": "Claro", + "SYSTEM_THEME": "Sistema" }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index dd7e840ef..d2bcaa0ff 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -375,9 +375,9 @@ "PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again." }, "THEMES": { - "DARK_THEME": "Тёмная тема", - "LIGHT_THEME": "Светлая тема", - "SYSTEM_THEME": "Системная тема" + "DARK_THEME": "Тёмная", + "LIGHT_THEME": "Светлая", + "SYSTEM_THEME": "Системная" }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index a7d4e824e..3e732d5d6 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -375,9 +375,9 @@ "PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again." }, "THEMES": { - "DARK_THEME": "Koyu tema", - "LIGHT_THEME": "Açık tema", - "SYSTEM_THEME": "Sistem teması" + "DARK_THEME": "Koyu", + "LIGHT_THEME": "Açık", + "SYSTEM_THEME": "Sistem" }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index b1f1798d3..5380c69ae 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -375,8 +375,8 @@ "PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again." }, "THEMES": { - "DARK_THEME": "暗黑模式", - "LIGHT_THEME": "明亮模式", + "DARK_THEME": "深色", + "LIGHT_THEME": "浅色", "SYSTEM_THEME": "跟随系统" }, "PLAYBACK_DIAGNOSTICS": { diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index c0aa33b49..7a25f94d0 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -375,8 +375,8 @@ "PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again." }, "THEMES": { - "DARK_THEME": "深色主題", - "LIGHT_THEME": "亮色主題", + "DARK_THEME": "深色", + "LIGHT_THEME": "淺色", "SYSTEM_THEME": "跟隨系統" }, "PLAYBACK_DIAGNOSTICS": { diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss index f1ec3dcfb..dc0e6ecb5 100644 --- a/apps/web/src/styles.scss +++ b/apps/web/src/styles.scss @@ -203,3 +203,20 @@ textarea, .mat-mdc-snack-bar-container.settings-snackbar { margin-bottom: calc(76px + env(safe-area-inset-bottom, 0px)); } + +// WCAG-friendly visually-hidden helper — content stays in the +// accessibility tree and DOM query results but occupies no layout box. +// Used where a visible heading would duplicate adjacent navigation +// context (e.g. Settings page title that the left rail already +// announces). +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html index 01afaf1cd..ed8593c74 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html @@ -1,18 +1,24 @@ -
- +
+ + @if (!isSettingsRoute()) { + + } @if (!hasNoPlaylists()) { } -
- - @if (headerShortcut(); as action) { + @if (!isSettingsRoute()) { +
- } - @if (headerBulkAction(); as bulk) { - - } -
+ @if (headerShortcut(); as action) { + + } + @if (headerBulkAction(); as bulk) { + + } +
+ } @if (isElectron()) {
diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.ts index f9fe6befa..19b2e85d3 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.ts @@ -48,6 +48,15 @@ export class WorkspaceShellHeaderComponent { readonly hasNoPlaylists = input(false); readonly isDownloadsView = input(false); readonly hasActiveDownloads = input(false); + /** + * When true the playlist switcher + the "+ Add source" / refresh / + * bulk-action buttons are hidden — those controls scope to a + * playlist, but Settings is a global page, so leaving them visible + * implies (falsely) that switching the playlist changes which + * settings you're editing. Driven from the shell facade's existing + * isSettingsRoute computed. + */ + readonly isSettingsRoute = input(false); readonly searchChanged = output(); readonly searchSubmitted = output(); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html index eb8015a2e..bedc6afd2 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html @@ -39,6 +39,7 @@ [hasNoPlaylists]="facade.hasNoPlaylists()" [isDownloadsView]="facade.isDownloadsView()" [hasActiveDownloads]="facade.hasActiveDownloads()" + [isSettingsRoute]="facade.isSettingsRoute()" (refreshPlaylistRequested)="facade.refreshCurrentPlaylist()" (searchChanged)="facade.onSearchInput($event)" (searchSubmitted)="facade.onSearchEnter($event)" diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts index ac7d67980..98ef07231 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts @@ -57,6 +57,7 @@ class MockWorkspaceShellHeaderComponent { readonly hasNoPlaylists = input(false); readonly isDownloadsView = input(false); readonly hasActiveDownloads = input(false); + readonly isSettingsRoute = input(false); readonly headerBulkAction = input(null); readonly searchChanged = output(); readonly searchSubmitted = output(); From 5a1f69937919986f64bff87daec1860648bf8fa2 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 19 May 2026 01:37:19 +0200 Subject: [PATCH 11/24] fix(settings): Remote nav now scrolls to the right section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
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) --- .../src/app/settings/settings-options.spec.ts | 78 +++++++++++++++++++ apps/web/src/app/settings/settings-options.ts | 7 +- 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/app/settings/settings-options.spec.ts diff --git a/apps/web/src/app/settings/settings-options.spec.ts b/apps/web/src/app/settings/settings-options.spec.ts new file mode 100644 index 000000000..7ac76cf97 --- /dev/null +++ b/apps/web/src/app/settings/settings-options.spec.ts @@ -0,0 +1,78 @@ +import { readFileSync, readdirSync } from 'fs'; +import { resolve } from 'path'; + +import { buildSettingsSectionNavItems } from './settings-options'; + +/** + * Every nav-item id must match the `id="..."` attribute on its + * corresponding section component template, otherwise clicking the nav + * link silently no-ops (the scroll directive runs `document.getElementById` + * and falls through when no element is found). + * + * This bug bit the Remote section once already — its nav id was set to + * the Nx library name (`@iptvnator/ui/remote-control`) instead of the + * template's `id="remote-control"`. The guard below catches future + * rename/copy-paste regressions before they ship. + */ +describe('buildSettingsSectionNavItems', () => { + // Anchor on the Nx workspace root (Jest runs from there); avoids + // depending on __dirname which isn't defined under the project's + // ESM Jest preset. + const settingsDir = resolve( + process.cwd(), + 'apps/web/src/app/settings' + ); + + function collectSectionTemplateIds(): Set { + const ids = new Set(); + for (const fileName of readdirSync(settingsDir)) { + if (!/^settings-.+-section\.component\.html$/.test(fileName)) { + continue; + } + const html = readFileSync( + resolve(settingsDir, fileName), + 'utf-8' + ); + // Match the FIRST `id="…"` on the
root only — + // descendant elements (form controls, anchors) also use id= + // attributes and would pollute the set. + const rootMatch = /]*\sid="([^"]+)"/i.exec(html); + if (rootMatch) { + ids.add(rootMatch[1]); + } + } + return ids; + } + + it('exposes desktop-only items only when isDesktop is true', () => { + const desktopItems = buildSettingsSectionNavItems(true); + const pwaItems = buildSettingsSectionNavItems(false); + + expect(desktopItems.map((item) => item.id)).toEqual( + expect.arrayContaining(['epg', 'remote-control']) + ); + expect(pwaItems.find((item) => item.id === 'epg')?.visible).toBe( + false + ); + expect( + pwaItems.find((item) => item.id === 'remote-control')?.visible + ).toBe(false); + }); + + it('every nav id matches an existing section template id (regression: remote-control nav no longer maps to the Nx lib name)', () => { + const navIds = new Set( + buildSettingsSectionNavItems(true).map((item) => item.id) + ); + const templateIds = collectSectionTemplateIds(); + + // Every nav id must exist as a section root id. + const orphans = [...navIds].filter((id) => !templateIds.has(id)); + expect(orphans).toEqual([]); + + // And every section template id must be reachable from the nav + // (catches the opposite drift — a new section added without a nav + // entry would never get scrolled to). + const unreachable = [...templateIds].filter((id) => !navIds.has(id)); + expect(unreachable).toEqual([]); + }); +}); diff --git a/apps/web/src/app/settings/settings-options.ts b/apps/web/src/app/settings/settings-options.ts index 8c509f186..93e8bb84d 100644 --- a/apps/web/src/app/settings/settings-options.ts +++ b/apps/web/src/app/settings/settings-options.ts @@ -108,7 +108,12 @@ export function buildSettingsSectionNavItems( visible: isDesktop, }, { - id: '@iptvnator/ui/remote-control', + // Must match the section's HTML id (`remote-control`) so the + // settings-section-scroll directive can resolve the anchor. + // Was previously '@iptvnator/ui/remote-control' (the NX lib + // name), which meant clicking the nav item silently no-op'd + // because document.getElementById of that string returned null. + id: 'remote-control', label: 'SETTINGS.NAV_REMOTE', icon: 'smartphone', visible: isDesktop, From 734ab3c580858fa406d9727836e906849e040e4f Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 19 May 2026 01:41:42 +0200 Subject: [PATCH 12/24] refactor(settings): align look-and-feel with v0.22 mockup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/app/settings/settings.component.scss | 102 ++++++++---------- ...pace-settings-context-panel.component.scss | 15 ++- 2 files changed, 59 insertions(+), 58 deletions(-) diff --git a/apps/web/src/app/settings/settings.component.scss b/apps/web/src/app/settings/settings.component.scss index 4c6c0db57..14386f6b9 100644 --- a/apps/web/src/app/settings/settings.component.scss +++ b/apps/web/src/app/settings/settings.component.scss @@ -64,36 +64,33 @@ gap: 16px; } +// Flat section — no panel chrome. The v0.22 mockup treats Settings as a +// single scroll column with each section anchored by a large title + +// bottom-border separator. The previous card frame (border-radius 22px, +// filled bg, box-shadow, active-state ring + icon-circle) made every +// section look like a feature card, which fought the inline form rhythm. .settings-group { position: relative; - background: var(--settings-group-bg); - overflow: hidden; - scroll-margin-top: 24px; + background: transparent; + border: 0; + border-radius: 0; + box-shadow: none; + overflow: visible; padding: 0; - border: 1px solid var(--settings-group-border); - border-radius: 22px; - box-shadow: var(--settings-group-shadow); - transition: - background 0.2s ease, - box-shadow 0.2s ease, - border-color 0.2s ease; + margin-top: 24px; + scroll-margin-top: 24px; + transition: none; + + &:first-of-type { + margin-top: 0; + } } +// The active section no longer gets a ring/shadow — the rail's active +// state on the left already announces "you are here". Title still +// colours to the accent so users scanning the right pane don't lose the +// anchor. .settings-group--active { - background: var(--settings-group-active-bg); - border-color: var(--settings-group-active-ring); - box-shadow: var(--settings-group-active-shadow); - - .settings-group__header-icon { - background: color-mix( - in srgb, - var(--app-selection-surface-strong) 70%, - var(--mat-sys-surface-container-high) - ); - color: var(--app-selection-color); - border-color: var(--settings-group-active-ring); - } - .settings-group__header-text h3 { color: var(--app-selection-color); } @@ -101,46 +98,33 @@ .settings-group__header { display: flex; - align-items: center; + align-items: flex-start; gap: 14px; - padding: 16px 18px 14px; + padding: 4px 0 18px; + margin: 0 0 8px; + border-bottom: 1px solid var(--settings-item-border); + // Icon-circle removed per UX-audit recommendation (the section title + // is the anchor, not a card). Hidden rather than ripped out of every + // section template so the existing markup stays untouched. .settings-group__header-icon { - display: flex; - align-items: center; - justify-content: center; - width: 40px; - height: 40px; - flex-shrink: 0; - border: 1px solid - color-mix(in srgb, var(--mat-sys-outline-variant) 74%, transparent); - border-radius: 12px; - background: color-mix( - in srgb, - var(--mat-sys-surface-container-high) 78%, - transparent - ); - color: var(--mat-sys-on-surface-variant); - - mat-icon { - font-size: 20px; - width: 20px; - height: 20px; - } + display: none; } .settings-group__header-text { h3 { margin: 0; - font-size: 1.08rem; - font-weight: 700; - letter-spacing: -0.02em; + font-size: 1.5rem; + font-weight: 600; + letter-spacing: -0.015em; + line-height: 1.2; + color: var(--app-heading-color); } p { - margin: 2px 0 0; - color: var(--settings-muted); - font-size: 0.84rem; + margin: 6px 0 0; + color: var(--app-body-color); + font-size: 0.92rem; line-height: 1.45; } } @@ -151,15 +135,23 @@ border-top: 1px solid var(--settings-item-border); } +// Row layout — flat list aligned to the section title (no inset). The +// previous `margin: 0 18px` came from the card chrome; now that each +// section is a flat block, rows sit at the same gutter as the title. .setting-item { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(220px, 340px); gap: 14px; - margin: 0 18px; - padding: 14px 0; + margin: 0; + padding: 18px 0; border-top: 1px solid var(--settings-item-border); background: transparent; + &:first-of-type { + border-top: 0; + padding-top: 8px; + } + &:last-of-type { margin-bottom: 0; } diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.scss b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.scss index ea949a985..1b56b6755 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.scss @@ -7,12 +7,21 @@ overflow: hidden; } +// Quiet mono eyebrow per the v0.22 mockup — replaces the previous bold +// 20px h2 that competed with the section headers on the right pane for +// "biggest text on screen". The rail is now a scannable list; the section +// titles on the right are the dominant heading. .panel-title { margin: 0; - padding: 16px 16px 0; - font-size: 20px; + padding: 20px 18px 12px; + font-family: + 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Monaco, Consolas, + monospace; + font-size: 11px; font-weight: 500; - color: var(--mat-sys-on-surface); + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--mat-sys-on-surface-variant); } .settings-panel-body { From 66f70b62c72ae80c8ff8a5027c5ca87b27686397 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 19 May 2026 01:56:25 +0200 Subject: [PATCH 13/24] fix(settings): pin save bar flush to viewport bottom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--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) --- apps/web/src/_settings-theme.scss | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/_settings-theme.scss b/apps/web/src/_settings-theme.scss index 437c54977..34cc1cb3b 100644 --- a/apps/web/src/_settings-theme.scss +++ b/apps/web/src/_settings-theme.scss @@ -1,6 +1,10 @@ .settings-container { --settings-safe-bottom: env(safe-area-inset-bottom, 0px); - --settings-action-bar-offset: 24px; + // Action bar is a full-width sticky footer, so it sits flush against + // the bottom edge of the scroll container — no hover offset. The + // previous 24px gap was tuned for the old floating-chip layout where + // the bar visually hovered above the bottom. + --settings-action-bar-offset: 0px; --settings-action-bar-space: 112px; --settings-page-header-bg: color-mix( in srgb, From 27becf5b2ac5fc29792ba980a1b8f546bc02fb25 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 19 May 2026 02:00:56 +0200 Subject: [PATCH 14/24] fix(settings): remove transient box-shadow pulse on scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../settings-section-scroll.directive.ts | 45 ++++--------------- 1 file changed, 8 insertions(+), 37 deletions(-) diff --git a/apps/web/src/app/settings/settings-section-scroll.directive.ts b/apps/web/src/app/settings/settings-section-scroll.directive.ts index 1550d6d41..d2207a61d 100644 --- a/apps/web/src/app/settings/settings-section-scroll.directive.ts +++ b/apps/web/src/app/settings/settings-section-scroll.directive.ts @@ -45,43 +45,14 @@ export class SettingsSectionScrollDirective { injector: this.injector } ); - effect( - (onCleanup) => { - const activeSectionId = this.settingsCtx.activeSection(); - const activeSectionElement = - this.elementRef.nativeElement.querySelector( - `#${activeSectionId}` - ) as HTMLElement | null; - - if (!activeSectionElement) { - return; - } - - const animation = activeSectionElement.animate( - [ - { - boxShadow: - 'inset 0 0 0 1px var(--settings-group-active-ring), 0 8px 18px -24px var(--settings-group-active-glow)', - }, - { - boxShadow: - 'inset 0 0 0 1px var(--settings-group-active-ring), 0 12px 22px -24px var(--settings-group-active-glow)', - }, - { - boxShadow: - 'inset 0 0 0 1px var(--settings-group-active-ring), 0 8px 18px -24px var(--settings-group-active-glow)', - }, - ], - { - duration: 260, - easing: 'ease-out', - } - ); - - onCleanup(() => animation.cancel()); - }, - { injector: this.injector } - ); + // The previous active-section change handler ran a 260ms box-shadow + // animation (inset 1px ring + soft glow) on whichever section + // became active during scroll. With the new flat layout (no card + // chrome, no static active ring) that pulse drew a brief 1px + // border around each block as the user scrolled past — what the + // user reported as "short border kind of highlight around blocks". + // The rail's left active state already announces the current + // section, so the inline pulse is redundant. Removed entirely. } ngAfterViewInit(): void { From b246f8e51b687a6f5425711b5cd37091da4dfb4a Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 19 May 2026 02:02:15 +0200 Subject: [PATCH 15/24] fix(settings): drop dead bottom padding below the action bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--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) --- apps/web/src/_settings-theme.scss | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/web/src/_settings-theme.scss b/apps/web/src/_settings-theme.scss index 34cc1cb3b..8a843b60e 100644 --- a/apps/web/src/_settings-theme.scss +++ b/apps/web/src/_settings-theme.scss @@ -5,7 +5,15 @@ // previous 24px gap was tuned for the old floating-chip layout where // the bar visually hovered above the bottom. --settings-action-bar-offset: 0px; - --settings-action-bar-space: 112px; + // 0 by default — the action bar is now the last child of the form + // and acts as the natural bottom edge, so no padding-bottom is + // needed below it. The previous 112px/80px reservation existed to + // push content above the OLD floating-chip layout, but in the new + // sticky-footer model that just added dead scrollable space below + // the last setting row. The bar's own padding (12px) and the + // section title bottom-borders already provide visual breathing + // room when scrolled to the end. + --settings-action-bar-space: 0px; --settings-page-header-bg: color-mix( in srgb, var(--mat-sys-surface-container-low) 94%, From 231c22f7d47be6b25db8c8fe3b454b779bbdd70d Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 19 May 2026 02:03:50 +0200 Subject: [PATCH 16/24] chore(settings): switch SETTINGS eyebrow to inherited sans-serif MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...orkspace-settings-context-panel.component.scss | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.scss b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.scss index 1b56b6755..981557ab6 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.scss @@ -7,18 +7,17 @@ overflow: hidden; } -// Quiet mono eyebrow per the v0.22 mockup — replaces the previous bold -// 20px h2 that competed with the section headers on the right pane for -// "biggest text on screen". The rail is now a scannable list; the section -// titles on the right are the dominant heading. +// Quiet eyebrow header — small, uppercase, letter-spaced so it doesn't +// compete with the section titles on the right pane (which is now the +// dominant heading). Stays on the app's inherited sans-serif (DM Sans) +// rather than the mockup's mono — the rest of the workspace shell uses +// sans for every other small label, so a one-off mono eyebrow stood out +// as inconsistent. .panel-title { margin: 0; padding: 20px 18px 12px; - font-family: - 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Monaco, Consolas, - monospace; font-size: 11px; - font-weight: 500; + font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase; color: var(--mat-sys-on-surface-variant); From 86ec1685a597128b6b0cc6a21fdca30e01155e15 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 19 May 2026 02:05:14 +0200 Subject: [PATCH 17/24] chore(settings): drop uppercase eyebrow on rail title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...pace-settings-context-panel.component.scss | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.scss b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.scss index 981557ab6..fb7b41080 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.scss @@ -7,19 +7,18 @@ overflow: hidden; } -// Quiet eyebrow header — small, uppercase, letter-spaced so it doesn't -// compete with the section titles on the right pane (which is now the -// dominant heading). Stays on the app's inherited sans-serif (DM Sans) -// rather than the mockup's mono — the rest of the workspace shell uses -// sans for every other small label, so a one-off mono eyebrow stood out -// as inconsistent. +// Quiet sentence-case rail header — matches the typography of the +// section nav items below ("Dashboard", "Sources", etc., all sentence +// case) instead of standing out as an uppercase eyebrow that didn't +// appear anywhere else in the workspace rail. The muted colour and +// small size still distinguish it from the clickable items so it reads +// as a section caption rather than competing for focus. .panel-title { margin: 0; - padding: 20px 18px 12px; - font-size: 11px; + padding: 20px 18px 10px; + font-size: 13px; font-weight: 600; - letter-spacing: 0.1em; - text-transform: uppercase; + letter-spacing: 0.005em; color: var(--mat-sys-on-surface-variant); } From 55215a50594b60d86f75eda690510a328032b8b8 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 19 May 2026 09:03:14 +0200 Subject: [PATCH 18/24] feat(radio): add tooltips + aria-labels on every player button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/web/src/assets/i18n/ar.json | 9 +++ apps/web/src/assets/i18n/ary.json | 9 +++ apps/web/src/assets/i18n/by.json | 9 +++ apps/web/src/assets/i18n/de.json | 9 +++ apps/web/src/assets/i18n/el.json | 9 +++ apps/web/src/assets/i18n/en.json | 9 +++ apps/web/src/assets/i18n/es.json | 9 +++ apps/web/src/assets/i18n/fr.json | 9 +++ apps/web/src/assets/i18n/it.json | 9 +++ apps/web/src/assets/i18n/ja.json | 9 +++ apps/web/src/assets/i18n/ko.json | 9 +++ apps/web/src/assets/i18n/nl.json | 9 +++ apps/web/src/assets/i18n/pl.json | 9 +++ apps/web/src/assets/i18n/pt.json | 9 +++ apps/web/src/assets/i18n/ru.json | 9 +++ apps/web/src/assets/i18n/tr.json | 9 +++ apps/web/src/assets/i18n/zh.json | 9 +++ apps/web/src/assets/i18n/zhtw.json | 9 +++ .../audio-player/audio-player.component.ts | 62 ++++++++++++++++++- 19 files changed, 221 insertions(+), 3 deletions(-) diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index cd2980df2..b8b309954 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -379,6 +379,15 @@ "LIGHT_THEME": "فاتح", "SYSTEM_THEME": "النظام" }, + "AUDIO_PLAYER": { + "PLAY": "تشغيل", + "PAUSE": "إيقاف مؤقت", + "PREVIOUS_STATION": "المحطة السابقة", + "NEXT_STATION": "المحطة التالية", + "MUTE": "كتم الصوت", + "UNMUTE": "إلغاء كتم الصوت", + "VOLUME": "مستوى الصوت" + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 35ae7c48a..3b72f27bf 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -379,6 +379,15 @@ "LIGHT_THEME": "فاتح", "SYSTEM_THEME": "النظام" }, + "AUDIO_PLAYER": { + "PLAY": "شغل", + "PAUSE": "وقّف", + "PREVIOUS_STATION": "المحطة السابقة", + "NEXT_STATION": "المحطة التالية", + "MUTE": "كتم الصوت", + "UNMUTE": "رجّع الصوت", + "VOLUME": "الصوت" + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 5921f839a..c2219873b 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -379,6 +379,15 @@ "LIGHT_THEME": "Светлая", "SYSTEM_THEME": "Сістэмная" }, + "AUDIO_PLAYER": { + "PLAY": "Прайграць", + "PAUSE": "Паўза", + "PREVIOUS_STATION": "Папярэдняя станцыя", + "NEXT_STATION": "Наступная станцыя", + "MUTE": "Адключыць гук", + "UNMUTE": "Уключыць гук", + "VOLUME": "Гучнасць" + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 885152535..12e1f41f6 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -379,6 +379,15 @@ "LIGHT_THEME": "Hell", "SYSTEM_THEME": "System" }, + "AUDIO_PLAYER": { + "PLAY": "Wiedergabe", + "PAUSE": "Pause", + "PREVIOUS_STATION": "Vorheriger Sender", + "NEXT_STATION": "Nächster Sender", + "MUTE": "Stummschalten", + "UNMUTE": "Stummschaltung aufheben", + "VOLUME": "Lautstärke" + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 69ec3d7ec..2a2178123 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -379,6 +379,15 @@ "LIGHT_THEME": "Ανοιχτό", "SYSTEM_THEME": "Σύστημα" }, + "AUDIO_PLAYER": { + "PLAY": "Αναπαραγωγή", + "PAUSE": "Παύση", + "PREVIOUS_STATION": "Προηγούμενος σταθμός", + "NEXT_STATION": "Επόμενος σταθμός", + "MUTE": "Σίγαση", + "UNMUTE": "Κατάργηση σίγασης", + "VOLUME": "Ένταση" + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index f708e035c..32201c923 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -379,6 +379,15 @@ "LIGHT_THEME": "Light", "SYSTEM_THEME": "System" }, + "AUDIO_PLAYER": { + "PLAY": "Play", + "PAUSE": "Pause", + "PREVIOUS_STATION": "Previous station", + "NEXT_STATION": "Next station", + "MUTE": "Mute", + "UNMUTE": "Unmute", + "VOLUME": "Volume" + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 3de1b6ec4..59dd5fc5b 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -379,6 +379,15 @@ "LIGHT_THEME": "Claro", "SYSTEM_THEME": "Sistema" }, + "AUDIO_PLAYER": { + "PLAY": "Reproducir", + "PAUSE": "Pausar", + "PREVIOUS_STATION": "Emisora anterior", + "NEXT_STATION": "Emisora siguiente", + "MUTE": "Silenciar", + "UNMUTE": "Activar sonido", + "VOLUME": "Volumen" + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 97b37ee9d..5ef0d425c 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -379,6 +379,15 @@ "LIGHT_THEME": "Clair", "SYSTEM_THEME": "Système" }, + "AUDIO_PLAYER": { + "PLAY": "Lecture", + "PAUSE": "Pause", + "PREVIOUS_STATION": "Station précédente", + "NEXT_STATION": "Station suivante", + "MUTE": "Muet", + "UNMUTE": "Activer le son", + "VOLUME": "Volume" + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index ed86645e7..afdcfe87d 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -379,6 +379,15 @@ "LIGHT_THEME": "Chiaro", "SYSTEM_THEME": "Sistema" }, + "AUDIO_PLAYER": { + "PLAY": "Riproduci", + "PAUSE": "Pausa", + "PREVIOUS_STATION": "Stazione precedente", + "NEXT_STATION": "Stazione successiva", + "MUTE": "Disattiva audio", + "UNMUTE": "Attiva audio", + "VOLUME": "Volume" + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 9ea348d08..2470efb2f 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -379,6 +379,15 @@ "LIGHT_THEME": "ライト", "SYSTEM_THEME": "システム" }, + "AUDIO_PLAYER": { + "PLAY": "再生", + "PAUSE": "一時停止", + "PREVIOUS_STATION": "前の局", + "NEXT_STATION": "次の局", + "MUTE": "ミュート", + "UNMUTE": "ミュート解除", + "VOLUME": "音量" + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 98212cdc1..e8380418e 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -379,6 +379,15 @@ "LIGHT_THEME": "밝게", "SYSTEM_THEME": "시스템" }, + "AUDIO_PLAYER": { + "PLAY": "재생", + "PAUSE": "일시정지", + "PREVIOUS_STATION": "이전 방송국", + "NEXT_STATION": "다음 방송국", + "MUTE": "음소거", + "UNMUTE": "음소거 해제", + "VOLUME": "볼륨" + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 4c7d2a961..4460ae762 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -379,6 +379,15 @@ "LIGHT_THEME": "Licht", "SYSTEM_THEME": "Systeem" }, + "AUDIO_PLAYER": { + "PLAY": "Afspelen", + "PAUSE": "Pauzeren", + "PREVIOUS_STATION": "Vorige zender", + "NEXT_STATION": "Volgende zender", + "MUTE": "Dempen", + "UNMUTE": "Dempen opheffen", + "VOLUME": "Volume" + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index fe97670d9..085571431 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -379,6 +379,15 @@ "LIGHT_THEME": "Jasny", "SYSTEM_THEME": "Systemowy" }, + "AUDIO_PLAYER": { + "PLAY": "Odtwórz", + "PAUSE": "Wstrzymaj", + "PREVIOUS_STATION": "Poprzednia stacja", + "NEXT_STATION": "Następna stacja", + "MUTE": "Wycisz", + "UNMUTE": "Wyłącz wyciszenie", + "VOLUME": "Głośność" + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 433e20503..2db9bc2ef 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -379,6 +379,15 @@ "LIGHT_THEME": "Claro", "SYSTEM_THEME": "Sistema" }, + "AUDIO_PLAYER": { + "PLAY": "Reproduzir", + "PAUSE": "Pausar", + "PREVIOUS_STATION": "Estação anterior", + "NEXT_STATION": "Próxima estação", + "MUTE": "Silenciar", + "UNMUTE": "Ativar som", + "VOLUME": "Volume" + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index d2bcaa0ff..e614fed48 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -379,6 +379,15 @@ "LIGHT_THEME": "Светлая", "SYSTEM_THEME": "Системная" }, + "AUDIO_PLAYER": { + "PLAY": "Воспроизвести", + "PAUSE": "Пауза", + "PREVIOUS_STATION": "Предыдущая станция", + "NEXT_STATION": "Следующая станция", + "MUTE": "Отключить звук", + "UNMUTE": "Включить звук", + "VOLUME": "Громкость" + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "Контейнер потока, вероятно, не поддерживается встроенным браузерным плеером", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 3e732d5d6..88969e6b2 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -379,6 +379,15 @@ "LIGHT_THEME": "Açık", "SYSTEM_THEME": "Sistem" }, + "AUDIO_PLAYER": { + "PLAY": "Oynat", + "PAUSE": "Duraklat", + "PREVIOUS_STATION": "Önceki istasyon", + "NEXT_STATION": "Sonraki istasyon", + "MUTE": "Sesi kapat", + "UNMUTE": "Sesi aç", + "VOLUME": "Ses seviyesi" + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 5380c69ae..764a79636 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -379,6 +379,15 @@ "LIGHT_THEME": "浅色", "SYSTEM_THEME": "跟随系统" }, + "AUDIO_PLAYER": { + "PLAY": "播放", + "PAUSE": "暂停", + "PREVIOUS_STATION": "上一个电台", + "NEXT_STATION": "下一个电台", + "MUTE": "静音", + "UNMUTE": "取消静音", + "VOLUME": "音量" + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 7a25f94d0..de4ba29f5 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -379,6 +379,15 @@ "LIGHT_THEME": "淺色", "SYSTEM_THEME": "跟隨系統" }, + "AUDIO_PLAYER": { + "PLAY": "播放", + "PAUSE": "暫停", + "PREVIOUS_STATION": "上一個電台", + "NEXT_STATION": "下一個電台", + "MUTE": "靜音", + "UNMUTE": "取消靜音", + "VOLUME": "音量" + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", diff --git a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts index a0fde9dba..a54bd623e 100644 --- a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts +++ b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts @@ -16,7 +16,9 @@ import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatSliderModule } from '@angular/material/slider'; +import { MatTooltip } from '@angular/material/tooltip'; import { Store } from '@ngrx/store'; +import { TranslatePipe } from '@ngx-translate/core'; import { ChannelActions } from '@iptvnator/m3u-state'; @Component({ @@ -67,6 +69,12 @@ import { ChannelActions } from '@iptvnator/m3u-state';
- - + @@ -109,7 +158,14 @@ import { ChannelActions } from '@iptvnator/m3u-state';
`, styleUrls: ['./audio-player.component.scss'], - imports: [MatSliderModule, MatIconModule, MatButtonModule, FormsModule], + imports: [ + FormsModule, + MatButtonModule, + MatIconModule, + MatSliderModule, + MatTooltip, + TranslatePipe, + ], }) export class AudioPlayerComponent { readonly icon = input(''); From 6891a1ca30e8fd2f7abdd6c45a2c69a5a544464e Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 19 May 2026 09:15:35 +0200 Subject: [PATCH 19/24] i18n(epg): translate multi-EPG toolbar tooltips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/web/src/assets/i18n/ar.json | 6 +++ apps/web/src/assets/i18n/ary.json | 6 +++ apps/web/src/assets/i18n/by.json | 6 +++ apps/web/src/assets/i18n/de.json | 6 +++ apps/web/src/assets/i18n/el.json | 6 +++ apps/web/src/assets/i18n/en.json | 6 +++ apps/web/src/assets/i18n/es.json | 6 +++ apps/web/src/assets/i18n/fr.json | 6 +++ apps/web/src/assets/i18n/it.json | 6 +++ apps/web/src/assets/i18n/ja.json | 6 +++ apps/web/src/assets/i18n/ko.json | 6 +++ apps/web/src/assets/i18n/nl.json | 6 +++ apps/web/src/assets/i18n/pl.json | 6 +++ apps/web/src/assets/i18n/pt.json | 6 +++ apps/web/src/assets/i18n/ru.json | 6 +++ apps/web/src/assets/i18n/tr.json | 6 +++ apps/web/src/assets/i18n/zh.json | 6 +++ apps/web/src/assets/i18n/zhtw.json | 6 +++ .../multi-epg-container.component.html | 38 +++++++++++++++++-- 19 files changed, 142 insertions(+), 4 deletions(-) diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index b8b309954..1fc55f155 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -528,6 +528,12 @@ "TITLE": "الدليل الإلكتروني للبرامج", "NEXT_DAY": "اليوم التالي", "PREVIOUS_DAY": "اليوم السابق", + "ZOOM_IN": "تكبير", + "ZOOM_OUT": "تصغير", + "FILTER_CHANNELS": "تصفية القنوات", + "CLOSE_FILTER": "إغلاق التصفية", + "SEARCH_PROGRAMS": "البحث في البرامج", + "CLOSE_PROGRAM_SEARCH": "إغلاق بحث البرامج", "LIVE_NOW": "يُبث الآن", "LIVE_STREAM": "بث مباشر", "TIMESHIFT_AVAILABLE": "التحريك الزمني متاح، انقر للتشغيل", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 3b72f27bf..0a8ea6cae 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -528,6 +528,12 @@ "TITLE": "دليل البرامج الإلكتروني", "NEXT_DAY": "اليوم التالي", "PREVIOUS_DAY": "اليوم السابق", + "ZOOM_IN": "كبر", + "ZOOM_OUT": "صغر", + "FILTER_CHANNELS": "فلتر القنوات", + "CLOSE_FILTER": "سد الفلتر", + "SEARCH_PROGRAMS": "قلب على البرامج", + "CLOSE_PROGRAM_SEARCH": "سد البحث على البرامج", "LIVE_NOW": "مباشر الآن", "LIVE_STREAM": "بث مباشر", "TIMESHIFT_AVAILABLE": "التأجيل الزمني متوفر، ضغط باش تلعب", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index c2219873b..dd7a84458 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -528,6 +528,12 @@ "TITLE": "Электронная праграма перадач", "NEXT_DAY": "Наступны дзень", "PREVIOUS_DAY": "Папярэдні дзень", + "ZOOM_IN": "Павялічыць маштаб", + "ZOOM_OUT": "Паменшыць маштаб", + "FILTER_CHANNELS": "Фільтраваць каналы", + "CLOSE_FILTER": "Закрыць фільтр", + "SEARCH_PROGRAMS": "Пошук перадач", + "CLOSE_PROGRAM_SEARCH": "Закрыць пошук перадач", "LIVE_NOW": "Цяпер", "LIVE_STREAM": "Прамая трансляцыя", "TIMESHIFT_AVAILABLE": "Даступная функцыя архіва (timeshift). Націсніце на перадачу для прайгравання.", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 12e1f41f6..aed04b4ac 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -528,6 +528,12 @@ "TITLE": "Elektronischer Programmführer", "NEXT_DAY": "Zum nächsten Tag", "PREVIOUS_DAY": "Zum vorherigen Tag", + "ZOOM_IN": "Vergrößern", + "ZOOM_OUT": "Verkleinern", + "FILTER_CHANNELS": "Sender filtern", + "CLOSE_FILTER": "Filter schließen", + "SEARCH_PROGRAMS": "Sendungen suchen", + "CLOSE_PROGRAM_SEARCH": "Sendungssuche schließen", "LIVE_NOW": "Live jetzt", "LIVE_STREAM": "Live stream", "TIMESHIFT_AVAILABLE": "Die Timeshift-Funktion (Archiv) ist verfügbar, klicken Sie an um abzuspielen", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 2a2178123..2c7aa3ae1 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -528,6 +528,12 @@ "TITLE": "Ηλεκτρονικός Οδηγός Προγράμματος", "NEXT_DAY": "Επόμενη μέρα", "PREVIOUS_DAY": "Προηγούμενη ημέρα", + "ZOOM_IN": "Μεγέθυνση", + "ZOOM_OUT": "Σμίκρυνση", + "FILTER_CHANNELS": "Φιλτράρισμα καναλιών", + "CLOSE_FILTER": "Κλείσιμο φίλτρου", + "SEARCH_PROGRAMS": "Αναζήτηση προγραμμάτων", + "CLOSE_PROGRAM_SEARCH": "Κλείσιμο αναζήτησης προγραμμάτων", "LIVE_NOW": "Ζωντανά τώρα", "LIVE_STREAM": "Ζωντανή ροή", "TIMESHIFT_AVAILABLE": "Η χρονική μετατόπιση είναι διαθέσιμη, κάντε κλικ για αναπαραγωγή.", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 32201c923..fb69a04b6 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -528,6 +528,12 @@ "TITLE": "Electronic Program Guide", "NEXT_DAY": "Next day", "PREVIOUS_DAY": "Previous day", + "ZOOM_IN": "Zoom in", + "ZOOM_OUT": "Zoom out", + "FILTER_CHANNELS": "Filter channels", + "CLOSE_FILTER": "Close filter", + "SEARCH_PROGRAMS": "Search programs", + "CLOSE_PROGRAM_SEARCH": "Close program search", "LIVE_NOW": "Live now", "LIVE_STREAM": "Live stream", "TIMESHIFT_AVAILABLE": "Timeshift is available, click to play", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 59dd5fc5b..4c5d8737d 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -528,6 +528,12 @@ "TITLE": "Guía electrónica de programación", "NEXT_DAY": "Día siguiente", "PREVIOUS_DAY": "Día anterior", + "ZOOM_IN": "Acercar", + "ZOOM_OUT": "Alejar", + "FILTER_CHANNELS": "Filtrar canales", + "CLOSE_FILTER": "Cerrar filtro", + "SEARCH_PROGRAMS": "Buscar programas", + "CLOSE_PROGRAM_SEARCH": "Cerrar búsqueda de programas", "LIVE_NOW": "Ahora en vivo", "LIVE_STREAM": "Transmisión en vivo", "TIMESHIFT_AVAILABLE": "Timeshift está disponible, clic para reproducir", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 5ef0d425c..298b822d5 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -528,6 +528,12 @@ "TITLE": "Guide électronique des programmes", "NEXT_DAY": "Jour suivant", "PREVIOUS_DAY": "Jour précédent", + "ZOOM_IN": "Zoom avant", + "ZOOM_OUT": "Zoom arrière", + "FILTER_CHANNELS": "Filtrer les chaînes", + "CLOSE_FILTER": "Fermer le filtre", + "SEARCH_PROGRAMS": "Rechercher des programmes", + "CLOSE_PROGRAM_SEARCH": "Fermer la recherche de programmes", "LIVE_NOW": "En direct", "LIVE_STREAM": "Diffusion en direct", "TIMESHIFT_AVAILABLE": "Contrôle du direct disponible, cliquez pour lire", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index afdcfe87d..0d108a6c1 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -528,6 +528,12 @@ "TITLE": "Guida elettronica programmi", "NEXT_DAY": "Giorno successivo", "PREVIOUS_DAY": "Giorno precedente", + "ZOOM_IN": "Ingrandisci", + "ZOOM_OUT": "Riduci", + "FILTER_CHANNELS": "Filtra canali", + "CLOSE_FILTER": "Chiudi filtro", + "SEARCH_PROGRAMS": "Cerca programmi", + "CLOSE_PROGRAM_SEARCH": "Chiudi ricerca programmi", "LIVE_NOW": "Live attuale", "LIVE_STREAM": "Stream live", "TIMESHIFT_AVAILABLE": "Disponibile il timeshift, fai clic per riprodurre", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 2470efb2f..576e8ad9a 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -528,6 +528,12 @@ "TITLE": "電子番組表", "NEXT_DAY": "翌日", "PREVIOUS_DAY": "前日", + "ZOOM_IN": "拡大", + "ZOOM_OUT": "縮小", + "FILTER_CHANNELS": "チャンネルをフィルター", + "CLOSE_FILTER": "フィルターを閉じる", + "SEARCH_PROGRAMS": "番組を検索", + "CLOSE_PROGRAM_SEARCH": "番組検索を閉じる", "LIVE_NOW": "現在放送中", "LIVE_STREAM": "ライブストリーム", "TIMESHIFT_AVAILABLE": "タイムシフトが利用可能です。クリックして再生", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index e8380418e..8a1f43f85 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -528,6 +528,12 @@ "TITLE": "전자 프로그램 가이드", "NEXT_DAY": "다음날", "PREVIOUS_DAY": "전날", + "ZOOM_IN": "확대", + "ZOOM_OUT": "축소", + "FILTER_CHANNELS": "채널 필터", + "CLOSE_FILTER": "필터 닫기", + "SEARCH_PROGRAMS": "프로그램 검색", + "CLOSE_PROGRAM_SEARCH": "프로그램 검색 닫기", "LIVE_NOW": "Live now", "LIVE_STREAM": "Live stream", "TIMESHIFT_AVAILABLE": "Timeshift를 사용할 수 있습니다, 클릭하여 재생합니다", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 4460ae762..74a39ec26 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -528,6 +528,12 @@ "TITLE": "TV-Gids", "NEXT_DAY": "Volgende dag", "PREVIOUS_DAY": "Vorige dag", + "ZOOM_IN": "Inzoomen", + "ZOOM_OUT": "Uitzoomen", + "FILTER_CHANNELS": "Kanalen filteren", + "CLOSE_FILTER": "Filter sluiten", + "SEARCH_PROGRAMS": "Programma's zoeken", + "CLOSE_PROGRAM_SEARCH": "Programmazoekopdracht sluiten", "LIVE_NOW": "Nu live", "LIVE_STREAM": "Live stream", "TIMESHIFT_AVAILABLE": "Doorspoelen is beschikbaar, klik om af te spelen", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 085571431..49de2a00a 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -528,6 +528,12 @@ "TITLE": "Elektroniczny przewodnik po programach", "NEXT_DAY": "Następny dzień", "PREVIOUS_DAY": "Poprzedni dzień", + "ZOOM_IN": "Powiększ", + "ZOOM_OUT": "Pomniejsz", + "FILTER_CHANNELS": "Filtruj kanały", + "CLOSE_FILTER": "Zamknij filtr", + "SEARCH_PROGRAMS": "Szukaj programów", + "CLOSE_PROGRAM_SEARCH": "Zamknij wyszukiwanie programów", "LIVE_NOW": "Na żywo teraz", "LIVE_STREAM": "Transmisja na żywo", "TIMESHIFT_AVAILABLE": "Dostępne jest przesunięcie czasowe, kliknij, aby odtworzyć", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 2db9bc2ef..7868e0dd9 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -528,6 +528,12 @@ "TITLE": "Guia Eletrônico de Programação", "NEXT_DAY": "Próximo dia", "PREVIOUS_DAY": "Dia anterior", + "ZOOM_IN": "Mais zoom", + "ZOOM_OUT": "Menos zoom", + "FILTER_CHANNELS": "Filtrar canais", + "CLOSE_FILTER": "Fechar filtro", + "SEARCH_PROGRAMS": "Pesquisar programas", + "CLOSE_PROGRAM_SEARCH": "Fechar pesquisa de programas", "LIVE_NOW": "Ao vivo agora", "LIVE_STREAM": "Transmissão ao vivo", "TIMESHIFT_AVAILABLE": "Timeshift está disponível, clique para reproduzir", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index e614fed48..61a5a301d 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -528,6 +528,12 @@ "TITLE": "Электронный программный гид", "NEXT_DAY": "Следующий день", "PREVIOUS_DAY": "Предыдущий день", + "ZOOM_IN": "Увеличить", + "ZOOM_OUT": "Уменьшить", + "FILTER_CHANNELS": "Фильтровать каналы", + "CLOSE_FILTER": "Закрыть фильтр", + "SEARCH_PROGRAMS": "Поиск программ", + "CLOSE_PROGRAM_SEARCH": "Закрыть поиск программ", "LIVE_NOW": "Сейчас", "LIVE_STREAM": "Прямая трансляция", "TIMESHIFT_AVAILABLE": "Доступна функция архива (timeshift). Нажмите на передачу для воспроизведения.", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 88969e6b2..ccfe6aa61 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -528,6 +528,12 @@ "TITLE": "Elektronik Program Rehberi", "NEXT_DAY": "Sonraki gün", "PREVIOUS_DAY": "Önceki gün", + "ZOOM_IN": "Yakınlaştır", + "ZOOM_OUT": "Uzaklaştır", + "FILTER_CHANNELS": "Kanalları filtrele", + "CLOSE_FILTER": "Filtreyi kapat", + "SEARCH_PROGRAMS": "Programları ara", + "CLOSE_PROGRAM_SEARCH": "Program aramayı kapat", "LIVE_NOW": "Şu an canlı", "LIVE_STREAM": "Canlı yayın", "TIMESHIFT_AVAILABLE": "Zaman kaydırma mevcut, tıklayarak oynat", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 764a79636..79f2ba4b1 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -528,6 +528,12 @@ "TITLE": "电子节目指南", "NEXT_DAY": "明天", "PREVIOUS_DAY": "昨天", + "ZOOM_IN": "放大", + "ZOOM_OUT": "缩小", + "FILTER_CHANNELS": "筛选频道", + "CLOSE_FILTER": "关闭筛选", + "SEARCH_PROGRAMS": "搜索节目", + "CLOSE_PROGRAM_SEARCH": "关闭节目搜索", "LIVE_NOW": "正在播放", "LIVE_STREAM": "正在直播", "TIMESHIFT_AVAILABLE": "时移功能可用,点击播放", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index de4ba29f5..159af6f57 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -528,6 +528,12 @@ "TITLE": "電子節目指南", "NEXT_DAY": "下一天", "PREVIOUS_DAY": "上一天", + "ZOOM_IN": "放大", + "ZOOM_OUT": "縮小", + "FILTER_CHANNELS": "篩選頻道", + "CLOSE_FILTER": "關閉篩選", + "SEARCH_PROGRAMS": "搜尋節目", + "CLOSE_PROGRAM_SEARCH": "關閉節目搜尋", "LIVE_NOW": "現在播放", "LIVE_STREAM": "現場直播", "TIMESHIFT_AVAILABLE": "時間平移功能可用,點選以播放", diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.html b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.html index 9242ff3ef..83901224d 100644 --- a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.html +++ b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.html @@ -23,10 +23,22 @@ > chevron_right - - @@ -53,7 +65,14 @@ @@ -113,7 +132,18 @@ From 8091dec07437a5bb0f58348b587986678585f3e6 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 19 May 2026 09:32:13 +0200 Subject: [PATCH 20/24] =?UTF-8?q?feat(dashboard):=20show=20S=C2=B7E=20badg?= =?UTF-8?q?e=20on=20Continue=20Watching=20series=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/web/src/assets/i18n/ar.json | 1 + apps/web/src/assets/i18n/ary.json | 1 + apps/web/src/assets/i18n/by.json | 1 + apps/web/src/assets/i18n/de.json | 1 + apps/web/src/assets/i18n/el.json | 1 + apps/web/src/assets/i18n/en.json | 1 + apps/web/src/assets/i18n/es.json | 1 + apps/web/src/assets/i18n/fr.json | 1 + apps/web/src/assets/i18n/it.json | 1 + apps/web/src/assets/i18n/ja.json | 1 + apps/web/src/assets/i18n/ko.json | 1 + apps/web/src/assets/i18n/nl.json | 1 + apps/web/src/assets/i18n/pl.json | 1 + apps/web/src/assets/i18n/pt.json | 1 + apps/web/src/assets/i18n/ru.json | 1 + apps/web/src/assets/i18n/tr.json | 1 + apps/web/src/assets/i18n/zh.json | 1 + apps/web/src/assets/i18n/zhtw.json | 1 + .../src/lib/dashboard-data.service.spec.ts | 81 +++++++++++++++++++ .../src/lib/dashboard-data.service.ts | 43 ++++++++-- .../lib/rails/dashboard-rail.component.html | 17 ++-- .../lib/rails/dashboard-rail.component.scss | 27 +++++++ .../src/lib/rails/dashboard-rail.component.ts | 7 ++ .../workspace-dashboard-rails.component.ts | 13 +++ 24 files changed, 193 insertions(+), 13 deletions(-) diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 1fc55f155..b7e780b4f 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "مباشر", "TYPE_MOVIE": "فيلم", "TYPE_SERIES": "مسلسل", + "SEASON_EPISODE_BADGE": "م{{season}}·ح{{episode}}", "NOT_YET_SYNCED": "لم تتم المزامنة بعد" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 0a8ea6cae..6a151fdf1 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "مباشر", "TYPE_MOVIE": "فيلم", "TYPE_SERIES": "سلسلة", + "SEASON_EPISODE_BADGE": "م{{season}}·ح{{episode}}", "NOT_YET_SYNCED": "مازال ما تزامنش" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index dd7a84458..4a406a4ac 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "Прамы эфір", "TYPE_MOVIE": "Фільм", "TYPE_SERIES": "Серыял", + "SEASON_EPISODE_BADGE": "С{{season}}·Э{{episode}}", "NOT_YET_SYNCED": "Яшчэ не сінхранізавана" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index aed04b4ac..0feac6aeb 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "Live", "TYPE_MOVIE": "Film", "TYPE_SERIES": "Serie", + "SEASON_EPISODE_BADGE": "St{{season}} · F{{episode}}", "NOT_YET_SYNCED": "Noch nicht synchronisiert" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 2c7aa3ae1..51993f773 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "Ζωντανά", "TYPE_MOVIE": "Ταινία", "TYPE_SERIES": "Σειρά", + "SEASON_EPISODE_BADGE": "Σ{{season}}·Ε{{episode}}", "NOT_YET_SYNCED": "Δεν έχει συγχρονιστεί ακόμα" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index fb69a04b6..8d4bc009b 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "Live", "TYPE_MOVIE": "Movie", "TYPE_SERIES": "Series", + "SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}", "NOT_YET_SYNCED": "Not yet synced" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 4c5d8737d..e7dee60af 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "En vivo", "TYPE_MOVIE": "Película", "TYPE_SERIES": "Serie", + "SEASON_EPISODE_BADGE": "T{{season}}·E{{episode}}", "NOT_YET_SYNCED": "Aún no sincronizado" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 298b822d5..ce46ce76a 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "Direct", "TYPE_MOVIE": "Film", "TYPE_SERIES": "Série", + "SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}", "NOT_YET_SYNCED": "Pas encore synchronisé" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 0d108a6c1..8d8e5f4f2 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "Live", "TYPE_MOVIE": "Film", "TYPE_SERIES": "Serie", + "SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}", "NOT_YET_SYNCED": "Non ancora sincronizzato" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 576e8ad9a..5ebf361c4 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "ライブ", "TYPE_MOVIE": "映画", "TYPE_SERIES": "シリーズ", + "SEASON_EPISODE_BADGE": "S{{season}}・E{{episode}}", "NOT_YET_SYNCED": "未同期" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 8a1f43f85..ce94623ca 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "라이브", "TYPE_MOVIE": "영화", "TYPE_SERIES": "시리즈", + "SEASON_EPISODE_BADGE": "시즌{{season}} {{episode}}화", "NOT_YET_SYNCED": "아직 동기화되지 않음" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 74a39ec26..5af92d3b4 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "Live", "TYPE_MOVIE": "Film", "TYPE_SERIES": "Series", + "SEASON_EPISODE_BADGE": "S{{season}}·A{{episode}}", "NOT_YET_SYNCED": "Nog niet gesynchroniseerd" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 49de2a00a..4731dfc4f 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "Na żywo", "TYPE_MOVIE": "Film", "TYPE_SERIES": "Serial", + "SEASON_EPISODE_BADGE": "S{{season}}·O{{episode}}", "NOT_YET_SYNCED": "Jeszcze niezsynchronizowane" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 7868e0dd9..15039fbbc 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "Ao vivo", "TYPE_MOVIE": "Filme", "TYPE_SERIES": "Série", + "SEASON_EPISODE_BADGE": "T{{season}}·E{{episode}}", "NOT_YET_SYNCED": "Ainda não sincronizado" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 61a5a301d..bfd900760 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "ТВ", "TYPE_MOVIE": "Фильм", "TYPE_SERIES": "Сериал", + "SEASON_EPISODE_BADGE": "С{{season}}·Э{{episode}}", "NOT_YET_SYNCED": "Еще не синхронизировано" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index ccfe6aa61..9113ae998 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "Canlı", "TYPE_MOVIE": "Film", "TYPE_SERIES": "Dizi", + "SEASON_EPISODE_BADGE": "S{{season}}·B{{episode}}", "NOT_YET_SYNCED": "Henüz eşitlenmedi" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 79f2ba4b1..304df9989 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "直播", "TYPE_MOVIE": "电影", "TYPE_SERIES": "剧集", + "SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}", "NOT_YET_SYNCED": "尚未同步" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 159af6f57..f36ae3913 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "直播", "TYPE_MOVIE": "電影", "TYPE_SERIES": "影集", + "SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}", "NOT_YET_SYNCED": "尚未同步" }, "SHELL": { diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index 24657818d..9f304671b 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -619,6 +619,87 @@ describe('DashboardDataService', () => { const seriesPos = service.getPlaybackPositionForItem(series!); expect(seriesPos?.contentType).toBe('episode'); expect(seriesPos?.positionSeconds).toBe(720); + // Season/episode metadata travels with the position so cards can + // render an "S2 · E5" badge without an extra round-trip. + expect(seriesPos?.seasonNumber).toBe(2); + expect(seriesPos?.episodeNumber).toBe(4); + }); + + it('resolves the latest episode position for series whose recent_items row carries the series id', async () => { + // The Xtream series landing page records the recent item with the + // SERIES id, while each played episode saves its position keyed by + // the EPISODE id (with seriesXtreamId pointing back). The lookup + // must follow seriesXtreamId so both card shapes show progress. + dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([ + { + id: 200, + category_id: 30, + title: 'Shadow Bay', + rating: '7.5', + viewed_at: '2026-05-01T09:00:00.000Z', + poster_url: 'https://example.com/shadow-bay.png', + xtream_id: 4000, // series id + type: 'series', + playlist_id: 'xtream-C', + playlist_name: 'Xtream C', + }, + ]); + playlistsSignal.set([ + ...playlistsSignal(), + { + _id: 'xtream-C', + title: 'Xtream C', + count: 1, + importDate: '2026-01-01T00:00:00.000Z', + autoRefresh: false, + serverUrl: 'https://c.example.com', + }, + ]); + playbackPositionsMock.getAllPlaybackPositions.mockImplementation( + (playlistId: string) => { + if (playlistId === 'xtream-C') { + return Promise.resolve([ + // Earlier episode — should NOT win even if it also + // matches the series, because the newer one is more + // relevant for "where you left off". + { + contentXtreamId: 4001, + contentType: 'episode', + seriesXtreamId: 4000, + seasonNumber: 1, + episodeNumber: 1, + positionSeconds: 60, + durationSeconds: 1800, + playlistId, + updatedAt: '2026-04-25T12:00:00.000Z', + } as PlaybackPositionData, + { + contentXtreamId: 4007, + contentType: 'episode', + seriesXtreamId: 4000, + seasonNumber: 3, + episodeNumber: 7, + positionSeconds: 540, + durationSeconds: 1800, + playlistId, + updatedAt: '2026-05-01T09:30:00.000Z', + } as PlaybackPositionData, + ]); + } + return Promise.resolve([]); + } + ); + + await service.reloadGlobalRecentItems(); + await service.reloadPlaybackPositions(); + + const series = service + .globalRecentVodItems() + .find((item) => item.title === 'Shadow Bay'); + const position = service.getPlaybackPositionForItem(series!); + expect(position?.contentType).toBe('episode'); + expect(position?.seasonNumber).toBe(3); + expect(position?.episodeNumber).toBe(7); }); it('exposes a live-only slice of global favorites so the dashboard can promote favorited channels into the Live rail', async () => { diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index 7ba38114d..62cac219e 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -248,8 +248,6 @@ export class DashboardDataService { if (item.type !== 'movie' && item.type !== 'series') { return null; } - const positionType: 'vod' | 'episode' = - item.type === 'series' ? 'episode' : 'vod'; const xtreamId = typeof item.xtream_id === 'number' ? item.xtream_id @@ -257,12 +255,41 @@ export class DashboardDataService { if (!Number.isFinite(xtreamId)) { return null; } - const key = playbackPositionMapKey( - item.playlist_id, - xtreamId, - positionType - ); - return this.playbackPositionsMap().get(key) ?? null; + + if (item.type === 'movie') { + const key = playbackPositionMapKey( + item.playlist_id, + xtreamId, + 'vod' + ); + return this.playbackPositionsMap().get(key) ?? null; + } + + // Series recent_items rows carry either the series id (the series + // landing page writes the series itself) or, for direct-play flows, + // the episode id. Match both shapes and prefer the most recently + // updated episode position so the card can show resume progress and + // a "S2 · E5" badge regardless of how the row was originally saved. + let best: PlaybackPositionData | null = null; + for (const position of this.playbackPositionsMap().values()) { + if (position.contentType !== 'episode') continue; + if ( + position.playlistId && + position.playlistId !== item.playlist_id + ) { + continue; + } + const matchesEpisode = position.contentXtreamId === xtreamId; + const matchesSeries = position.seriesXtreamId === xtreamId; + if (!matchesEpisode && !matchesSeries) continue; + if ( + !best || + (position.updatedAt ?? '') > (best.updatedAt ?? '') + ) { + best = position; + } + } + return best; } /** diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html index 4a9c668c0..6ca18fa11 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html @@ -189,11 +189,18 @@ {{ card.title }} - @if (card.subtitle) { - {{ - card.subtitle - }} - } + + @if (card.episodeBadge) { + {{ + card.episodeBadge + }} + } + @if (card.subtitle) { + {{ + card.subtitle + }} + } +
diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss index efa43f327..b459f7d29 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss @@ -350,7 +350,34 @@ transition: color 0.15s ease; } +.rail__card-meta-row { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.rail__card-episode { + flex: 0 0 auto; + font-size: 0.66rem; + font-weight: 700; + line-height: 1; + padding: 2px 6px; + border-radius: 4px; + background: color-mix( + in srgb, + var(--app-selection-color, #2f86ff) 14%, + transparent + ); + color: var(--app-selection-color, #2f86ff); + letter-spacing: 0.02em; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + .rail__card-subtitle { + flex: 1 1 auto; + min-width: 0; font-size: 0.72rem; font-weight: 500; line-height: 1.4; diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts index a3d221740..16c1c6233 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts @@ -54,6 +54,13 @@ export interface DashboardRailCard { * position leave this unset. */ watchProgress?: number | null; + + /** + * Localised "S{n} · E{n}" badge for series with a tracked episode + * position. Renders as a small chip next to the card subtitle so the + * user can see which episode they were on without opening the show. + */ + episodeBadge?: string | null; } /** diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index ea2419443..2216b91da 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -615,6 +615,18 @@ export class WorkspaceDashboardRailsComponent { private toRecentCard(item: GlobalRecentItem): DashboardRailCard { const position = this.data.getPlaybackPositionForItem(item); const watchProgress = playbackProgressPercent(position); + const episodeBadge = + item.type === 'series' && + position?.seasonNumber != null && + position?.episodeNumber != null + ? this.translate.instant( + 'WORKSPACE.DASHBOARD.SEASON_EPISODE_BADGE', + { + season: position.seasonNumber, + episode: position.episodeNumber, + } + ) + : null; return { id: `recent-${item.id}-${item.playlist_id}-${item.viewed_at}`, title: item.title, @@ -625,6 +637,7 @@ export class WorkspaceDashboardRailsComponent { link: this.data.getRecentItemLink(item), state: this.data.getRecentItemNavigationState(item), watchProgress, + episodeBadge, }; } From a8253c580179a5ee6fa89f08e04f112e8849a376 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 19 May 2026 22:07:26 +0200 Subject: [PATCH 21/24] test(e2e): adapt to v0.22 dialog + theme picker redesigns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/electron-test-fixtures.ts | 104 ++++++++++-------- apps/web-e2e/src/basic.e2e.ts | 3 +- apps/web-e2e/src/self-hosted.e2e.ts | 8 +- apps/web-e2e/src/settings.e2e.ts | 15 ++- apps/web-e2e/src/stalker.e2e.ts | 3 +- apps/web-e2e/src/xtream.e2e.ts | 5 +- 6 files changed, 86 insertions(+), 52 deletions(-) diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index 6757825e4..9589992ad 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -273,7 +273,7 @@ export async function importM3uPlaylistFromNativeDialog( await clickDialogCategoryOption(dialog, /^m3u$/i); await clickDialogSubtypeOption( dialog, - /add\s+via\s+file\s+upload/i, + /m3u\s*file|add\s+via\s+file\s+upload/i, 'mat-button-toggle[value="file"]' ); const fileInput = dialog.locator('input[type="file"][name="playlist"]'); @@ -364,7 +364,7 @@ export async function addXtreamPortal( const dialog = await getActiveDialog(page); await clickDialogCategoryOption( dialog, - /^xtream$/i, + /xtream(\s+credentials)?/i, 'mat-button-toggle[value="xtream"]' ); @@ -409,57 +409,46 @@ async function setInputValue(input: Locator, value: string): Promise { await expect(input).toHaveValue(value); } -async function clickDialogCategoryOption( +/** + * Pick a source method on the Add Playlist dialog. Since v0.22 the dialog + * exposes a single flat 5-card radiogroup ("M3U URL", "M3U file", "Xtream + * credentials", "Stalker portal", "Raw m3u text") instead of the legacy + * 2-level category × subtype tabs/toggles. The helper still falls through + * to the old tab/button/legacy-selector lookups so we don't have to rewrite + * every call-site on each redesign — but the radio-based picker is the + * primary path now. + */ +async function clickDialogMethodOption( dialog: Locator, label: RegExp, legacySelector?: string ): Promise { - await clickDialogSegmentedOption( - dialog, - 'Playlist category', - label, - legacySelector - ); -} - -async function clickDialogSubtypeOption( - dialog: Locator, - label: RegExp, - legacySelector?: string -): Promise { - await clickDialogSegmentedOption( - dialog, - 'M3U source', - label, - legacySelector - ); -} - -async function clickDialogSegmentedOption( - dialog: Locator, - tablistLabel: string, - label: RegExp, - legacySelector?: string -): Promise { - const tablist = dialog - .locator(`[role="tablist"][aria-label="${tablistLabel}"]`) + const optionByRadio = dialog + .getByRole('radio', { name: label }) .first(); + if ((await optionByRadio.count()) > 0) { + await optionByRadio.click(); + return; + } - if ((await tablist.count()) > 0) { - const optionByTabRole = tablist - .getByRole('tab', { name: label }) + for (const tablistLabel of ['Source method', 'Playlist category', 'M3U source']) { + const tablist = dialog + .locator(`[role="tablist"][aria-label="${tablistLabel}"]`) .first(); - - if ((await optionByTabRole.count()) > 0) { - await optionByTabRole.click(); - return; + if ((await tablist.count()) > 0) { + const optionByTabRole = tablist + .getByRole('tab', { name: label }) + .first(); + if ((await optionByTabRole.count()) > 0) { + await optionByTabRole.click(); + return; + } } } const optionByGlobalTabRole = dialog .getByRole('tab', { name: label }) .first(); - if ((await optionByGlobalTabRole.count()) > 0) { await optionByGlobalTabRole.click(); return; @@ -468,7 +457,6 @@ async function clickDialogSegmentedOption( const optionByButtonRole = dialog .getByRole('button', { name: label }) .first(); - if ((await optionByButtonRole.count()) > 0) { await optionByButtonRole.click(); return; @@ -476,13 +464,41 @@ async function clickDialogSegmentedOption( if (!legacySelector) { throw new Error( - `Could not find dialog option matching ${label} in "${tablistLabel}".` + `Could not find dialog option matching ${label}.` ); } await dialog.locator(legacySelector).click(); } +// Backwards-compat shims for the legacy two-step flow. Both helpers now route +// through `clickDialogMethodOption` and use the patterns of the new flat +// picker. `clickDialogCategoryOption` is a no-op for "M3U" since the new +// picker has no parent "M3U" tile — callers immediately follow up with a +// `clickDialogSubtypeOption` which picks the concrete M3U URL/file/text card. +async function clickDialogCategoryOption( + dialog: Locator, + label: RegExp, + legacySelector?: string +): Promise { + // The legacy "M3U" category is now implicit — the new picker has no + // standalone "M3U" radio; callers always immediately specialise via + // `clickDialogSubtypeOption` below. Skip the click to avoid matching + // unrelated radios (e.g. "M3U URL" when caller wanted "M3U file"). + if (/^\^?m3u\$?$/i.test(label.source)) { + return; + } + await clickDialogMethodOption(dialog, label, legacySelector); +} + +async function clickDialogSubtypeOption( + dialog: Locator, + label: RegExp, + legacySelector?: string +): Promise { + await clickDialogMethodOption(dialog, label, legacySelector); +} + export async function addStalkerPortal( page: Page, options: { @@ -501,7 +517,7 @@ export async function addStalkerPortal( const dialog = await getActiveDialog(page); await clickDialogCategoryOption( dialog, - /^stalker$/i, + /stalker(\s+portal)?/i, 'mat-button-toggle[value="stalker"]' ); @@ -576,7 +592,7 @@ export async function importM3uPlaylistFromUrl( await clickDialogCategoryOption(dialog, /^m3u$/i); await clickDialogSubtypeOption( dialog, - /add\s+via\s+url/i, + /m3u\s*url|add\s+via\s+url/i, 'mat-button-toggle[value="url"]' ); diff --git a/apps/web-e2e/src/basic.e2e.ts b/apps/web-e2e/src/basic.e2e.ts index 6a6262ac8..6e8040ed5 100644 --- a/apps/web-e2e/src/basic.e2e.ts +++ b/apps/web-e2e/src/basic.e2e.ts @@ -13,7 +13,8 @@ test('basic test', async ({ page }) => { await page.getByRole('button', { name: 'Add playlist' }).click(); const dialog = page.locator('mat-dialog-container'); await expect(dialog).toBeVisible(); - await dialog.getByRole('tab', { name: 'Add via file upload' }).click(); + // v0.22 redesign: tabs were replaced with a flat 5-card radio picker. + await dialog.getByRole('radio', { name: /M3U file/i }).click(); await page.setInputFiles('input[type="file"]', fixturePath); const addButton = dialog.getByRole('button', { name: 'Add playlist', diff --git a/apps/web-e2e/src/self-hosted.e2e.ts b/apps/web-e2e/src/self-hosted.e2e.ts index 0529f4baa..332bb6479 100644 --- a/apps/web-e2e/src/self-hosted.e2e.ts +++ b/apps/web-e2e/src/self-hosted.e2e.ts @@ -36,7 +36,10 @@ async function addXtreamPortal(page: Page): Promise { await page.getByRole('button', { name: 'Add playlist' }).click(); const dialog = page.locator('mat-dialog-container'); await expect(dialog).toBeVisible(); - await dialog.getByRole('tab', { name: 'Xtream', exact: true }).click(); + // v0.22 redesign: tabs were replaced with a flat 5-card radio picker. + await dialog + .getByRole('radio', { name: /Xtream credentials/i }) + .click(); await dialog.locator('#title').fill('Self-hosted Xtream'); await dialog.locator('#serverUrl').fill(XTREAM_MOCK_SERVER); @@ -52,7 +55,8 @@ async function addStalkerPortal(page: Page): Promise { await page.getByRole('button', { name: 'Add playlist' }).click(); const dialog = page.locator('mat-dialog-container'); await expect(dialog).toBeVisible(); - await dialog.getByRole('tab', { name: 'Stalker', exact: true }).click(); + // v0.22 redesign: tabs were replaced with a flat 5-card radio picker. + await dialog.getByRole('radio', { name: /Stalker portal/i }).click(); await setInputValue(dialog.locator('input#title'), 'Self-hosted Stalker'); await setInputValue(dialog.locator('input#portalUrl'), STALKER_PORTAL_URL); diff --git a/apps/web-e2e/src/settings.e2e.ts b/apps/web-e2e/src/settings.e2e.ts index 3b18bc6e1..f7ba49b2b 100644 --- a/apps/web-e2e/src/settings.e2e.ts +++ b/apps/web-e2e/src/settings.e2e.ts @@ -49,17 +49,26 @@ test.describe('Settings', () => { test('Change app theme', async ({ page }) => { await openSettings(page); + // v0.22 compact theme picker exposes the segmented control as a + // radiogroup with options labelled just "Light"/"Dark"/"System". + // Scope to the theme radiogroup so we don't collide with the + // identically-labelled cover-size options below. + const themeGroup = page.locator( + '[data-test-id="select-theme"][role="radiogroup"]' + ); await expect( - page.getByRole('radio', { name: 'System theme' }) + themeGroup.getByRole('radio', { name: 'System', exact: true }) ).toHaveAttribute('aria-checked', 'true'); - await page.getByRole('radio', { name: 'Dark theme' }).click(); + await themeGroup + .getByRole('radio', { name: 'Dark', exact: true }) + .click(); await saveSettings(page); await page.reload(); await openSettings(page); await expect( - page.getByRole('radio', { name: 'Dark theme' }) + themeGroup.getByRole('radio', { name: 'Dark', exact: true }) ).toHaveAttribute('aria-checked', 'true'); }); diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index aa8e8bc5e..11695285a 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -126,7 +126,8 @@ async function addStalkerPortal( await page.getByRole('button', { name: 'Add playlist' }).click(); const dialog = page.locator('mat-dialog-container'); await expect(dialog).toBeVisible(); - await dialog.getByRole('tab', { name: 'Stalker', exact: true }).click(); + // v0.22 redesign: tabs were replaced with a flat 5-card radio picker. + await dialog.getByRole('radio', { name: /Stalker portal/i }).click(); await setInputValue(dialog.locator('input#title'), name); await setInputValue(dialog.locator('input#portalUrl'), PORTAL_URL); diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index f1c54b529..2cf44e57b 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -78,7 +78,10 @@ async function addXtreamPortal( await page.getByRole('button', { name: 'Add playlist' }).click(); const dialog = page.locator('mat-dialog-container'); await expect(dialog).toBeVisible(); - await dialog.getByRole('tab', { name: 'Xtream', exact: true }).click(); + // v0.22 redesign: tabs were replaced with a flat 5-card radio picker. + await dialog + .getByRole('radio', { name: /Xtream credentials/i }) + .click(); await dialog.locator('#title').fill(name); await dialog.locator('#serverUrl').fill(MOCK_SERVER); From 7083bd2af48a9992c327c5d904d2243747cb7d66 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 20 May 2026 15:55:06 +0200 Subject: [PATCH 22/24] fix(shell): keep Add Playlist reachable on Settings; fix dashboard e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Electron E2E regressions from this branch's redesigns: 1. Hiding the playlist switcher on /settings (a playlist-scoped control) also hid the global "Add playlist" button, which lives in the same header-actions block. A user configuring Settings before importing their first source had no way to add a playlist — and the settings / playlist-switcher E2E suites, which add a portal right after saving a setting, timed out waiting for the button. The Add Playlist button is a global action, so it now stays visible on Settings; only the per-playlist context shortcut and bulk actions are hidden there. 2. dashboard-activation.e2e.ts asserted on `dashboard-global-favorites- rail`, the mixed Favorites rail removed earlier in this branch. The test now targets the v0.22 rails: live favorites resolve through the favorites-first `dashboard-live-recent-rail`, and the played movie / series resolve through `dashboard-continue-watching-rail` (renamed from `dashboard-recently-watched-rail`). The movie-from-favorites assertion was dropped — that dashboard surface no longer exists; the favorites collection still has its own page-level coverage. Verified locally: dashboard-activation, settings, and playlist-switcher E2E specs all pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/dashboard-activation.e2e.ts | 35 ++++++++++--------- .../workspace-shell-header.component.html | 31 +++++++++------- 2 files changed, 36 insertions(+), 30 deletions(-) diff --git a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts index f1d23c113..bf8e7c139 100644 --- a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts +++ b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts @@ -85,10 +85,17 @@ test.describe('Dashboard Activation', () => { await goToDashboard(app.mainWindow); - await expectDashboardRail(app.mainWindow, 'dashboard-global-favorites-rail'); + // v0.22 dashboard: the mixed Global Favorites rail was removed. + // Favorited live channels are promoted into the favorites-first + // Live rail; clicking one still opens the Xtream favorites + // collection with the channel playing. + await expectDashboardRail( + app.mainWindow, + 'dashboard-live-recent-rail' + ); await dashboardRailCardByTitle( app.mainWindow, - 'dashboard-global-favorites-rail', + 'dashboard-live-recent-rail', liveTitle ).click(); await app.mainWindow.waitForURL( @@ -108,22 +115,16 @@ test.describe('Dashboard Activation', () => { await app.mainWindow.goBack(); await app.mainWindow.waitForURL(/\/workspace\/dashboard$/); + // Movies and series that were played land in the Continue + // Watching rail (formerly "recently-watched"); clicking a card + // opens it inline in the global-recent collection detail view. + await expectDashboardRail( + app.mainWindow, + 'dashboard-continue-watching-rail' + ); await dashboardRailCardByTitle( app.mainWindow, - 'dashboard-global-favorites-rail', - movieTitle - ).click(); - await expectInlineCollectionDetail(app.mainWindow, { - pathname: /\/workspace\/global-favorites$/, - title: movieTitle, - }); - - await goBackFromDetail(app.mainWindow); - await expectPathname(app.mainWindow, /\/workspace\/dashboard$/); - - await dashboardRailCardByTitle( - app.mainWindow, - 'dashboard-recently-watched-rail', + 'dashboard-continue-watching-rail', movieTitle ).click(); await expectInlineCollectionDetail(app.mainWindow, { @@ -136,7 +137,7 @@ test.describe('Dashboard Activation', () => { await dashboardRailCardByTitle( app.mainWindow, - 'dashboard-recently-watched-rail', + 'dashboard-continue-watching-rail', seriesTitle ).click(); await expectInlineCollectionDetail(app.mainWindow, { diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html index ed8593c74..5624c29bc 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html @@ -59,17 +59,22 @@ } - @if (!isSettingsRoute()) { -
- + +
+ + @if (!isSettingsRoute()) { @if (headerShortcut(); as action) { } -
- } + } +
@if (isElectron()) {
From 2ecb5c28f47653e389fdb973550940a0658a41f2 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 22 May 2026 00:59:17 +0300 Subject: [PATCH 23/24] fix(dashboard): address PR review feedback --- apps/web/src/assets/i18n/ar.json | 4 +- apps/web/src/assets/i18n/ary.json | 4 +- apps/web/src/assets/i18n/by.json | 4 +- apps/web/src/assets/i18n/de.json | 4 +- apps/web/src/assets/i18n/el.json | 4 +- apps/web/src/assets/i18n/en.json | 4 +- apps/web/src/assets/i18n/es.json | 4 +- apps/web/src/assets/i18n/fr.json | 4 +- apps/web/src/assets/i18n/it.json | 4 +- apps/web/src/assets/i18n/ja.json | 4 +- apps/web/src/assets/i18n/ko.json | 4 +- apps/web/src/assets/i18n/nl.json | 4 +- apps/web/src/assets/i18n/pl.json | 4 +- apps/web/src/assets/i18n/pt.json | 4 +- apps/web/src/assets/i18n/ru.json | 4 +- apps/web/src/assets/i18n/tr.json | 4 +- apps/web/src/assets/i18n/zh.json | 4 +- apps/web/src/assets/i18n/zhtw.json | 4 +- docs/architecture/workspace-dashboard.md | 31 ++++-- .../src/lib/playlist-recently-viewed.utils.ts | 18 +++- .../src/lib/portal-activity-item.interface.ts | 5 + .../multi-epg-container.component.spec.ts | 11 +++ .../multi-epg-container.component.ts | 10 ++ .../src/lib/dashboard-data.service.spec.ts | 22 ++++- .../src/lib/dashboard-data.service.ts | 87 +++++++++++----- .../lib/rails/dashboard-rail.component.html | 5 +- .../src/lib/rails/dashboard-rail.component.ts | 1 + .../workspace-dashboard-rails.component.html | 2 +- ...orkspace-dashboard-rails.component.spec.ts | 82 ++++++++++++++++ .../workspace-dashboard-rails.component.ts | 98 +++++++++++++------ 30 files changed, 340 insertions(+), 104 deletions(-) create mode 100644 libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.spec.ts diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index b7e780b4f..4ecacc5a4 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "ترتيب الفئات", "DASHBOARD": { "CONTINUE_WATCHING": "متابعة المشاهدة", - "LIVE_RECENT": "يُبث الآن على المفضلة لديك", - "LIVE_CONTINUE": "متابعة البث المباشر", + "LIVE_FAVORITES": "يُبث الآن على المفضلة لديك", + "LIVE_RECENT": "متابعة البث المباشر", "UNTITLED_SOURCE": "مصدر بدون عنوان", "GLOBAL_FAVORITES": "المفضلة العامة", "REMOVE_FROM_HISTORY": "إزالة من السجل", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 6a151fdf1..facc7890c 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "ترتيب التصنيفات", "DASHBOARD": { "CONTINUE_WATCHING": "كمل المشاهدة", - "LIVE_RECENT": "مباشر دابا على المفضلة ديالك", - "LIVE_CONTINUE": "كمل مع المباشر", + "LIVE_FAVORITES": "مباشر دابا على المفضلة ديالك", + "LIVE_RECENT": "كمل مع المباشر", "UNTITLED_SOURCE": "مصدر بلا عنوان", "GLOBAL_FAVORITES": "المفضلة العامة", "REMOVE_FROM_HISTORY": "حيد من السجل", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 4a406a4ac..4654352c0 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "Сартаваць катэгорыі", "DASHBOARD": { "CONTINUE_WATCHING": "Працягнуць прагляд", - "LIVE_RECENT": "Цяпер у эфіры на выбраных", - "LIVE_CONTINUE": "Працягнуць прагляд эфіру", + "LIVE_FAVORITES": "Цяпер у эфіры на выбраных", + "LIVE_RECENT": "Працягнуць прагляд эфіру", "UNTITLED_SOURCE": "Крыніца без назвы", "GLOBAL_FAVORITES": "Усе фаварыты", "REMOVE_FROM_HISTORY": "Выдаліць з гісторыі", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 0feac6aeb..aa51b7a9a 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "Kategorien sortieren", "DASHBOARD": { "CONTINUE_WATCHING": "Weiter schauen", - "LIVE_RECENT": "Jetzt live auf Ihren Favoriten", - "LIVE_CONTINUE": "Mit Live-TV weiterschauen", + "LIVE_FAVORITES": "Jetzt live auf Ihren Favoriten", + "LIVE_RECENT": "Mit Live-TV weiterschauen", "UNTITLED_SOURCE": "Unbenannte Quelle", "GLOBAL_FAVORITES": "Globale Favoriten", "REMOVE_FROM_HISTORY": "Aus Verlauf entfernen", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 51993f773..53ced9c68 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "Ταξινόμηση κατηγοριών", "DASHBOARD": { "CONTINUE_WATCHING": "Συνέχιση παρακολούθησης", - "LIVE_RECENT": "Ζωντανά τώρα στα αγαπημένα σας", - "LIVE_CONTINUE": "Συνέχιση με ζωντανή TV", + "LIVE_FAVORITES": "Ζωντανά τώρα στα αγαπημένα σας", + "LIVE_RECENT": "Συνέχιση με ζωντανή TV", "UNTITLED_SOURCE": "Πηγή χωρίς τίτλο", "GLOBAL_FAVORITES": "Βασικά Αγαπημένα", "REMOVE_FROM_HISTORY": "Αφαίρεση από το ιστορικό", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 8d4bc009b..efde90ae8 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "Sort categories", "DASHBOARD": { "CONTINUE_WATCHING": "Continue Watching", - "LIVE_RECENT": "Live now on your favorites", - "LIVE_CONTINUE": "Continue with live TV", + "LIVE_FAVORITES": "Live now on your favorites", + "LIVE_RECENT": "Continue with live TV", "UNTITLED_SOURCE": "Untitled source", "GLOBAL_FAVORITES": "Global Favorites", "REMOVE_FROM_HISTORY": "Remove from history", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index e7dee60af..637be0594 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "Ordenar categorías", "DASHBOARD": { "CONTINUE_WATCHING": "Continuar viendo", - "LIVE_RECENT": "Ahora en vivo en tus favoritos", - "LIVE_CONTINUE": "Continuar con TV en vivo", + "LIVE_FAVORITES": "Ahora en vivo en tus favoritos", + "LIVE_RECENT": "Continuar con TV en vivo", "UNTITLED_SOURCE": "Fuente sin título", "GLOBAL_FAVORITES": "Favoritos globales", "REMOVE_FROM_HISTORY": "Quitar del historial", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index ce46ce76a..161c3c106 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "Trier les catégories", "DASHBOARD": { "CONTINUE_WATCHING": "Reprendre la lecture", - "LIVE_RECENT": "En direct sur vos favoris", - "LIVE_CONTINUE": "Reprendre la TV en direct", + "LIVE_FAVORITES": "En direct sur vos favoris", + "LIVE_RECENT": "Reprendre la TV en direct", "UNTITLED_SOURCE": "Source sans titre", "GLOBAL_FAVORITES": "Favoris globaux", "REMOVE_FROM_HISTORY": "Retirer de l'historique", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 8d8e5f4f2..d527787ab 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "Ordina categorie", "DASHBOARD": { "CONTINUE_WATCHING": "Continua a guardare", - "LIVE_RECENT": "Live ora sui tuoi preferiti", - "LIVE_CONTINUE": "Continua con la TV live", + "LIVE_FAVORITES": "Live ora sui tuoi preferiti", + "LIVE_RECENT": "Continua con la TV live", "UNTITLED_SOURCE": "Sorgente senza titolo", "GLOBAL_FAVORITES": "Preferiti globali", "REMOVE_FROM_HISTORY": "Rimuovi dalla cronologia", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 5ebf361c4..44817f13f 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "カテゴリを並び替え", "DASHBOARD": { "CONTINUE_WATCHING": "視聴を再開", - "LIVE_RECENT": "お気に入りで現在放送中", - "LIVE_CONTINUE": "ライブTVの視聴を再開", + "LIVE_FAVORITES": "お気に入りで現在放送中", + "LIVE_RECENT": "ライブTVの視聴を再開", "UNTITLED_SOURCE": "無題のソース", "GLOBAL_FAVORITES": "グローバルお気に入り", "REMOVE_FROM_HISTORY": "履歴から削除", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index ce94623ca..3e5849dc4 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "카테고리 정렬", "DASHBOARD": { "CONTINUE_WATCHING": "이어서 시청", - "LIVE_RECENT": "즐겨찾기에서 지금 라이브 중인 채널입니다", - "LIVE_CONTINUE": "라이브 TV 이어서 시청", + "LIVE_FAVORITES": "즐겨찾기에서 지금 라이브 중인 채널입니다", + "LIVE_RECENT": "라이브 TV 이어서 시청", "UNTITLED_SOURCE": "제목 없는 소스", "GLOBAL_FAVORITES": "전체 즐겨찾기", "REMOVE_FROM_HISTORY": "기록에서 제거", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 5af92d3b4..f11ced48f 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "Categorieën sorteren", "DASHBOARD": { "CONTINUE_WATCHING": "Verder kijken", - "LIVE_RECENT": "Nu live op je favorieten", - "LIVE_CONTINUE": "Verder kijken met live tv", + "LIVE_FAVORITES": "Nu live op je favorieten", + "LIVE_RECENT": "Verder kijken met live tv", "UNTITLED_SOURCE": "Naamloze bron", "GLOBAL_FAVORITES": "Algemene favorieten", "REMOVE_FROM_HISTORY": "Verwijderen uit geschiedenis", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 4731dfc4f..82a01f75d 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "Sortuj kategorie", "DASHBOARD": { "CONTINUE_WATCHING": "Kontynuuj oglądanie", - "LIVE_RECENT": "Na żywo w Twoich ulubionych", - "LIVE_CONTINUE": "Kontynuuj oglądanie na żywo", + "LIVE_FAVORITES": "Na żywo w Twoich ulubionych", + "LIVE_RECENT": "Kontynuuj oglądanie na żywo", "UNTITLED_SOURCE": "Źródło bez tytułu", "GLOBAL_FAVORITES": "Globalne ulubione", "REMOVE_FROM_HISTORY": "Usuń z historii", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 15039fbbc..4715f2cc8 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "Ordenar categorias", "DASHBOARD": { "CONTINUE_WATCHING": "Continuar assistindo", - "LIVE_RECENT": "Ao vivo agora nos seus favoritos", - "LIVE_CONTINUE": "Continuar com TV ao vivo", + "LIVE_FAVORITES": "Ao vivo agora nos seus favoritos", + "LIVE_RECENT": "Continuar com TV ao vivo", "UNTITLED_SOURCE": "Fonte sem título", "GLOBAL_FAVORITES": "Favoritos globais", "REMOVE_FROM_HISTORY": "Remover do histórico", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index bfd900760..d51948a34 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "Сортировать категории", "DASHBOARD": { "CONTINUE_WATCHING": "Продолжить просмотр", - "LIVE_RECENT": "Сейчас в эфире на избранных каналах", - "LIVE_CONTINUE": "Продолжить просмотр прямого эфира", + "LIVE_FAVORITES": "Сейчас в эфире на избранных каналах", + "LIVE_RECENT": "Продолжить просмотр прямого эфира", "UNTITLED_SOURCE": "Источник без названия", "GLOBAL_FAVORITES": "Глобальные избранные", "REMOVE_FROM_HISTORY": "Удалить из истории", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 9113ae998..aa1a0354a 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "Kategorileri sırala", "DASHBOARD": { "CONTINUE_WATCHING": "İzlemeye Devam Et", - "LIVE_RECENT": "Favorilerinizde şu an canlı", - "LIVE_CONTINUE": "Canlı TV ile devam et", + "LIVE_FAVORITES": "Favorilerinizde şu an canlı", + "LIVE_RECENT": "Canlı TV ile devam et", "UNTITLED_SOURCE": "Adsız kaynak", "GLOBAL_FAVORITES": "Tüm Favoriler", "REMOVE_FROM_HISTORY": "Geçmişten kaldır", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 304df9989..2d2f6c20b 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "排序分类", "DASHBOARD": { "CONTINUE_WATCHING": "继续观看", - "LIVE_RECENT": "收藏频道正在播放", - "LIVE_CONTINUE": "继续观看直播", + "LIVE_FAVORITES": "收藏频道正在播放", + "LIVE_RECENT": "继续观看直播", "UNTITLED_SOURCE": "未命名源", "GLOBAL_FAVORITES": "全局收藏", "REMOVE_FROM_HISTORY": "从历史记录中移除", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index f36ae3913..ae4e893a1 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "排序分類", "DASHBOARD": { "CONTINUE_WATCHING": "繼續觀看", - "LIVE_RECENT": "我的最愛正在直播", - "LIVE_CONTINUE": "繼續觀看直播電視", + "LIVE_FAVORITES": "我的最愛正在直播", + "LIVE_RECENT": "繼續觀看直播電視", "UNTITLED_SOURCE": "未命名的來源", "GLOBAL_FAVORITES": "全域我的最愛", "REMOVE_FROM_HISTORY": "從歷史記錄中移除", diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index 6a5a471f7..15b8ad5f1 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -35,11 +35,11 @@ Core implementation: ┌─────────────────────────────────────────────────────────────────────┐ │ Hero — Continue Watching (most recent item) │ ├─────────────────────────────────────────────────────────────────────┤ -│ Recently Watched · See all → │ +│ Continue Watching · See all → │ │ [poster][poster][poster][poster] →→ │ ├─────────────────────────────────────────────────────────────────────┤ -│ Global Favorites · See all → │ -│ [poster][poster][poster] →→ │ +│ Live now on your favorites / Continue with live TV · See all → │ +│ [channel][channel][channel][channel] →→ │ ├─────────────────────────────────────────────────────────────────────┤ │ Recently Used Sources · See all → │ │ [tile][tile][tile][tile] →→ │ @@ -63,6 +63,9 @@ Render rules: 5. The continue-watching hero prefers a stored Xtream `backdrop_url`; when it is missing the UI falls back to a blurred poster treatment instead of showing a flat panel. +6. The mixed global favorites rail is not rendered on the dashboard. Live + favorites are promoted into the live rail, while mixed favorites stay on + `/workspace/global-favorites`. ## Rail Contract @@ -83,10 +86,19 @@ Render rules: ## Data Flow 1. `WorkspaceDashboardRailsComponent` injects `DashboardDataService`. -2. It derives five signals via `computed()`: +2. It derives the dashboard surface via `computed()`: 1. `hero` — first item of `globalRecentItems()`. - 2. `recentlyWatchedCards` — maps `globalRecentItems()` to rail cards. - 3. `xtreamRecentlyAddedCards` — maps `xtreamRecentlyAddedItems()` to rail + 2. `continueWatchingCards` — maps `globalRecentVodItems()` to movie/series + cover cards. Xtream playback positions are bulk-loaded per playlist so + hero and cards can show progress, remaining time, and series season/ + episode badges. Series lookup uses keyed maps for both direct episode ids + and series ids; card renders must not scan the full playback-position map. + 3. `liveOnFavoritesCardsEnriched` — maps favorited live channels first, + falling back to recently watched live channels when no live favorites + exist. M3U cards carry an `epg_lookup_key` using the app-wide XMLTV + fallback order (`tvg-id` -> `tvg-name` -> channel name); EPG enrichment + must use that key before falling back to the card title. + 4. `xtreamRecentlyAddedCards` — maps `xtreamRecentlyAddedItems()` to rail cards. Aggregates newly added VOD and series across *all* Xtream playlists via `DashboardDataService.reloadXtreamRecentlyAddedItems()`, which calls `getGlobalRecentlyAdded('all', limit, 'xtream')` with the @@ -95,7 +107,6 @@ Render rules: playlists never see it. Cards carry a `playlist_name · type` subtitle so users can tell which provider each item came from. Driven by an effect that re-runs whenever the Xtream playlist count changes. - 4. `favoriteCards` — maps `globalFavoriteItems()` to rail cards. 5. `sourceCards` — maps `recentPlaylists()` to rail cards. `recentPlaylists()` ranks M3U, Xtream, and Stalker sources by their latest recent activity from `globalRecentItems()`, then falls back to playlist @@ -114,6 +125,9 @@ Render rules: 7. The dashboard feature triggers a fresh reload of DB-backed recent/favorite rows on dashboard entry so newly backfilled backdrop data is visible as soon as the user returns from a detail page. +8. Playback-position reloads are keyed by the VOD/series recent set and should + call `reloadPlaybackPositions()` through `untracked()` so live-only recent + changes do not trigger unnecessary IPC round-trips. ## Empty State @@ -141,6 +155,9 @@ The welcome state is rendered via the existing route without switching the active playlist in the header switcher. 6. `Recently Used Sources` reflects recent source usage across all provider types, not just recent imports. +7. The live rail title key must match the rendered source: favorites use + `WORKSPACE.DASHBOARD.LIVE_FAVORITES`; recently watched fallback uses + `WORKSPACE.DASHBOARD.LIVE_RECENT`. ## Adding Or Changing Rails diff --git a/libs/shared/interfaces/src/lib/playlist-recently-viewed.utils.ts b/libs/shared/interfaces/src/lib/playlist-recently-viewed.utils.ts index ab037c7c0..c20219417 100644 --- a/libs/shared/interfaces/src/lib/playlist-recently-viewed.utils.ts +++ b/libs/shared/interfaces/src/lib/playlist-recently-viewed.utils.ts @@ -17,6 +17,10 @@ interface PlaylistRecentLabels { m3u: string; } +function firstNonEmpty(...values: Array): string | undefined { + return values.map((value) => value?.trim()).find(Boolean); +} + function getPlaylistRecentItems( playlist: PlaylistMeta ): PlaylistRecentlyViewedItem[] { @@ -76,9 +80,11 @@ function mapM3uPlaylistRecentItems( acc.push({ id: rawItem.id || channelUrl, title: - rawItem.title?.trim() || - rawItem.tvg_name?.trim() || - rawItem.channel_id || + firstNonEmpty( + rawItem.title, + rawItem.tvg_name, + rawItem.channel_id + ) || channelUrl, type: 'live', playlist_id: playlist._id, @@ -87,6 +93,12 @@ function mapM3uPlaylistRecentItems( category_id: rawItem.category_id || 'live', xtream_id: channelUrl, poster_url: rawItem.poster_url || undefined, + epg_lookup_key: firstNonEmpty( + rawItem.tvg_id, + rawItem.tvg_name, + rawItem.title, + rawItem.channel_id + ), source: 'm3u', }); diff --git a/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts b/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts index 548fdad18..b3119dc6b 100644 --- a/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts +++ b/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts @@ -16,6 +16,11 @@ export interface PortalActivityItem { category_id: string | number; xtream_id: string | number; poster_url?: string; + /** + * Preferred XMLTV lookup key for live EPG enrichment. For M3U channels this + * follows the app-wide tvg.id -> tvg.name -> display name fallback chain. + */ + epg_lookup_key?: string; /** * Wide backdrop URL (typically 16:9), persisted when the user first views * the detail page. Absent for stalker and for items that have never had diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.spec.ts b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.spec.ts new file mode 100644 index 000000000..addd6cb0c --- /dev/null +++ b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.spec.ts @@ -0,0 +1,11 @@ +import { isSelectedEpgDayToday } from './multi-epg-container.component'; + +describe('isSelectedEpgDayToday', () => { + it('returns true only when the selected EPG day is the actual current day', () => { + const now = new Date('2026-05-21T20:00:00.000Z'); + + expect(isSelectedEpgDayToday('20260521', now)).toBe(true); + expect(isSelectedEpgDayToday('20260520', now)).toBe(false); + expect(isSelectedEpgDayToday('20260522', now)).toBe(false); + }); +}); diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts index 5ef6564d0..3923f4822 100644 --- a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts +++ b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts @@ -54,6 +54,13 @@ interface ProgramSearchResult extends EpgProgram { display_name?: string | null; } +export function isSelectedEpgDayToday( + selectedDay: string, + now: Date = new Date() +): boolean { + return selectedDay === format(now, 'yyyyMMdd'); +} + @Component({ imports: [ DatePipe, @@ -250,6 +257,9 @@ export class MultiEpgContainerComponent */ isProgramAiringNow(program: EnrichedProgram): boolean { const nowX = this.currentTimeLine(); + if (!isSelectedEpgDayToday(this.today())) { + return false; + } return ( nowX >= program.startPosition && nowX <= program.startPosition + program.width diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index 9f304671b..09f2453dc 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -242,6 +242,7 @@ describe('DashboardDataService', () => { playlist_id: 'm3u-1', source: 'm3u', poster_url: 'https://example.com/logo-1.png', + epg_lookup_key: 'tvg-1', }), expect.objectContaining({ id: 'https://example.com/stream-2.m3u8', @@ -250,6 +251,7 @@ describe('DashboardDataService', () => { playlist_id: 'm3u-1', source: 'm3u', poster_url: 'https://example.com/logo-2.png', + epg_lookup_key: 'tvg-2', }), ]) ); @@ -436,6 +438,7 @@ describe('DashboardDataService', () => { playlist_id: 'm3u-1', source: 'm3u', xtream_id: 'https://example.com/stream-1.m3u8', + epg_lookup_key: 'tvg-1', }), ]) ); @@ -696,10 +699,27 @@ describe('DashboardDataService', () => { const series = service .globalRecentVodItems() .find((item) => item.title === 'Shadow Bay'); - const position = service.getPlaybackPositionForItem(series!); + const positionsMap = service.playbackPositions$(); + const failOnLinearScan = jest.fn(() => { + throw new Error('series lookup should not scan all positions'); + }); + ( + positionsMap as unknown as { + values: () => IterableIterator; + } + ).values = + failOnLinearScan as unknown as () => IterableIterator; + + expect(series).toBeDefined(); + if (!series) { + throw new Error('expected Shadow Bay recent item'); + } + + const position = service.getPlaybackPositionForItem(series); expect(position?.contentType).toBe('episode'); expect(position?.seasonNumber).toBe(3); expect(position?.episodeNumber).toBe(7); + expect(failOnLinearScan).not.toHaveBeenCalled(); }); it('exposes a live-only slice of global favorites so the dashboard can promote favorited channels into the Live rail', async () => { diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index 62cac219e..1cf7d2962 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -65,6 +65,28 @@ function playbackPositionMapKey( return `${playlistId}::${contentXtreamId}::${contentType}`; } +function seriesPlaybackPositionMapKey( + playlistId: string, + seriesXtreamId: number +): string { + return `${playlistId}::${seriesXtreamId}`; +} + +function newestPlaybackPosition( + current: PlaybackPositionData | null | undefined, + candidate: PlaybackPositionData | null | undefined +): PlaybackPositionData | null { + if (!current) { + return candidate ?? null; + } + if (!candidate) { + return current; + } + return (candidate.updatedAt ?? '') > (current.updatedAt ?? '') + ? candidate + : current; +} + /** @deprecated Use {@link PortalRecentItem} from `@iptvnator/shared/interfaces` instead. */ export type GlobalRecentItem = PortalRecentItem; /** @deprecated Use {@link PortalFavoriteItem} from `@iptvnator/shared/interfaces` instead. */ @@ -239,6 +261,9 @@ export class DashboardDataService { private readonly playbackPositionsMap = signal< Map >(new Map()); + private readonly playbackPositionsBySeriesMap = signal< + Map + >(new Map()); readonly playbackPositions$ = this.playbackPositionsMap.asReadonly(); @@ -267,29 +292,17 @@ export class DashboardDataService { // Series recent_items rows carry either the series id (the series // landing page writes the series itself) or, for direct-play flows, - // the episode id. Match both shapes and prefer the most recently - // updated episode position so the card can show resume progress and - // a "S2 · E5" badge regardless of how the row was originally saved. - let best: PlaybackPositionData | null = null; - for (const position of this.playbackPositionsMap().values()) { - if (position.contentType !== 'episode') continue; - if ( - position.playlistId && - position.playlistId !== item.playlist_id - ) { - continue; - } - const matchesEpisode = position.contentXtreamId === xtreamId; - const matchesSeries = position.seriesXtreamId === xtreamId; - if (!matchesEpisode && !matchesSeries) continue; - if ( - !best || - (position.updatedAt ?? '') > (best.updatedAt ?? '') - ) { - best = position; - } - } - return best; + // the episode id. Match both shapes through keyed maps so card renders + // do not scan every saved playback position. + const episodePosition = + this.playbackPositionsMap().get( + playbackPositionMapKey(item.playlist_id, xtreamId, 'episode') + ) ?? null; + const seriesPosition = + this.playbackPositionsBySeriesMap().get( + seriesPlaybackPositionMapKey(item.playlist_id, xtreamId) + ) ?? null; + return newestPlaybackPosition(episodePosition, seriesPosition); } /** @@ -307,10 +320,12 @@ export class DashboardDataService { } if (playlistIds.size === 0) { this.playbackPositionsMap.set(new Map()); + this.playbackPositionsBySeriesMap.set(new Map()); return; } const next = new Map(); + const nextBySeries = new Map(); for (const playlistId of playlistIds) { try { const positions = @@ -326,6 +341,22 @@ export class DashboardDataService { ), position ); + if ( + position.contentType === 'episode' && + Number.isFinite(position.seriesXtreamId) + ) { + const seriesKey = seriesPlaybackPositionMapKey( + playlistId, + position.seriesXtreamId as number + ); + nextBySeries.set( + seriesKey, + newestPlaybackPosition( + nextBySeries.get(seriesKey), + position + ) as PlaybackPositionData + ); + } } } catch (err) { console.warn( @@ -336,7 +367,10 @@ export class DashboardDataService { } } - this.ngZone.run(() => this.playbackPositionsMap.set(next)); + this.ngZone.run(() => { + this.playbackPositionsMap.set(next); + this.playbackPositionsBySeriesMap.set(nextBySeries); + }); } readonly stalkerGlobalFavorites = computed(() => { @@ -1041,6 +1075,11 @@ export class DashboardDataService { category_id: 'live', xtream_id: matchedFavoriteId, poster_url: channel.tvg?.logo || undefined, + epg_lookup_key: + channel.tvg?.id?.trim() || + channel.tvg?.name?.trim() || + channel.name?.trim() || + undefined, source: 'm3u', }); return acc; diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html index 6ca18fa11..8b91b148a 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html @@ -110,7 +110,8 @@ } - @if (card.watchProgress != null) { + @if (card.watchProgress !== null && card.watchProgress !== undefined) {