diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json
index 3b9e77d05..713e4341e 100644
--- a/apps/web/src/assets/i18n/ar.json
+++ b/apps/web/src/assets/i18n/ar.json
@@ -1904,7 +1904,18 @@
"STALKER_PLAYLISTS": "قوائم Stalker",
"ALL_PLAYLISTS": "جميع قوائم التشغيل",
"PLAYLIST_COUNT_ONE": "قائمة تشغيل واحدة",
- "PLAYLIST_COUNT_OTHER": "{{count}} قائمة تشغيل"
+ "PLAYLIST_COUNT_OTHER": "{{count}} قائمة تشغيل",
+ "ROW": {
+ "TYPE_M3U_FILE": "M3U · ملف",
+ "ADDED": "أُضيف {{date}}",
+ "UPDATED": "حُدِّث {{date}}",
+ "UNREACHABLE": "لا يستجيب",
+ "UNREACHABLE_FOR": "لا يستجيب · {{duration}}",
+ "EXPIRED": "منتهي",
+ "EDIT": "تعديل…",
+ "COPY_URL": "نسخ الرابط",
+ "OPEN": "فتح"
+ }
},
"CONTEXT": {
"CATEGORIES": "الفئات",
diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json
index 6d9d83852..d2906296d 100644
--- a/apps/web/src/assets/i18n/ary.json
+++ b/apps/web/src/assets/i18n/ary.json
@@ -1904,7 +1904,18 @@
"STALKER_PLAYLISTS": "قوائم تشغيل Stalker",
"ALL_PLAYLISTS": "جميع قوائم التشغيل",
"PLAYLIST_COUNT_ONE": "قائمة تشغيل وحدة",
- "PLAYLIST_COUNT_OTHER": "{{count}} ديال قوائم التشغيل"
+ "PLAYLIST_COUNT_OTHER": "{{count}} ديال قوائم التشغيل",
+ "ROW": {
+ "TYPE_M3U_FILE": "M3U · فيشي",
+ "ADDED": "تزاد {{date}}",
+ "UPDATED": "تحدّث {{date}}",
+ "UNREACHABLE": "ما كيجاوبش",
+ "UNREACHABLE_FOR": "ما كيجاوبش · {{duration}}",
+ "EXPIRED": "سالات",
+ "EDIT": "بدّل…",
+ "COPY_URL": "نسخ الرابط",
+ "OPEN": "حلّ"
+ }
},
"CONTEXT": {
"CATEGORIES": "الفئات",
diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json
index 606bcd6bd..4bda49a6f 100644
--- a/apps/web/src/assets/i18n/by.json
+++ b/apps/web/src/assets/i18n/by.json
@@ -1904,7 +1904,18 @@
"STALKER_PLAYLISTS": "Stalker плэйлісты",
"ALL_PLAYLISTS": "Усе плэйлісты",
"PLAYLIST_COUNT_ONE": "1 плэйліст",
- "PLAYLIST_COUNT_OTHER": "{{count}} плэйлістаў"
+ "PLAYLIST_COUNT_OTHER": "{{count}} плэйлістаў",
+ "ROW": {
+ "TYPE_M3U_FILE": "M3U · файл",
+ "ADDED": "дададзены {{date}}",
+ "UPDATED": "абноўлены {{date}}",
+ "UNREACHABLE": "Не адказвае",
+ "UNREACHABLE_FOR": "Не адказвае · {{duration}}",
+ "EXPIRED": "Скончыўся",
+ "EDIT": "Змяніць…",
+ "COPY_URL": "Скапіяваць URL",
+ "OPEN": "Адкрыць"
+ }
},
"CONTEXT": {
"CATEGORIES": "Катэгорыі",
diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json
index 11962d897..1d731feb3 100644
--- a/apps/web/src/assets/i18n/de.json
+++ b/apps/web/src/assets/i18n/de.json
@@ -1904,7 +1904,18 @@
"STALKER_PLAYLISTS": "Stalker-Playlists",
"ALL_PLAYLISTS": "Alle Playlists",
"PLAYLIST_COUNT_ONE": "1 Playlist",
- "PLAYLIST_COUNT_OTHER": "{{count}} Playlists"
+ "PLAYLIST_COUNT_OTHER": "{{count}} Playlists",
+ "ROW": {
+ "TYPE_M3U_FILE": "M3U · Datei",
+ "ADDED": "hinzugefügt am {{date}}",
+ "UPDATED": "aktualisiert am {{date}}",
+ "UNREACHABLE": "Antwortet nicht",
+ "UNREACHABLE_FOR": "Antwortet nicht · {{duration}}",
+ "EXPIRED": "Abgelaufen",
+ "EDIT": "Bearbeiten…",
+ "COPY_URL": "URL kopieren",
+ "OPEN": "Öffnen"
+ }
},
"CONTEXT": {
"CATEGORIES": "Kategorien",
diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json
index 7f2238a7b..2387bc764 100644
--- a/apps/web/src/assets/i18n/el.json
+++ b/apps/web/src/assets/i18n/el.json
@@ -1904,7 +1904,18 @@
"STALKER_PLAYLISTS": "Λίστες αναπαραγωγής Stalker",
"ALL_PLAYLISTS": "Όλες οι λίστες αναπαραγωγής",
"PLAYLIST_COUNT_ONE": "1 λίστα αναπαραγωγής",
- "PLAYLIST_COUNT_OTHER": "{{count}} λίστες αναπαραγωγής"
+ "PLAYLIST_COUNT_OTHER": "{{count}} λίστες αναπαραγωγής",
+ "ROW": {
+ "TYPE_M3U_FILE": "M3U · αρχείο",
+ "ADDED": "προστέθηκε {{date}}",
+ "UPDATED": "ενημερώθηκε {{date}}",
+ "UNREACHABLE": "Δεν αποκρίνεται",
+ "UNREACHABLE_FOR": "Δεν αποκρίνεται · {{duration}}",
+ "EXPIRED": "Έληξε",
+ "EDIT": "Επεξεργασία…",
+ "COPY_URL": "Αντιγραφή URL",
+ "OPEN": "Άνοιγμα"
+ }
},
"CONTEXT": {
"CATEGORIES": "Κατηγορίες",
diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json
index 084229dc6..211e780f2 100644
--- a/apps/web/src/assets/i18n/en.json
+++ b/apps/web/src/assets/i18n/en.json
@@ -1904,7 +1904,18 @@
"STALKER_PLAYLISTS": "Stalker Playlists",
"ALL_PLAYLISTS": "All Playlists",
"PLAYLIST_COUNT_ONE": "1 playlist",
- "PLAYLIST_COUNT_OTHER": "{{count}} playlists"
+ "PLAYLIST_COUNT_OTHER": "{{count}} playlists",
+ "ROW": {
+ "TYPE_M3U_FILE": "M3U · file",
+ "ADDED": "added {{date}}",
+ "UPDATED": "updated {{date}}",
+ "UNREACHABLE": "Not responding",
+ "UNREACHABLE_FOR": "Not responding · {{duration}}",
+ "EXPIRED": "Expired",
+ "EDIT": "Edit…",
+ "COPY_URL": "Copy URL",
+ "OPEN": "Open"
+ }
},
"CONTEXT": {
"CATEGORIES": "Categories",
diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json
index 19874b2cf..815d8cc9c 100644
--- a/apps/web/src/assets/i18n/es.json
+++ b/apps/web/src/assets/i18n/es.json
@@ -1904,7 +1904,18 @@
"STALKER_PLAYLISTS": "Listas Stalker",
"ALL_PLAYLISTS": "Todas las listas",
"PLAYLIST_COUNT_ONE": "1 lista de reproducción",
- "PLAYLIST_COUNT_OTHER": "{{count}} listas de reproducción"
+ "PLAYLIST_COUNT_OTHER": "{{count}} listas de reproducción",
+ "ROW": {
+ "TYPE_M3U_FILE": "M3U · archivo",
+ "ADDED": "añadido el {{date}}",
+ "UPDATED": "actualizado el {{date}}",
+ "UNREACHABLE": "No responde",
+ "UNREACHABLE_FOR": "No responde · {{duration}}",
+ "EXPIRED": "Caducada",
+ "EDIT": "Editar…",
+ "COPY_URL": "Copiar URL",
+ "OPEN": "Abrir"
+ }
},
"CONTEXT": {
"CATEGORIES": "Categorías",
diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json
index 5b01c03a0..eb873a768 100644
--- a/apps/web/src/assets/i18n/fr.json
+++ b/apps/web/src/assets/i18n/fr.json
@@ -1904,7 +1904,18 @@
"STALKER_PLAYLISTS": "Listes de lecture Stalker",
"ALL_PLAYLISTS": "Toutes les listes de lecture",
"PLAYLIST_COUNT_ONE": "1 liste de lecture",
- "PLAYLIST_COUNT_OTHER": "{{count}} listes de lecture"
+ "PLAYLIST_COUNT_OTHER": "{{count}} listes de lecture",
+ "ROW": {
+ "TYPE_M3U_FILE": "M3U · fichier",
+ "ADDED": "ajouté le {{date}}",
+ "UPDATED": "mis à jour le {{date}}",
+ "UNREACHABLE": "Ne répond pas",
+ "UNREACHABLE_FOR": "Ne répond pas · {{duration}}",
+ "EXPIRED": "Expiré",
+ "EDIT": "Modifier…",
+ "COPY_URL": "Copier l’URL",
+ "OPEN": "Ouvrir"
+ }
},
"CONTEXT": {
"CATEGORIES": "Catégories",
diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json
index 673e40489..f2d60447b 100644
--- a/apps/web/src/assets/i18n/hu.json
+++ b/apps/web/src/assets/i18n/hu.json
@@ -1904,7 +1904,18 @@
"STALKER_PLAYLISTS": "Stalker-lejátszási listák",
"ALL_PLAYLISTS": "Összes lejátszási lista",
"PLAYLIST_COUNT_ONE": "1 lejátszási lista",
- "PLAYLIST_COUNT_OTHER": "{{count}} lejátszási lista"
+ "PLAYLIST_COUNT_OTHER": "{{count}} lejátszási lista",
+ "ROW": {
+ "TYPE_M3U_FILE": "M3U · fájl",
+ "ADDED": "hozzáadva: {{date}}",
+ "UPDATED": "frissítve: {{date}}",
+ "UNREACHABLE": "Nem válaszol",
+ "UNREACHABLE_FOR": "Nem válaszol · {{duration}}",
+ "EXPIRED": "Lejárt",
+ "EDIT": "Szerkesztés…",
+ "COPY_URL": "URL másolása",
+ "OPEN": "Megnyitás"
+ }
},
"CONTEXT": {
"CATEGORIES": "Kategóriák",
diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json
index 57414c463..d717c46de 100644
--- a/apps/web/src/assets/i18n/it.json
+++ b/apps/web/src/assets/i18n/it.json
@@ -1904,7 +1904,18 @@
"STALKER_PLAYLISTS": "Playlist Stalker",
"ALL_PLAYLISTS": "Tutte le playlist",
"PLAYLIST_COUNT_ONE": "1 playlist",
- "PLAYLIST_COUNT_OTHER": "{{count}} playlist"
+ "PLAYLIST_COUNT_OTHER": "{{count}} playlist",
+ "ROW": {
+ "TYPE_M3U_FILE": "M3U · file locale",
+ "ADDED": "aggiunto il {{date}}",
+ "UPDATED": "aggiornato il {{date}}",
+ "UNREACHABLE": "Non risponde",
+ "UNREACHABLE_FOR": "Non risponde · {{duration}}",
+ "EXPIRED": "Scaduto",
+ "EDIT": "Modifica…",
+ "COPY_URL": "Copia URL",
+ "OPEN": "Apri"
+ }
},
"CONTEXT": {
"CATEGORIES": "Categorie",
diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json
index 7147bb1a2..88dae1f48 100644
--- a/apps/web/src/assets/i18n/ja.json
+++ b/apps/web/src/assets/i18n/ja.json
@@ -1904,7 +1904,18 @@
"STALKER_PLAYLISTS": "Stalkerプレイリスト",
"ALL_PLAYLISTS": "すべてのプレイリスト",
"PLAYLIST_COUNT_ONE": "1 件のプレイリスト",
- "PLAYLIST_COUNT_OTHER": "{{count}} 件のプレイリスト"
+ "PLAYLIST_COUNT_OTHER": "{{count}} 件のプレイリスト",
+ "ROW": {
+ "TYPE_M3U_FILE": "M3U · ファイル",
+ "ADDED": "{{date}}に追加",
+ "UPDATED": "{{date}}に更新",
+ "UNREACHABLE": "応答なし",
+ "UNREACHABLE_FOR": "応答なし · {{duration}}",
+ "EXPIRED": "期限切れ",
+ "EDIT": "編集…",
+ "COPY_URL": "URLをコピー",
+ "OPEN": "開く"
+ }
},
"CONTEXT": {
"CATEGORIES": "カテゴリー",
diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json
index 59c240111..995294133 100644
--- a/apps/web/src/assets/i18n/ko.json
+++ b/apps/web/src/assets/i18n/ko.json
@@ -1904,7 +1904,18 @@
"STALKER_PLAYLISTS": "Stalker 재생목록",
"ALL_PLAYLISTS": "모든 재생목록",
"PLAYLIST_COUNT_ONE": "1개 재생목록",
- "PLAYLIST_COUNT_OTHER": "{{count}}개 재생목록"
+ "PLAYLIST_COUNT_OTHER": "{{count}}개 재생목록",
+ "ROW": {
+ "TYPE_M3U_FILE": "M3U · 파일",
+ "ADDED": "{{date}} 추가됨",
+ "UPDATED": "{{date}} 업데이트됨",
+ "UNREACHABLE": "응답 없음",
+ "UNREACHABLE_FOR": "응답 없음 · {{duration}}",
+ "EXPIRED": "만료됨",
+ "EDIT": "편집…",
+ "COPY_URL": "URL 복사",
+ "OPEN": "열기"
+ }
},
"CONTEXT": {
"CATEGORIES": "카테고리",
diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json
index ccb881c59..48573d999 100644
--- a/apps/web/src/assets/i18n/nl.json
+++ b/apps/web/src/assets/i18n/nl.json
@@ -1904,7 +1904,18 @@
"STALKER_PLAYLISTS": "Stalker-afspeellijsten",
"ALL_PLAYLISTS": "Alle afspeellijsten",
"PLAYLIST_COUNT_ONE": "1 afspeellijst",
- "PLAYLIST_COUNT_OTHER": "{{count}} afspeellijsten"
+ "PLAYLIST_COUNT_OTHER": "{{count}} afspeellijsten",
+ "ROW": {
+ "TYPE_M3U_FILE": "M3U · bestand",
+ "ADDED": "toegevoegd {{date}}",
+ "UPDATED": "bijgewerkt {{date}}",
+ "UNREACHABLE": "Reageert niet",
+ "UNREACHABLE_FOR": "Reageert niet · {{duration}}",
+ "EXPIRED": "Verlopen",
+ "EDIT": "Bewerken…",
+ "COPY_URL": "URL kopiëren",
+ "OPEN": "Openen"
+ }
},
"CONTEXT": {
"CATEGORIES": "Categorieën",
diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json
index 85c5714d0..1aab4a126 100644
--- a/apps/web/src/assets/i18n/pl.json
+++ b/apps/web/src/assets/i18n/pl.json
@@ -1904,7 +1904,18 @@
"STALKER_PLAYLISTS": "Listy odtwarzania Stalker",
"ALL_PLAYLISTS": "Wszystkie listy odtwarzania",
"PLAYLIST_COUNT_ONE": "1 lista odtwarzania",
- "PLAYLIST_COUNT_OTHER": "{{count}} list odtwarzania"
+ "PLAYLIST_COUNT_OTHER": "{{count}} list odtwarzania",
+ "ROW": {
+ "TYPE_M3U_FILE": "M3U · plik",
+ "ADDED": "dodano {{date}}",
+ "UPDATED": "zaktualizowano {{date}}",
+ "UNREACHABLE": "Nie odpowiada",
+ "UNREACHABLE_FOR": "Nie odpowiada · {{duration}}",
+ "EXPIRED": "Wygasło",
+ "EDIT": "Edytuj…",
+ "COPY_URL": "Kopiuj URL",
+ "OPEN": "Otwórz"
+ }
},
"CONTEXT": {
"CATEGORIES": "Kategorie",
diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json
index 0a32d81c9..599db4189 100644
--- a/apps/web/src/assets/i18n/pt.json
+++ b/apps/web/src/assets/i18n/pt.json
@@ -1904,7 +1904,18 @@
"STALKER_PLAYLISTS": "Playlists Stalker",
"ALL_PLAYLISTS": "Todas as playlists",
"PLAYLIST_COUNT_ONE": "1 playlist",
- "PLAYLIST_COUNT_OTHER": "{{count}} playlists"
+ "PLAYLIST_COUNT_OTHER": "{{count}} playlists",
+ "ROW": {
+ "TYPE_M3U_FILE": "M3U · arquivo",
+ "ADDED": "adicionado em {{date}}",
+ "UPDATED": "atualizado em {{date}}",
+ "UNREACHABLE": "Não responde",
+ "UNREACHABLE_FOR": "Não responde · {{duration}}",
+ "EXPIRED": "Expirada",
+ "EDIT": "Editar…",
+ "COPY_URL": "Copiar URL",
+ "OPEN": "Abrir"
+ }
},
"CONTEXT": {
"CATEGORIES": "Categorias",
diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json
index 9da7e722b..a6ae67173 100644
--- a/apps/web/src/assets/i18n/ru.json
+++ b/apps/web/src/assets/i18n/ru.json
@@ -1904,7 +1904,18 @@
"STALKER_PLAYLISTS": "Stalker плейлисты",
"ALL_PLAYLISTS": "Все плейлисты",
"PLAYLIST_COUNT_ONE": "1 плейлист",
- "PLAYLIST_COUNT_OTHER": "{{count}} плейлистов"
+ "PLAYLIST_COUNT_OTHER": "{{count}} плейлистов",
+ "ROW": {
+ "TYPE_M3U_FILE": "M3U · файл",
+ "ADDED": "добавлен {{date}}",
+ "UPDATED": "обновлён {{date}}",
+ "UNREACHABLE": "Не отвечает",
+ "UNREACHABLE_FOR": "Не отвечает · {{duration}}",
+ "EXPIRED": "Истёк",
+ "EDIT": "Изменить…",
+ "COPY_URL": "Копировать URL",
+ "OPEN": "Открыть"
+ }
},
"CONTEXT": {
"CATEGORIES": "Категории",
diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json
index d5632df26..4bcde1825 100644
--- a/apps/web/src/assets/i18n/tr.json
+++ b/apps/web/src/assets/i18n/tr.json
@@ -1904,7 +1904,18 @@
"STALKER_PLAYLISTS": "Stalker Oynatma Listeleri",
"ALL_PLAYLISTS": "Tüm Oynatma Listeleri",
"PLAYLIST_COUNT_ONE": "1 oynatma listesi",
- "PLAYLIST_COUNT_OTHER": "{{count}} oynatma listesi"
+ "PLAYLIST_COUNT_OTHER": "{{count}} oynatma listesi",
+ "ROW": {
+ "TYPE_M3U_FILE": "M3U · dosya",
+ "ADDED": "{{date}} eklendi",
+ "UPDATED": "{{date}} güncellendi",
+ "UNREACHABLE": "Yanıt vermiyor",
+ "UNREACHABLE_FOR": "Yanıt vermiyor · {{duration}}",
+ "EXPIRED": "Süresi doldu",
+ "EDIT": "Düzenle…",
+ "COPY_URL": "URL'yi kopyala",
+ "OPEN": "Aç"
+ }
},
"CONTEXT": {
"CATEGORIES": "Kategoriler",
diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json
index 3c1e77759..44c6d4671 100644
--- a/apps/web/src/assets/i18n/zh.json
+++ b/apps/web/src/assets/i18n/zh.json
@@ -1904,7 +1904,18 @@
"STALKER_PLAYLISTS": "Stalker 播放列表",
"ALL_PLAYLISTS": "所有播放列表",
"PLAYLIST_COUNT_ONE": "1 个播放列表",
- "PLAYLIST_COUNT_OTHER": "{{count}} 个播放列表"
+ "PLAYLIST_COUNT_OTHER": "{{count}} 个播放列表",
+ "ROW": {
+ "TYPE_M3U_FILE": "M3U · 文件",
+ "ADDED": "{{date}} 添加",
+ "UPDATED": "{{date}} 更新",
+ "UNREACHABLE": "无响应",
+ "UNREACHABLE_FOR": "无响应 · {{duration}}",
+ "EXPIRED": "已过期",
+ "EDIT": "编辑…",
+ "COPY_URL": "复制 URL",
+ "OPEN": "打开"
+ }
},
"CONTEXT": {
"CATEGORIES": "分类",
diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json
index a88e94426..aafae2899 100644
--- a/apps/web/src/assets/i18n/zhtw.json
+++ b/apps/web/src/assets/i18n/zhtw.json
@@ -1904,7 +1904,18 @@
"STALKER_PLAYLISTS": "Stalker 播放清單",
"ALL_PLAYLISTS": "所有播放清單",
"PLAYLIST_COUNT_ONE": "1 個播放清單",
- "PLAYLIST_COUNT_OTHER": "{{count}} 個播放清單"
+ "PLAYLIST_COUNT_OTHER": "{{count}} 個播放清單",
+ "ROW": {
+ "TYPE_M3U_FILE": "M3U · 檔案",
+ "ADDED": "{{date}} 新增",
+ "UPDATED": "{{date}} 更新",
+ "UNREACHABLE": "無回應",
+ "UNREACHABLE_FOR": "無回應 · {{duration}}",
+ "EXPIRED": "已過期",
+ "EDIT": "編輯…",
+ "COPY_URL": "複製 URL",
+ "OPEN": "開啟"
+ }
},
"CONTEXT": {
"CATEGORIES": "類別",
diff --git a/apps/web/src/m3-theme.scss b/apps/web/src/m3-theme.scss
index acd62acd0..3ad16f0f4 100644
--- a/apps/web/src/m3-theme.scss
+++ b/apps/web/src/m3-theme.scss
@@ -169,6 +169,23 @@ html {
// 4.5:1 over hero artwork under the light scrim (#a16207 measured 3.4:1).
--app-rating-color: #7a4a00;
+ // Source lists (Sources page, playlist switcher) and their popovers.
+ --app-line-strong: rgba(0, 0, 0, 0.12);
+ --app-font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;
+ // A failing source's tag and destructive menu items.
+ --app-danger-text: #b3261e;
+ --app-danger-surface: color-mix(in srgb, #d12c2c 12%, transparent);
+ --app-neutral-tag-surface: rgba(0, 0, 0, 0.06);
+ // One selected-row look for side lists and the switcher's current row:
+ // the Settings navigation's soft tint, plus an outline in light theme
+ // (selection reads as outline + tint here, not a solid slab). Light
+ // text stays the heading colour: the blue on its own tint is 3.3:1.
+ --app-list-selected-bg: var(--app-selection-surface);
+ --app-list-selected-fg: var(--app-heading-color);
+ --app-list-selected-outline: var(--app-selection-border);
+ --app-popover-shadow: 0 24px 70px rgba(15, 20, 28, 0.18);
+ --app-popover-backdrop: rgba(20, 24, 32, 0.28);
+
.dark-theme {
@include mat.all-component-colors($dark-theme);
// Only the color roles differ per theme: the elevation shadows are
@@ -274,6 +291,19 @@ html {
--app-cta-hover-bg: #ffffff;
--app-cta-meta-fg: #5a6274;
--app-rating-color: #ffcf5c;
+
+ // Source lists and popovers. The selected-row tokens are
+ // re-declared so they resolve against this context's selection
+ // colour, not the light values computed on `html`.
+ --app-line-strong: rgba(255, 255, 255, 0.1);
+ --app-danger-text: #ff8d9b;
+ --app-danger-surface: rgba(255, 91, 110, 0.14);
+ --app-neutral-tag-surface: rgba(255, 255, 255, 0.08);
+ --app-list-selected-bg: var(--app-selection-surface);
+ --app-list-selected-fg: var(--app-selection-color);
+ --app-list-selected-outline: transparent;
+ --app-popover-shadow: 0 24px 70px rgba(0, 0, 0, 0.6);
+ --app-popover-backdrop: rgba(4, 6, 10, 0.5);
}
}
diff --git a/libs/playlist/shared/ui/src/index.ts b/libs/playlist/shared/ui/src/index.ts
index 16a7edff9..f44b09eef 100644
--- a/libs/playlist/shared/ui/src/index.ts
+++ b/libs/playlist/shared/ui/src/index.ts
@@ -8,3 +8,5 @@ export * from './lib/playlist-refresh-action.service';
export * from './lib/xtream-refresh-flow.service';
export * from './lib/source-health/source-cleanup-dialog.component';
+export * from './lib/source-row/source-row.component';
+export * from './lib/source-row/source-tile.component';
diff --git a/libs/playlist/shared/ui/src/lib/source-row/source-row.component.html b/libs/playlist/shared/ui/src/lib/source-row/source-row.component.html
new file mode 100644
index 000000000..9014bd32b
--- /dev/null
+++ b/libs/playlist/shared/ui/src/lib/source-row/source-row.component.html
@@ -0,0 +1,255 @@
+
+ @if (draggable()) {
+
drag_indicator
+ }
+
+
+
+ {{ name() }}
+ @if (tag(); as tag) {
+ {{ tag.label }}
+ }
+
+
+ @for (part of meta(); track part.kind; let first = $first) {
+ @if (!first) {
+ ·
+ }
+ {{ part.text }}
+ }
+
+ @if (busy() && busyMessage()) {
+
+ {{
+ busyMessage()
+ }}
+ @if (busyProgress() !== null) {
+ {{ busyProgress() }}%
+ }
+
+
+ }
+
+
+
+ @if (compact()) {
+ @if (current()) {
+ check
+ }
+
+
+ more_horiz
+
+ } @else {
+ @if (canCancelBusy()) {
+
+ }
+ @if (failing() && canRecheck()) {
+
+ } @else if (canRefresh()) {
+
+ }
+
+
+ }
+
+
+
+
diff --git a/libs/playlist/shared/ui/src/lib/source-row/source-row.component.scss b/libs/playlist/shared/ui/src/lib/source-row/source-row.component.scss
new file mode 100644
index 000000000..c97c7f587
--- /dev/null
+++ b/libs/playlist/shared/ui/src/lib/source-row/source-row.component.scss
@@ -0,0 +1,341 @@
+@use '../../../../../../ui/styles/app-controls' as controls;
+@use '../../../../../../ui/styles/focus-ring';
+
+// `--source-row-base` is the surface the list sits on (the page or the
+// popover). Row fills are solid mixes of it, so the status dot's ring, which
+// takes the row's fill, always matches what is behind the dot.
+:host {
+ display: block;
+ --source-row-base: var(--app-content-bg);
+}
+
+.source-row {
+ --source-row-fill: var(--source-row-base);
+ --source-tile-ring: var(--source-row-fill);
+
+ position: relative;
+ display: grid;
+ grid-template-columns: 36px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 14px;
+ min-height: 56px;
+ padding: 8px 12px;
+ box-sizing: border-box;
+ border-radius: 10px;
+ background: var(--source-row-fill);
+ cursor: pointer;
+
+ &:has(> .source-row__handle) {
+ grid-template-columns: 18px 36px minmax(0, 1fr) auto;
+ gap: 10px 14px;
+ padding-inline-start: 4px;
+ }
+
+ &:hover,
+ &:focus-within,
+ &.source-row--menu-open {
+ --source-row-fill: color-mix(
+ in srgb,
+ var(--app-on-surface) 5%,
+ var(--source-row-base)
+ );
+ }
+}
+
+.source-row--busy {
+ cursor: default;
+}
+
+// ── Compact (playlist switcher) ──────────────────────────────────────────
+.source-row--compact {
+ grid-template-columns: 32px minmax(0, 1fr) auto;
+ gap: 12px;
+ min-height: 52px;
+ padding: 0 8px;
+ border-radius: 9px;
+
+ &.source-row--highlighted {
+ --source-row-fill: color-mix(
+ in srgb,
+ var(--app-on-surface) 7%,
+ var(--source-row-base)
+ );
+ // The listbox keeps focus in its search field; the highlighted
+ // option is the focus indicator.
+ @include focus-ring.focus-ring-declarations($offset: -2px);
+ }
+
+ &.source-row--current {
+ // The same selected-row look as the side lists: a soft tint, and an
+ // outline in light theme. The dot's ring approximates the tint.
+ --source-tile-ring: color-mix(
+ in srgb,
+ var(--app-selection-color) 16%,
+ var(--source-row-base)
+ );
+ background: var(--app-list-selected-bg);
+ box-shadow: inset 0 0 0 1px var(--app-list-selected-outline);
+ }
+}
+
+// ── Tile column ──────────────────────────────────────────────────────────
+.source-row__handle {
+ width: 18px;
+ height: 18px;
+ font-size: 18px;
+ color: var(--app-muted-color);
+ cursor: grab;
+}
+
+.source-row--failing app-source-tile {
+ --source-tile-color: var(--app-muted-color);
+}
+
+// ── Text column ──────────────────────────────────────────────────────────
+.source-row__body {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ min-width: 0;
+ // The row draws this element's focus ring.
+ outline: none;
+
+ .source-row--compact & {
+ gap: 2px;
+ }
+}
+
+// The keyboard ring belongs to the whole row, the unit that opens; inset,
+// since the list's scroller would cut one drawn outside the row.
+.source-row:has(> .source-row__body:focus-visible) {
+ @include focus-ring.focus-ring-declarations($offset: -2px);
+}
+
+.source-row__title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+
+.source-row__name {
+ min-width: 0;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ font-size: 14px;
+ font-weight: 500;
+ line-height: 1.3;
+ color: var(--app-heading-color);
+
+ .source-row--compact & {
+ font-size: 13.5px;
+ }
+
+ .source-row--failing & {
+ color: var(--app-body-color);
+ }
+}
+
+.source-row__tag {
+ flex: none;
+ padding: 2px 7px;
+ border-radius: 999px;
+ background: var(--app-danger-surface);
+ color: var(--app-danger-text);
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 1.3;
+ white-space: nowrap;
+}
+
+.source-row__tag--neutral {
+ background: var(--app-neutral-tag-surface);
+ color: var(--app-body-color);
+}
+
+.source-row__meta {
+ display: flex;
+ align-items: baseline;
+ gap: 6px;
+ min-width: 0;
+ overflow: hidden;
+ white-space: nowrap;
+ font-size: 12.5px;
+ line-height: 1.35;
+ color: var(--app-body-color);
+
+ .source-row--compact & {
+ font-size: 12px;
+ }
+}
+
+.source-row__sep {
+ color: var(--app-muted-color);
+}
+
+.source-row__part {
+ flex: none;
+}
+
+.source-row__part--number {
+ font-variant-numeric: tabular-nums;
+}
+
+.source-row__part--url {
+ flex: 0 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-family: var(--app-font-mono);
+ font-size: 12px;
+ font-weight: 400;
+ color: var(--app-muted-color);
+}
+
+.source-row__busy {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ font-size: 12px;
+ color: var(--app-body-color);
+}
+
+.source-row__busy-message {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.source-row__busy-value {
+ font-variant-numeric: tabular-nums;
+}
+
+.source-row__busy-bar {
+ margin-top: 2px;
+}
+
+// ── Actions ──────────────────────────────────────────────────────────────
+.source-row__actions {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+}
+
+.source-row__action {
+ @include controls.ghost-icon-button(32px, 14px);
+}
+
+.source-row__retry {
+ @include controls.ghost-button(sm);
+}
+
+// Row actions appear on hover, keyboard focus and while the menu is open.
+// A failing row keeps Retry in view; touch screens, which cannot hover,
+// always show them.
+.source-row__action,
+.source-row__more--compact {
+ opacity: 0;
+ transition: opacity 120ms ease;
+}
+
+.source-row:hover,
+.source-row:focus-within,
+.source-row--menu-open,
+.source-row--current,
+.source-row--highlighted {
+ .source-row__action,
+ .source-row__more--compact {
+ opacity: 1;
+ }
+}
+
+.source-row__action--visible {
+ opacity: 1;
+}
+
+@media (hover: none) {
+ .source-row__action,
+ .source-row__more--compact {
+ opacity: 1;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .source-row__action,
+ .source-row__more--compact {
+ transition: none;
+ }
+}
+
+.source-row__check {
+ width: 18px;
+ height: 18px;
+ font-size: 18px;
+ color: var(--app-selection-color);
+}
+
+.source-row__more--compact {
+ display: grid;
+ place-items: center;
+ width: 30px;
+ height: 30px;
+ border-radius: 8px;
+ color: var(--app-body-color);
+
+ &:hover {
+ background: color-mix(in srgb, var(--app-on-surface) 8%, transparent);
+ color: var(--app-heading-color);
+ }
+
+ .mat-icon {
+ width: 16px;
+ height: 16px;
+ font-size: 16px;
+ }
+}
+
+// ── Drag and drop (custom order) ─────────────────────────────────────────
+.cdk-drag-preview {
+ box-shadow: 0 12px 30px rgba(0, 0, 0, 0.3);
+}
+
+.cdk-drag-placeholder {
+ opacity: 0;
+}
+
+.cdk-drag-animating {
+ transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .cdk-drag-animating {
+ transition: none;
+ }
+}
+
+// Phones keep the ⋯ menu, which holds every action, and drop the rest.
+@media (max-width: 480px) {
+ .source-row {
+ gap: 10px;
+ padding-inline: 8px;
+ }
+
+ .source-row__action:not(.source-row__more):not(
+ .source-row__action--visible
+ ) {
+ display: none;
+ }
+
+ .source-row__part--url,
+ .source-row__sep:has(+ .source-row__part--url) {
+ display: none;
+ }
+}
+
+// UI-kit menu panel. The panel renders in the overlay container, so the
+// rule is global; it ships with the components that use `class="app-menu"`
+// rather than in the app's initial stylesheet.
+::ng-deep .mat-mdc-menu-panel.app-menu {
+ @include controls.menu-panel;
+}
diff --git a/libs/playlist/shared/ui/src/lib/source-row/source-row.component.spec.ts b/libs/playlist/shared/ui/src/lib/source-row/source-row.component.spec.ts
new file mode 100644
index 000000000..163aa7f41
--- /dev/null
+++ b/libs/playlist/shared/ui/src/lib/source-row/source-row.component.spec.ts
@@ -0,0 +1,175 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { TranslateModule } from '@ngx-translate/core';
+import { SourceStatusService } from '@iptvnator/portal/shared/data-access';
+import { PlaylistMeta, SourceStatus } from '@iptvnator/shared/interfaces';
+import { SourceRowComponent } from './source-row.component';
+
+const M3U: PlaylistMeta = {
+ _id: 'm3u',
+ title: 'get.php?username=joe&password=hunter2&type=m3u_plus',
+ url: 'http://biggie.to:8080/get.php?username=joe&password=hunter2&type=m3u_plus',
+ count: 1803,
+ importDate: new Date(2026, 8, 18).toISOString(),
+ autoRefresh: false,
+};
+const LOCAL: PlaylistMeta = {
+ _id: 'local',
+ title: 'playlist (13)',
+ count: 3220,
+ importDate: new Date(2026, 8, 30).toISOString(),
+ autoRefresh: false,
+};
+
+describe('SourceRowComponent', () => {
+ let fixture: ComponentFixture;
+ let statuses: Map;
+ let statusService: {
+ statusOf: jest.Mock;
+ tracks: jest.Mock;
+ ensureChecked: jest.Mock;
+ recheck: jest.Mock;
+ };
+
+ const render = (
+ playlist: PlaylistMeta,
+ inputs: Record = {}
+ ): HTMLElement => {
+ fixture = TestBed.createComponent(SourceRowComponent);
+ fixture.componentRef.setInput('playlist', playlist);
+ for (const [name, value] of Object.entries(inputs)) {
+ fixture.componentRef.setInput(name, value);
+ }
+ fixture.detectChanges();
+ return fixture.nativeElement as HTMLElement;
+ };
+
+ beforeEach(() => {
+ statuses = new Map();
+ statusService = {
+ statusOf: jest.fn((p: PlaylistMeta) =>
+ p.url || p.serverUrl ? (statuses.get(p._id) ?? null) : null
+ ),
+ tracks: jest.fn((p: PlaylistMeta) => !!(p.url || p.serverUrl)),
+ ensureChecked: jest.fn(),
+ recheck: jest.fn(),
+ };
+ TestBed.configureTestingModule({
+ imports: [
+ SourceRowComponent,
+ NoopAnimationsModule,
+ TranslateModule.forRoot(),
+ ],
+ providers: [
+ { provide: SourceStatusService, useValue: statusService },
+ ],
+ });
+ });
+
+ it('shows the host and a credential-free URL, never the password', () => {
+ const element = render(M3U);
+ expect(element.querySelector('.source-row__name')?.textContent).toBe(
+ 'biggie.to:8080'
+ );
+ expect(
+ element.querySelector('.source-row__part--url')?.textContent
+ ).toBe('http://biggie.to:8080/get.php?type=m3u_plus');
+ expect(element.textContent).not.toMatch(/hunter2|password=/);
+ expect(
+ element
+ .querySelector('.source-row__body')
+ ?.getAttribute('aria-label')
+ ).toBe('biggie.to:8080');
+ });
+
+ it('draws no status dot for a local file', () => {
+ const element = render(LOCAL);
+ expect(element.querySelector('.source-tile__dot')).toBeNull();
+ expect(element.querySelector('.source-row__part--url')).toBeNull();
+ });
+
+ it('tags a failing source and offers Retry, which rechecks it', () => {
+ statuses.set('m3u', { reachability: 'bad' });
+ const element = render(M3U, { canRefresh: true });
+ expect(element.querySelector('.source-row__tag')?.textContent).toBe(
+ 'WORKSPACE.SOURCES.ROW.UNREACHABLE'
+ );
+ const retry =
+ element.querySelector('.source-row__retry');
+ expect(retry).not.toBeNull();
+ retry?.click();
+ expect(statusService.recheck).toHaveBeenCalledWith(M3U);
+ expect(
+ element
+ .querySelector('.source-tile__dot')
+ ?.getAttribute('data-state')
+ ).toBe('bad');
+ });
+
+ it('opens on click and on Enter, but not while busy', () => {
+ const element = render(M3U);
+ const opened = jest.fn();
+ fixture.componentInstance.activated.subscribe(opened);
+
+ element.querySelector('.source-row')?.click();
+ element
+ .querySelector('.source-row__body')
+ ?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
+ expect(opened).toHaveBeenCalledTimes(2);
+
+ fixture.componentRef.setInput('deleting', true);
+ fixture.detectChanges();
+ element.querySelector('.source-row')?.click();
+ expect(opened).toHaveBeenCalledTimes(2);
+ });
+
+ it('refreshes the focused row with R', () => {
+ const element = render(M3U, { canRefresh: true });
+ const refresh = jest.fn();
+ fixture.componentInstance.refreshRequested.subscribe(refresh);
+ const row = element.querySelector('.source-row');
+
+ row?.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'r', bubbles: true })
+ );
+ row?.dispatchEvent(
+ new KeyboardEvent('keydown', {
+ key: 'r',
+ metaKey: true,
+ bubbles: true,
+ })
+ );
+ expect(refresh).toHaveBeenCalledTimes(1);
+ });
+
+ it('is a plain option in the switcher, with a pointer-only menu', () => {
+ const element = render(M3U, { size: 'compact', current: true });
+ expect(
+ element.querySelector('.source-row__body')?.getAttribute('role')
+ ).toBeNull();
+ expect(element.querySelector('button')).toBeNull();
+ expect(
+ element
+ .querySelector('.source-row__more--compact')
+ ?.getAttribute('aria-hidden')
+ ).toBe('true');
+ expect(element.querySelector('.source-row__check')).not.toBeNull();
+ expect(element.querySelector('.source-row__part--url')).toBeNull();
+ });
+
+ it('checks the source while shown and cancels when removed', () => {
+ render(M3U);
+ expect(statusService.ensureChecked).toHaveBeenCalledWith(
+ M3U,
+ expect.any(AbortSignal)
+ );
+ const signal = statusService.ensureChecked.mock.calls[0][1];
+ fixture.destroy();
+ expect((signal as AbortSignal).aborted).toBe(true);
+ });
+
+ it('does not check the source when told not to', () => {
+ render(M3U, { checkHealth: false });
+ expect(statusService.ensureChecked).not.toHaveBeenCalled();
+ });
+});
diff --git a/libs/playlist/shared/ui/src/lib/source-row/source-row.component.ts b/libs/playlist/shared/ui/src/lib/source-row/source-row.component.ts
new file mode 100644
index 000000000..1febf5dbd
--- /dev/null
+++ b/libs/playlist/shared/ui/src/lib/source-row/source-row.component.ts
@@ -0,0 +1,213 @@
+import { Clipboard } from '@angular/cdk/clipboard';
+import { DragDropModule } from '@angular/cdk/drag-drop';
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ effect,
+ inject,
+ input,
+ output,
+ signal,
+ untracked,
+ viewChild,
+} from '@angular/core';
+import { MatButton, MatIconButton } from '@angular/material/button';
+import { MatDivider } from '@angular/material/divider';
+import { MatIcon } from '@angular/material/icon';
+import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
+import { MatProgressBar } from '@angular/material/progress-bar';
+import { MatProgressSpinner } from '@angular/material/progress-spinner';
+import { MatSnackBar } from '@angular/material/snack-bar';
+import { MatTooltip } from '@angular/material/tooltip';
+import { TranslatePipe, TranslateService } from '@ngx-translate/core';
+import { normalizeDateLocale } from '@iptvnator/pipes';
+import { SourceStatusService } from '@iptvnator/portal/shared/data-access';
+import { injectTranslationTick } from '@iptvnator/services';
+import {
+ isPortalAccountPlaylist,
+ PlaylistMeta,
+} from '@iptvnator/shared/interfaces';
+import {
+ sourceCopyUrl,
+ sourceDisplayName,
+ SourceFormatContext,
+ sourceMetaParts,
+ sourceStatusTag,
+} from '@iptvnator/playlist/shared/util';
+import { SourceTileComponent } from './source-tile.component';
+
+/**
+ * One source in a list: the Sources page (`regular`, 56px, hover actions)
+ * or the playlist switcher (`compact`, 52px, a ⋯ menu only).
+ *
+ * The regular row opens from the keyboard through its title block, a
+ * `role="button"` sibling of the drag handle and the actions, so no control
+ * sits inside a button. In the switcher the host is a listbox option driven
+ * by `aria-activedescendant`; its ⋯ is pointer-only and the list opens the
+ * menu from the keyboard (`openMenu()`).
+ */
+@Component({
+ selector: 'app-source-row',
+ imports: [
+ DragDropModule,
+ MatButton,
+ MatDivider,
+ MatIcon,
+ MatIconButton,
+ MatMenuModule,
+ MatProgressBar,
+ MatProgressSpinner,
+ MatTooltip,
+ SourceTileComponent,
+ TranslatePipe,
+ ],
+ templateUrl: './source-row.component.html',
+ styleUrl: './source-row.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class SourceRowComponent {
+ private readonly statusService = inject(SourceStatusService);
+ private readonly translate = inject(TranslateService);
+ private readonly languageTick = injectTranslationTick();
+ private readonly clipboard = inject(Clipboard);
+ private readonly snackBar = inject(MatSnackBar);
+ private readonly menuTrigger = viewChild(MatMenuTrigger);
+
+ readonly playlist = input.required();
+ readonly size = input<'regular' | 'compact'>('regular');
+ /** The active playlist (switcher): filled row, check mark, ⋯ shown. */
+ readonly current = input(false);
+ /** The keyboard highlight of the switcher's listbox. */
+ readonly highlighted = input(false);
+ /** Runs a passive reachability check while the row is shown. */
+ readonly checkHealth = input(true);
+ readonly canRefresh = input(false);
+ readonly draggable = input(false);
+ readonly refreshing = input(false);
+ readonly deleting = input(false);
+ readonly busyMessage = input('');
+ readonly busyProgress = input(null);
+ readonly canCancelBusy = input(false);
+
+ readonly activated = output();
+ readonly refreshRequested = output();
+ readonly editRequested = output();
+ readonly removeRequested = output();
+ readonly cancelBusyRequested = output();
+ readonly playlistInfoRequested = output();
+ readonly accountInfoRequested = output();
+ readonly menuClosed = output();
+
+ readonly menuOpen = signal(false);
+ readonly compact = computed(() => this.size() === 'compact');
+ readonly busy = computed(() => this.refreshing() || this.deleting());
+ readonly status = computed(() =>
+ this.statusService.statusOf(this.playlist())
+ );
+ readonly failing = computed(() => {
+ const reachability = this.status()?.reachability;
+ return reachability === 'bad' || reachability === 'expired';
+ });
+ readonly canRecheck = computed(() =>
+ this.statusService.tracks(this.playlist())
+ );
+ readonly copyUrl = computed(() => sourceCopyUrl(this.playlist()));
+ readonly hasAccountInfo = computed(() =>
+ isPortalAccountPlaylist(this.playlist())
+ );
+
+ private readonly formatContext = computed(() => {
+ this.languageTick();
+ return {
+ translate: (key, params) => this.translate.instant(key, params),
+ locale: normalizeDateLocale(
+ this.translate.currentLang || this.translate.defaultLang
+ ),
+ now: Date.now(),
+ };
+ });
+ readonly name = computed(() => {
+ this.languageTick();
+ return (
+ sourceDisplayName(this.playlist()) ??
+ this.translate.instant('HOME.PLAYLISTS.UNTITLED_PLAYLIST')
+ );
+ });
+ readonly meta = computed(() =>
+ sourceMetaParts(this.playlist(), this.formatContext(), this.compact())
+ );
+ readonly tag = computed(() =>
+ sourceStatusTag(this.status(), this.formatContext(), this.compact())
+ );
+
+ constructor() {
+ effect((onCleanup) => {
+ const playlist = this.playlist();
+ if (!this.checkHealth()) {
+ return;
+ }
+ const controller = new AbortController();
+ untracked(() =>
+ this.statusService.ensureChecked(playlist, controller.signal)
+ );
+ onCleanup(() => controller.abort());
+ });
+ }
+
+ onRowClick(): void {
+ if (!this.busy()) {
+ this.activated.emit();
+ }
+ }
+
+ /** Enter/Space on the title block; Space must not scroll the page. */
+ onActivationKey(event: Event): void {
+ event.preventDefault();
+ this.onRowClick();
+ }
+
+ /** `R` refreshes the focused row, as its menu item says. */
+ onRowKeydown(event: KeyboardEvent): void {
+ const plain =
+ !event.ctrlKey &&
+ !event.metaKey &&
+ !event.altKey &&
+ !event.shiftKey;
+ if (
+ plain &&
+ event.key.toLowerCase() === 'r' &&
+ !this.compact() &&
+ this.canRefresh() &&
+ !this.busy()
+ ) {
+ event.preventDefault();
+ this.refreshRequested.emit();
+ }
+ }
+
+ recheck(): void {
+ this.statusService.recheck(this.playlist());
+ }
+
+ copyUrlToClipboard(): void {
+ const url = this.copyUrl();
+ if (url && this.clipboard.copy(url)) {
+ this.snackBar.open(
+ this.translate.instant('HOME.PLAYLISTS.INFO_DIALOG.URL_COPIED'),
+ undefined,
+ { duration: 2000 }
+ );
+ }
+ }
+
+ /** Opens the ⋯ menu from the keyboard (switcher: Shift+F10). */
+ openMenu(): void {
+ this.menuTrigger()?.openMenu();
+ }
+
+ onMenuClosed(): void {
+ this.menuOpen.set(false);
+ this.menuClosed.emit();
+ }
+}
diff --git a/libs/playlist/shared/ui/src/lib/source-row/source-tile.component.scss b/libs/playlist/shared/ui/src/lib/source-row/source-tile.component.scss
new file mode 100644
index 000000000..29da213a4
--- /dev/null
+++ b/libs/playlist/shared/ui/src/lib/source-row/source-tile.component.scss
@@ -0,0 +1,70 @@
+// The provider tile of a source row and the switcher trigger. The dot's
+// 2px ring takes `--source-tile-ring`, which the row sets to its own
+// background, so the dot reads as cut out of the tile in every state.
+:host {
+ --source-tile-size: 36px;
+ --source-tile-radius: 9px;
+ --source-tile-icon: 17px;
+ --source-tile-dot: 11px;
+ --source-tile-dot-offset: -3px;
+
+ position: relative;
+ flex: none;
+ display: grid;
+ place-items: center;
+ width: var(--source-tile-size);
+ height: var(--source-tile-size);
+ border-radius: var(--source-tile-radius);
+ background: var(--source-tile-bg, var(--app-widget-bg));
+ color: var(--source-tile-color, var(--app-body-color));
+}
+
+:host(.source-tile--md) {
+ --source-tile-size: 32px;
+ --source-tile-radius: 8px;
+ --source-tile-icon: 15px;
+ --source-tile-dot: 9px;
+}
+
+:host(.source-tile--sm) {
+ --source-tile-size: 28px;
+ --source-tile-radius: 7px;
+ --source-tile-icon: 14px;
+ --source-tile-dot: 9px;
+ --source-tile-dot-offset: -2px;
+}
+
+.source-tile__icon {
+ width: var(--source-tile-icon);
+ height: var(--source-tile-icon);
+ font-size: var(--source-tile-icon);
+ line-height: var(--source-tile-icon);
+}
+
+.source-tile__dot {
+ position: absolute;
+ right: var(--source-tile-dot-offset);
+ bottom: var(--source-tile-dot-offset);
+ box-sizing: border-box;
+ width: var(--source-tile-dot);
+ height: var(--source-tile-dot);
+ border-radius: 50%;
+ border: 2px solid var(--source-tile-ring, var(--app-content-bg));
+ background: var(--app-muted-color);
+
+ &[data-state='ok'] {
+ background: var(--app-success-color);
+ }
+
+ &[data-state='bad'] {
+ background: var(--app-live-color);
+ }
+
+ &[data-state='expired'] {
+ background: var(--app-expiry-warn-text);
+ }
+}
+
+.source-tile__dot--pending {
+ opacity: 0.45;
+}
diff --git a/libs/playlist/shared/ui/src/lib/source-row/source-tile.component.ts b/libs/playlist/shared/ui/src/lib/source-row/source-tile.component.ts
new file mode 100644
index 000000000..3878801a3
--- /dev/null
+++ b/libs/playlist/shared/ui/src/lib/source-row/source-tile.component.ts
@@ -0,0 +1,102 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ inject,
+ input,
+} from '@angular/core';
+import { MatIcon } from '@angular/material/icon';
+import { MatTooltip } from '@angular/material/tooltip';
+import { TranslateService } from '@ngx-translate/core';
+import { normalizeDateLocale } from '@iptvnator/pipes';
+import { injectTranslationTick } from '@iptvnator/services';
+import {
+ getPlaylistSourceIcon,
+ PlaylistMeta,
+ SourceReachability,
+ SourceStatus,
+} from '@iptvnator/shared/interfaces';
+
+/** The health reason shown for each reachability when no detail is kept. */
+const REACHABILITY_REASON: Record = {
+ ok: 'available',
+ bad: 'network',
+ expired: 'expired',
+ checking: 'checking',
+ unknown: 'unknown',
+};
+
+/**
+ * The provider icon on a rounded tile, with the status dot at its corner:
+ * green answers, red does not, orange is an expired account, grey is not
+ * checked yet. Local files have no dot.
+ */
+@Component({
+ selector: 'app-source-tile',
+ imports: [MatIcon, MatTooltip],
+ template: `
+ {{
+ icon()
+ }}
+ @if (status(); as status) {
+
+ }
+ `,
+ styleUrl: './source-tile.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: {
+ class: 'source-tile',
+ '[class.source-tile--sm]': "size() === 'sm'",
+ '[class.source-tile--md]': "size() === 'md'",
+ '[class.source-tile--lg]': "size() === 'lg'",
+ },
+})
+export class SourceTileComponent {
+ private readonly translate = inject(TranslateService);
+ private readonly languageTick = injectTranslationTick();
+
+ readonly playlist =
+ input.required<
+ Pick
+ >();
+ readonly status = input(null);
+ /** 28px (trigger), 32px (switcher) or 36px (Sources page). */
+ readonly size = input<'sm' | 'md' | 'lg'>('lg');
+
+ readonly icon = computed(() => getPlaylistSourceIcon(this.playlist()));
+ readonly dotState = computed(() => {
+ const reachability = this.status()?.reachability;
+ return reachability === 'checking' ? 'unknown' : reachability;
+ });
+ readonly description = computed(() => {
+ this.languageTick();
+ const status = this.status();
+ if (!status) {
+ return '';
+ }
+ const reason = status.pending
+ ? 'checking'
+ : REACHABILITY_REASON[status.reachability];
+ const label = this.translate.instant(`SOURCE_HEALTH.REASON.${reason}`);
+ const time = status.checkedAt
+ ? new Date(status.checkedAt).toLocaleString(
+ normalizeDateLocale(this.translate.currentLang || 'en'),
+ {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ }
+ )
+ : '';
+ return [label, time].filter(Boolean).join(' · ');
+ });
+}
diff --git a/libs/playlist/shared/util/src/index.ts b/libs/playlist/shared/util/src/index.ts
index 7fc8eefe5..f19c69beb 100644
--- a/libs/playlist/shared/util/src/index.ts
+++ b/libs/playlist/shared/util/src/index.ts
@@ -1,2 +1,4 @@
export * from './lib/playlist-context.facade';
export * from './lib/playlist-file-import.service';
+export * from './lib/source-display/source-display';
+export * from './lib/source-display/source-format';
diff --git a/libs/playlist/shared/util/src/lib/source-display/source-display.spec.ts b/libs/playlist/shared/util/src/lib/source-display/source-display.spec.ts
new file mode 100644
index 000000000..74696d6a5
--- /dev/null
+++ b/libs/playlist/shared/util/src/lib/source-display/source-display.spec.ts
@@ -0,0 +1,175 @@
+import {
+ sourceCopyUrl,
+ sourceDisplayName,
+ sourceDisplayUrl,
+ sourceHost,
+} from './source-display';
+
+const M3U_URL =
+ 'http://biggie.to:8080/get.php?username=joe&password=hunter2&type=m3u_plus';
+
+describe('sourceDisplayName', () => {
+ it('keeps a name the user chose', () => {
+ expect(sourceDisplayName({ title: 'Free TV', url: M3U_URL })).toBe(
+ 'Free TV'
+ );
+ });
+
+ it('shows the host when the source has no name', () => {
+ expect(sourceDisplayName({ title: '', url: M3U_URL })).toBe(
+ 'biggie.to:8080'
+ );
+ expect(
+ sourceDisplayName({
+ title: '',
+ serverUrl: 'http://xtream.tv:80',
+ username: 'joe',
+ password: 'hunter2',
+ })
+ ).toBe('xtream.tv');
+ });
+
+ it('shows the host for the name an URL import derives', () => {
+ // The import stores the URL's last segment, query and all.
+ expect(
+ sourceDisplayName({
+ title: 'get.php?username=joe&password=hunter2&type=m3u_plus',
+ url: M3U_URL,
+ })
+ ).toBe('biggie.to:8080');
+ });
+
+ it('shows the host for a pasted URL', () => {
+ expect(
+ sourceDisplayName({
+ title: 'https://cdn.tv/lists/clean.m3u',
+ url: 'https://cdn.tv/lists/clean.m3u',
+ })
+ ).toBe('cdn.tv');
+ });
+
+ it('drops a name that carries a credential', () => {
+ expect(
+ sourceDisplayName({
+ title: 'My portal pass== hunter2',
+ serverUrl: 'http://xtream.tv',
+ })
+ ).toBe('xtream.tv');
+ expect(
+ sourceDisplayName({
+ title: 'backup hunter2',
+ serverUrl: 'http://xtream.tv',
+ password: 'hunter2',
+ })
+ ).toBe('xtream.tv');
+ expect(
+ sourceDisplayName({
+ title: 'Box 00:1A:79:00:00:01',
+ macAddress: '00:1A:79:00:00:01',
+ portalUrl: 'http://portal.tv/c/',
+ })
+ ).toBe('portal.tv');
+ });
+
+ it('keeps an account name in the name: it is shown by design', () => {
+ expect(sourceDisplayName({ title: 'joe', url: M3U_URL })).toBe('joe');
+ });
+
+ it('returns null for a local file without a name', () => {
+ expect(sourceDisplayName({ title: '' })).toBeNull();
+ expect(sourceDisplayName({ title: '', filename: 'list.m3u' })).toBe(
+ 'list.m3u'
+ );
+ });
+});
+
+describe('sourceDisplayUrl', () => {
+ it('shows an M3U URL without credentials', () => {
+ expect(sourceDisplayUrl({ url: M3U_URL })).toBe(
+ 'http://biggie.to:8080/get.php?type=m3u_plus'
+ );
+ });
+
+ it('shortens a long path to its last segment', () => {
+ expect(
+ sourceDisplayUrl({
+ url: 'https://raw.githubusercontent.com/user/repo/main/clean.m3u',
+ })
+ ).toBe('https://raw.githubusercontent.com/…/clean.m3u');
+ expect(
+ sourceDisplayUrl({
+ url: 'https://iptv-org.github.io/iptv/index.m3u',
+ })
+ ).toBe('https://iptv-org.github.io/iptv/index.m3u');
+ });
+
+ it('shows host, port and account for Xtream, never the password', () => {
+ expect(
+ sourceDisplayUrl({
+ serverUrl: 'http://xtream.tv:8080/',
+ username: 'joe',
+ password: 'hunter2',
+ })
+ ).toBe('http://xtream.tv:8080 · joe');
+ });
+
+ it('shows the portal URL for Stalker, never the MAC', () => {
+ expect(
+ sourceDisplayUrl({
+ macAddress: '00:1A:79:00:00:01',
+ portalUrl:
+ 'http://portal.tv/stalker_portal/c/?mac=00:1A:79:00:00:01',
+ })
+ ).toBe('http://portal.tv/stalker_portal/c/');
+ });
+
+ it('falls back to the origin when a secret sits in the path', () => {
+ expect(
+ sourceDisplayUrl({
+ macAddress: '00:1A:79:00:00:01',
+ portalUrl: 'http://portal.tv/00:1A:79:00:00:01/c/',
+ })
+ ).toBe('http://portal.tv');
+ });
+
+ it('has no URL for a local file or an unparsable address', () => {
+ expect(sourceDisplayUrl({ title: 'local' })).toBeNull();
+ expect(sourceDisplayUrl({ url: 'not a url' })).toBeNull();
+ expect(
+ sourceDisplayUrl({ serverUrl: 'nope', username: 'joe' })
+ ).toBeNull();
+ });
+
+ it('never shows a password, token or MAC', () => {
+ const sources = [
+ { url: M3U_URL },
+ { url: 'http://joe:hunter2@cdn.tv/a/b/c/list.m3u?token=hunter2' },
+ { serverUrl: 'http://x.tv', username: 'joe', password: 'hunter2' },
+ {
+ macAddress: 'hunter2',
+ portalUrl: 'http://p.tv/c/?mac=hunter2&sn=hunter2',
+ },
+ ];
+ for (const source of sources) {
+ expect(sourceDisplayUrl(source) ?? '').not.toMatch(/hunter2/);
+ expect(
+ sourceDisplayName({ ...source, title: '' }) ?? ''
+ ).not.toMatch(/hunter2/);
+ }
+ });
+});
+
+describe('sourceHost and sourceCopyUrl', () => {
+ it('reads the endpoint by provider precedence', () => {
+ const stalker = {
+ macAddress: '00:1A:79:00:00:01',
+ portalUrl: 'http://portal.tv/c/',
+ url: 'http://other.tv/list.m3u',
+ };
+ expect(sourceHost(stalker)).toBe('portal.tv');
+ expect(sourceCopyUrl(stalker)).toBe('http://portal.tv/c/');
+ expect(sourceCopyUrl({ url: M3U_URL })).toBe(M3U_URL);
+ expect(sourceCopyUrl({})).toBeNull();
+ expect(sourceHost({})).toBeNull();
+ });
+});
diff --git a/libs/playlist/shared/util/src/lib/source-display/source-display.ts b/libs/playlist/shared/util/src/lib/source-display/source-display.ts
new file mode 100644
index 000000000..25012e48d
--- /dev/null
+++ b/libs/playlist/shared/util/src/lib/source-display/source-display.ts
@@ -0,0 +1,184 @@
+import { isSensitiveKey, stripUrlCredentials } from '@iptvnator/shared/logging';
+import {
+ PlaylistMeta,
+ playlistDisplayLabel,
+ playlistNameHasCredentials,
+ resolvePlaylistSourceKind,
+} from '@iptvnator/shared/interfaces';
+
+export type SourceDisplayFields = Partial<
+ Pick<
+ PlaylistMeta,
+ | 'title'
+ | 'filename'
+ | 'url'
+ | 'serverUrl'
+ | 'portalUrl'
+ | 'macAddress'
+ | 'username'
+ | 'password'
+ >
+>;
+
+/** The account name stays on screen by design; every other secret goes. */
+const SHOWN_ACCOUNT_KEYS = new Set(['username', 'login']);
+/** Shorter values match ordinary words, not secrets. */
+const MIN_SECRET_LENGTH = 3;
+
+function parseUrl(value: string | undefined): URL | null {
+ if (!value) {
+ return null;
+ }
+ try {
+ return new URL(value.trim());
+ } catch {
+ return null;
+ }
+}
+
+function safeDecode(value: string): string {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return value;
+ }
+}
+
+/** The portal, server or playlist URL a source is loaded from. */
+export function sourceEndpoint(
+ playlist: Pick<
+ SourceDisplayFields,
+ 'macAddress' | 'serverUrl' | 'portalUrl' | 'url'
+ >
+): string | undefined {
+ if (playlist.macAddress) {
+ return playlist.portalUrl || playlist.url;
+ }
+ return playlist.serverUrl || playlist.url;
+}
+
+/** Host and non-default port of the endpoint; null for a local file. */
+export function sourceHost(
+ playlist: Pick<
+ SourceDisplayFields,
+ 'macAddress' | 'serverUrl' | 'portalUrl' | 'url'
+ >
+): string | null {
+ return parseUrl(sourceEndpoint(playlist))?.host || null;
+}
+
+/**
+ * Values that must never be on screen for this source: the password, the
+ * MAC address, and every sensitive parameter of its URL except the account
+ * name, in both decoded and encoded form.
+ */
+function sourceSecrets(playlist: SourceDisplayFields): string[] {
+ const secrets = [playlist.password, playlist.macAddress];
+ const url = parseUrl(sourceEndpoint(playlist));
+ if (url) {
+ secrets.push(safeDecode(url.password));
+ url.searchParams.forEach((value, key) => {
+ const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, '');
+ if (isSensitiveKey(key) && !SHOWN_ACCOUNT_KEYS.has(normalized)) {
+ secrets.push(value);
+ }
+ });
+ }
+ const shown: string[] = [];
+ for (const secret of secrets) {
+ if (secret && secret.length >= MIN_SECRET_LENGTH) {
+ shown.push(secret, encodeURIComponent(secret));
+ }
+ }
+ return shown;
+}
+
+export function containsSourceSecret(
+ text: string,
+ playlist: SourceDisplayFields
+): boolean {
+ return sourceSecrets(playlist).some((secret) => text.includes(secret));
+}
+
+/**
+ * A name an import made from the URL rather than one the user typed: the
+ * URL itself, or the last path segment of a URL with a query, such as
+ * `get.php?username=…&password=…`.
+ */
+function isUrlDerivedName(name: string, url: string | undefined): boolean {
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(name)) {
+ return true;
+ }
+ return !!url && name.includes('?') && url.trim().endsWith(name);
+}
+
+/**
+ * The name a source list shows. A name the user chose stays; an empty name,
+ * a URL or a URL-derived name becomes the host, and any name that carries a
+ * credential collapses to the host as well. Null when there is nothing safe
+ * to show; the caller renders its "untitled" label.
+ */
+export function sourceDisplayName(
+ playlist: SourceDisplayFields
+): string | null {
+ const raw = (playlist.title || playlist.filename || '').trim();
+ const host = sourceHost(playlist);
+ if (!raw) {
+ return host;
+ }
+ if (
+ isUrlDerivedName(raw, playlist.url) ||
+ playlistNameHasCredentials(raw)
+ ) {
+ return host ?? (playlistDisplayLabel(raw) || null);
+ }
+ return containsSourceSecret(raw, playlist) ? host : raw;
+}
+
+/** `origin/…/last` once a path has more than two segments. */
+function compactPath(pathname: string): string {
+ const segments = pathname.split('/').filter(Boolean);
+ return segments.length > 2
+ ? `/…/${segments[segments.length - 1]}`
+ : pathname;
+}
+
+/**
+ * The location a source list shows beside the name, without credentials:
+ * `scheme://host:port · username` for Xtream, the portal URL for Stalker,
+ * the playlist URL with a shortened path for M3U, and null for a local file.
+ */
+export function sourceDisplayUrl(playlist: SourceDisplayFields): string | null {
+ const kind = resolvePlaylistSourceKind(playlist);
+ if (kind === 'xtream') {
+ const server = parseUrl(playlist.serverUrl);
+ if (!server) {
+ return null;
+ }
+ const base = `${server.protocol}//${server.host}`;
+ return playlist.username ? `${base} · ${playlist.username}` : base;
+ }
+
+ const stripped = parseUrl(
+ stripUrlCredentials(sourceEndpoint(playlist) ?? '') ?? undefined
+ );
+ if (!stripped) {
+ return null;
+ }
+ const origin = `${stripped.protocol}//${stripped.host}`;
+ const shown = `${origin}${compactPath(stripped.pathname)}${stripped.search}${stripped.hash}`;
+ return containsSourceSecret(shown, playlist) ? origin : shown;
+}
+
+/**
+ * The full address "Copy URL" puts on the clipboard; credentials included,
+ * since that is what the user asked for. Null for a local file.
+ */
+export function sourceCopyUrl(
+ playlist: Pick<
+ SourceDisplayFields,
+ 'macAddress' | 'serverUrl' | 'portalUrl' | 'url'
+ >
+): string | null {
+ return sourceEndpoint(playlist)?.trim() || null;
+}
diff --git a/libs/playlist/shared/util/src/lib/source-display/source-format.spec.ts b/libs/playlist/shared/util/src/lib/source-display/source-format.spec.ts
new file mode 100644
index 000000000..544737d96
--- /dev/null
+++ b/libs/playlist/shared/util/src/lib/source-display/source-format.spec.ts
@@ -0,0 +1,200 @@
+import {
+ formatSourceDate,
+ formatSourceDuration,
+ SourceFormatContext,
+ sourceMetaParts,
+ sourceStatusTag,
+ sourceTypeLabel,
+} from './source-format';
+
+const NOW = new Date(2026, 9, 12, 12, 0).getTime();
+const DAY = 86_400_000;
+
+/** Echoes the key and its parameters, so specs read what was asked for. */
+const translate = (key: string, params?: Record) =>
+ params ? `${key} ${JSON.stringify(params)}` : key;
+
+const context = (locale = 'en'): SourceFormatContext => ({
+ translate,
+ locale,
+ now: NOW,
+});
+
+describe('formatSourceDate', () => {
+ it('shows day and month in the current year', () => {
+ const date = new Date(2026, 8, 6).toISOString();
+ expect(formatSourceDate(date, 'en', NOW)).toBe('Sep 6');
+ expect(formatSourceDate(date, 'ru', NOW)).toMatch(/^6 сент/);
+ });
+
+ it('adds the year for another year', () => {
+ const date = new Date(2025, 8, 6).toISOString();
+ expect(formatSourceDate(date, 'en', NOW)).toBe('Sep 6, 2025');
+ });
+
+ it('adds the time on request', () => {
+ const date = new Date(2026, 8, 6, 21, 34).getTime();
+ expect(formatSourceDate(date, 'en-GB', NOW, true)).toBe(
+ '6 Sept, 21:34'
+ );
+ });
+
+ it('reads epoch milliseconds stored as a string', () => {
+ const value = String(new Date(2026, 0, 2).getTime());
+ expect(formatSourceDate(value, 'en', NOW)).toBe('Jan 2');
+ });
+
+ it('returns null for an empty or invalid value', () => {
+ expect(formatSourceDate(undefined, 'en', NOW)).toBeNull();
+ expect(formatSourceDate('', 'en', NOW)).toBeNull();
+ expect(formatSourceDate('not a date', 'en', NOW)).toBeNull();
+ });
+
+ it('falls back to English for an unknown locale', () => {
+ const date = new Date(2026, 8, 6).toISOString();
+ expect(formatSourceDate(date, 'not_a-locale!', NOW)).toBe('Sep 6');
+ });
+});
+
+describe('formatSourceDuration', () => {
+ it('counts whole days, then hours, in the UI language', () => {
+ expect(formatSourceDuration(NOW - 3 * DAY - 5, 'en', NOW)).toBe(
+ '3 days'
+ );
+ expect(formatSourceDuration(NOW - 3 * DAY, 'ru', NOW)).toBe('3 дня');
+ expect(formatSourceDuration(NOW - 5 * 3_600_000, 'en', NOW)).toBe(
+ '5 hours'
+ );
+ });
+
+ it('says nothing under an hour', () => {
+ expect(formatSourceDuration(NOW - 60_000, 'en', NOW)).toBeNull();
+ });
+});
+
+describe('sourceMetaParts', () => {
+ const m3u = {
+ title: 'Free TV',
+ url: 'https://cdn.tv/a/b/list.m3u?token=secret1',
+ count: 16805,
+ importDate: new Date(2026, 8, 30).toISOString(),
+ updateDate: new Date(2026, 9, 8, 3, 0).getTime(),
+ };
+
+ it('lists type, channels, added, updated and URL in that order', () => {
+ const parts = sourceMetaParts(m3u, context());
+ expect(parts.map((part) => part.kind)).toEqual([
+ 'type',
+ 'channels',
+ 'added',
+ 'updated',
+ 'url',
+ ]);
+ expect(parts[0].text).toBe('M3U');
+ expect(parts[1].text).toBe(
+ 'HOME.PLAYLISTS.CHANNELS_COUNT {"count":"16,805"}'
+ );
+ expect(parts[4].text).toBe('https://cdn.tv/…/list.m3u');
+ });
+
+ it('leaves out empty parts and zero channels', () => {
+ const parts = sourceMetaParts(
+ { title: 'x', count: 0, importDate: '' },
+ context()
+ );
+ expect(parts).toEqual([
+ { kind: 'type', text: 'WORKSPACE.SOURCES.ROW.TYPE_M3U_FILE' },
+ ]);
+ });
+
+ it('shows no channel count for Xtream and Stalker', () => {
+ const parts = sourceMetaParts(
+ {
+ title: 'x',
+ serverUrl: 'http://x.tv',
+ username: 'joe',
+ password: 'p4ss',
+ count: 0,
+ importDate: '',
+ },
+ context()
+ );
+ expect(parts.map((part) => part.kind)).toEqual(['type', 'url']);
+ expect(parts[0].text).toBe('Xtream');
+ });
+
+ it('keeps type, channels and updated in the compact row', () => {
+ expect(
+ sourceMetaParts(m3u, context(), true).map((part) => part.kind)
+ ).toEqual(['type', 'channels', 'updated']);
+ });
+});
+
+describe('sourceTypeLabel', () => {
+ it('names every provider', () => {
+ expect(sourceTypeLabel({ url: 'http://x' }, translate)).toBe('M3U');
+ expect(sourceTypeLabel({}, translate)).toBe(
+ 'WORKSPACE.SOURCES.ROW.TYPE_M3U_FILE'
+ );
+ expect(sourceTypeLabel({ serverUrl: 'http://x' }, translate)).toBe(
+ 'Xtream'
+ );
+ expect(sourceTypeLabel({ macAddress: 'm' }, translate)).toBe('Stalker');
+ });
+});
+
+describe('sourceStatusTag', () => {
+ it('says how long a source has not responded', () => {
+ expect(
+ sourceStatusTag(
+ { reachability: 'bad', failingSince: NOW - 3 * DAY },
+ context()
+ )
+ ).toEqual({
+ tone: 'danger',
+ label: 'WORKSPACE.SOURCES.ROW.UNREACHABLE_FOR {"duration":"3 days"}',
+ });
+ });
+
+ it('drops the duration in the compact row and under an hour', () => {
+ const status = {
+ reachability: 'bad',
+ failingSince: NOW - 3 * DAY,
+ } as const;
+ expect(sourceStatusTag(status, context(), true)?.label).toBe(
+ 'WORKSPACE.SOURCES.ROW.UNREACHABLE'
+ );
+ expect(
+ sourceStatusTag(
+ { reachability: 'bad', failingSince: NOW - 1000 },
+ context()
+ )?.label
+ ).toBe('WORKSPACE.SOURCES.ROW.UNREACHABLE');
+ });
+
+ it('tags expired accounts and unchecked sources', () => {
+ expect(sourceStatusTag({ reachability: 'expired' }, context())).toEqual(
+ {
+ tone: 'danger',
+ label: 'WORKSPACE.SOURCES.ROW.EXPIRED',
+ }
+ );
+ expect(sourceStatusTag({ reachability: 'unknown' }, context())).toEqual(
+ {
+ tone: 'neutral',
+ label: 'SOURCE_HEALTH.REASON.unknown',
+ }
+ );
+ expect(
+ sourceStatusTag({ reachability: 'unknown' }, context(), true)
+ ).toBeNull();
+ });
+
+ it('has no tag for a healthy, checking or local source', () => {
+ expect(sourceStatusTag({ reachability: 'ok' }, context())).toBeNull();
+ expect(
+ sourceStatusTag({ reachability: 'checking' }, context())
+ ).toBeNull();
+ expect(sourceStatusTag(null, context())).toBeNull();
+ });
+});
diff --git a/libs/playlist/shared/util/src/lib/source-display/source-format.ts b/libs/playlist/shared/util/src/lib/source-display/source-format.ts
new file mode 100644
index 000000000..a2cb00418
--- /dev/null
+++ b/libs/playlist/shared/util/src/lib/source-display/source-format.ts
@@ -0,0 +1,216 @@
+import {
+ PlaylistMeta,
+ resolvePlaylistSourceKind,
+ SourceStatus,
+} from '@iptvnator/shared/interfaces';
+import { SourceDisplayFields, sourceDisplayUrl } from './source-display';
+
+/** `TranslateService.instant` or a test double. */
+export type SourceTranslate = (
+ key: string,
+ params?: Record
+) => string;
+
+export interface SourceFormatContext {
+ translate: SourceTranslate;
+ /** A BCP 47 locale for `Intl`, e.g. from `normalizeDateLocale`. */
+ locale: string;
+ now: number;
+}
+
+const DAY_MS = 86_400_000;
+const HOUR_MS = 3_600_000;
+
+/** Provider names are brands, the same in every language. */
+const KIND_LABELS = {
+ m3u: 'M3U',
+ xtream: 'Xtream',
+ stalker: 'Stalker',
+} as const;
+
+function toDate(value: string | number | undefined | null): Date | null {
+ if (value === undefined || value === null || value === '') {
+ return null;
+ }
+ const date =
+ typeof value === 'number' || /^\d+$/.test(value)
+ ? new Date(Number(value))
+ : new Date(value);
+ return Number.isNaN(date.getTime()) ? null : date;
+}
+
+function dateFormat(
+ locale: string,
+ options: Intl.DateTimeFormatOptions
+): Intl.DateTimeFormat {
+ try {
+ return new Intl.DateTimeFormat(locale, options);
+ } catch {
+ return new Intl.DateTimeFormat('en', options);
+ }
+}
+
+/**
+ * A short date in the UI language: day and month, the year only when it is
+ * not the current one, and the time when asked for.
+ */
+export function formatSourceDate(
+ value: string | number | undefined | null,
+ locale: string,
+ now: number,
+ withTime = false
+): string | null {
+ const date = toDate(value);
+ if (!date) {
+ return null;
+ }
+ const sameYear = date.getFullYear() === new Date(now).getFullYear();
+ return dateFormat(locale, {
+ day: 'numeric',
+ month: 'short',
+ ...(sameYear ? {} : { year: 'numeric' }),
+ ...(withTime ? { hour: '2-digit', minute: '2-digit' } : {}),
+ }).format(date);
+}
+
+/** "3 days" / "5 hours" in the UI language; null under an hour. */
+export function formatSourceDuration(
+ since: number,
+ locale: string,
+ now: number
+): string | null {
+ const elapsed = now - since;
+ const [unit, amount] =
+ elapsed >= DAY_MS
+ ? (['day', Math.floor(elapsed / DAY_MS)] as const)
+ : (['hour', Math.floor(elapsed / HOUR_MS)] as const);
+ if (amount < 1) {
+ return null;
+ }
+ // The app's TypeScript lib (es2018) predates unit formatting; every
+ // supported runtime has it.
+ const options = {
+ style: 'unit',
+ unit,
+ unitDisplay: 'long',
+ } as unknown as Intl.NumberFormatOptions;
+ try {
+ return new Intl.NumberFormat(locale, options).format(amount);
+ } catch {
+ return new Intl.NumberFormat('en', options).format(amount);
+ }
+}
+
+export function sourceTypeLabel(
+ playlist: Pick,
+ translate: SourceTranslate
+): string {
+ const kind = resolvePlaylistSourceKind(playlist);
+ if (kind === 'm3u' && !playlist.url) {
+ return translate('WORKSPACE.SOURCES.ROW.TYPE_M3U_FILE');
+ }
+ return KIND_LABELS[kind];
+}
+
+export type SourceMetaPartKind =
+ 'type' | 'channels' | 'added' | 'updated' | 'url';
+
+export interface SourceMetaPart {
+ kind: SourceMetaPartKind;
+ text: string;
+}
+
+export type SourceMetaFields = SourceDisplayFields &
+ Partial>;
+
+/**
+ * The meta line, always in this order: type · channels · added · updated ·
+ * URL, with empty parts left out. Only M3U playlists know their channel
+ * count. The compact switcher row keeps type, channels and updated.
+ */
+export function sourceMetaParts(
+ playlist: SourceMetaFields,
+ context: SourceFormatContext,
+ compact = false
+): SourceMetaPart[] {
+ const { translate, locale, now } = context;
+ const parts: SourceMetaPart[] = [
+ { kind: 'type', text: sourceTypeLabel(playlist, translate) },
+ ];
+ const isM3u = resolvePlaylistSourceKind(playlist) === 'm3u';
+ if (isM3u && (playlist.count ?? 0) > 0) {
+ const count = new Intl.NumberFormat(locale).format(playlist.count ?? 0);
+ parts.push({
+ kind: 'channels',
+ text: translate('HOME.PLAYLISTS.CHANNELS_COUNT', { count }),
+ });
+ }
+ const added = compact
+ ? null
+ : formatSourceDate(playlist.importDate, locale, now);
+ if (added) {
+ parts.push({
+ kind: 'added',
+ text: translate('WORKSPACE.SOURCES.ROW.ADDED', { date: added }),
+ });
+ }
+ const updated = formatSourceDate(playlist.updateDate, locale, now, true);
+ if (updated) {
+ parts.push({
+ kind: 'updated',
+ text: translate('WORKSPACE.SOURCES.ROW.UPDATED', { date: updated }),
+ });
+ }
+ const url = compact ? null : sourceDisplayUrl(playlist);
+ if (url) {
+ parts.push({ kind: 'url', text: url });
+ }
+ return parts;
+}
+
+export interface SourceStatusTag {
+ label: string;
+ tone: 'danger' | 'neutral';
+}
+
+/**
+ * The tag after the name. A healthy source has none: green is the dot's
+ * job. The compact row drops the duration and the "not checked" tag.
+ */
+export function sourceStatusTag(
+ status: SourceStatus | null,
+ context: SourceFormatContext,
+ compact = false
+): SourceStatusTag | null {
+ const { translate, locale, now } = context;
+ switch (status?.reachability) {
+ case 'bad': {
+ const duration =
+ !compact && status.failingSince !== undefined
+ ? formatSourceDuration(status.failingSince, locale, now)
+ : null;
+ return {
+ tone: 'danger',
+ label: duration
+ ? translate('WORKSPACE.SOURCES.ROW.UNREACHABLE_FOR', {
+ duration,
+ })
+ : translate('WORKSPACE.SOURCES.ROW.UNREACHABLE'),
+ };
+ }
+ case 'expired':
+ return {
+ tone: 'danger',
+ label: translate('WORKSPACE.SOURCES.ROW.EXPIRED'),
+ };
+ case 'unknown':
+ return compact
+ ? null
+ : {
+ tone: 'neutral',
+ label: translate('SOURCE_HEALTH.REASON.unknown'),
+ };
+ default:
+ return null;
+ }
+}
diff --git a/libs/portal/shared/data-access/src/index.ts b/libs/portal/shared/data-access/src/index.ts
index eb5318d98..cba065db7 100644
--- a/libs/portal/shared/data-access/src/index.ts
+++ b/libs/portal/shared/data-access/src/index.ts
@@ -4,6 +4,8 @@ export * from './lib/multi-source';
export * from './lib/live-channel-playback-queue';
export * from './lib/source-health.service';
+export * from './lib/source-health-history.service';
+export * from './lib/source-status.service';
export * from './lib/source-cleanup.service';
diff --git a/libs/portal/shared/data-access/src/lib/source-health-history.service.spec.ts b/libs/portal/shared/data-access/src/lib/source-health-history.service.spec.ts
new file mode 100644
index 000000000..eb8eb280a
--- /dev/null
+++ b/libs/portal/shared/data-access/src/lib/source-health-history.service.spec.ts
@@ -0,0 +1,89 @@
+import { TestBed } from '@angular/core/testing';
+import { SourceHealthEvidenceService } from '@iptvnator/services';
+import {
+ SOURCE_HEALTH_HISTORY_STORAGE_KEY,
+ SourceHealthHistoryService,
+} from './source-health-history.service';
+
+const failed = (checkedAt: number) => ({
+ state: 'unavailable' as const,
+ reason: 'network' as const,
+ confirmedInactive: false,
+ checkedAt,
+});
+
+describe('SourceHealthHistoryService', () => {
+ const create = () => {
+ TestBed.configureTestingModule({
+ providers: [
+ SourceHealthHistoryService,
+ SourceHealthEvidenceService,
+ ],
+ });
+ return TestBed.inject(SourceHealthHistoryService);
+ };
+
+ beforeEach(() => localStorage.clear());
+ afterEach(() => TestBed.resetTestingModule());
+
+ it('keeps the start of a failing run across a restart', () => {
+ create().record(['a'], failed(100));
+ TestBed.resetTestingModule();
+
+ const restarted = create();
+ restarted.record(['a'], failed(900));
+ expect(restarted.entries().get('a')).toEqual({
+ state: 'bad',
+ checkedAt: 900,
+ failingSince: 100,
+ });
+ });
+
+ it('stores no URL or credential', () => {
+ create().record(['a'], failed(100));
+ expect(localStorage.getItem(SOURCE_HEALTH_HISTORY_STORAGE_KEY)).toBe(
+ '{"a":{"state":"bad","checkedAt":100,"failingSince":100}}'
+ );
+ });
+
+ it('ignores inconclusive checks', () => {
+ const history = create();
+ history.record(['a'], {
+ state: 'unavailable',
+ reason: 'paused',
+ confirmedInactive: false,
+ checkedAt: 100,
+ });
+ expect(history.entries().size).toBe(0);
+ expect(localStorage.getItem(SOURCE_HEALTH_HISTORY_STORAGE_KEY)).toBe(
+ null
+ );
+ });
+
+ it('forgets a deleted source and everything on a reset', () => {
+ const history = create();
+ const evidence = TestBed.inject(SourceHealthEvidenceService);
+ history.record(['a', 'b'], failed(100));
+
+ evidence.connections.next({ id: 'a' });
+ expect([...history.entries().keys()]).toEqual(['b']);
+
+ // An edit keeps the entry; the next check replaces it.
+ evidence.connections.next({ id: 'b', playlist: { title: 'b' } });
+ expect([...history.entries().keys()]).toEqual(['b']);
+
+ evidence.connections.next({});
+ expect(history.entries().size).toBe(0);
+ });
+
+ it('starts empty from unreadable storage', () => {
+ localStorage.setItem(SOURCE_HEALTH_HISTORY_STORAGE_KEY, '{nope');
+ expect(create().entries().size).toBe(0);
+ localStorage.setItem(
+ SOURCE_HEALTH_HISTORY_STORAGE_KEY,
+ '{"a":{"state":"weird","checkedAt":1}}'
+ );
+ TestBed.resetTestingModule();
+ expect(create().entries().size).toBe(0);
+ });
+});
diff --git a/libs/portal/shared/data-access/src/lib/source-health-history.service.ts b/libs/portal/shared/data-access/src/lib/source-health-history.service.ts
new file mode 100644
index 000000000..510b0091c
--- /dev/null
+++ b/libs/portal/shared/data-access/src/lib/source-health-history.service.ts
@@ -0,0 +1,107 @@
+import { Injectable, inject, signal } from '@angular/core';
+import { SourceHealthEvidenceService } from '@iptvnator/services';
+import {
+ nextSourceHealthHistory,
+ SourceHealthHistoryEntry,
+ SourceHealthSnapshot,
+ sourceReachabilityFromHealth,
+} from '@iptvnator/shared/interfaces';
+
+export const SOURCE_HEALTH_HISTORY_STORAGE_KEY = 'source-health:history:v1';
+
+function readHistory(): Map {
+ try {
+ const raw = localStorage.getItem(SOURCE_HEALTH_HISTORY_STORAGE_KEY);
+ const parsed: unknown = raw ? JSON.parse(raw) : null;
+ if (!parsed || typeof parsed !== 'object') {
+ return new Map();
+ }
+ return new Map(
+ Object.entries(
+ parsed as Record
+ ).filter(
+ ([, entry]) =>
+ typeof entry?.checkedAt === 'number' &&
+ ['ok', 'bad', 'expired'].includes(entry.state)
+ )
+ );
+ } catch {
+ return new Map();
+ }
+}
+
+function toRecord(
+ entries: ReadonlyMap
+): Record {
+ const record: Record = {};
+ entries.forEach((entry, id) => (record[id] = entry));
+ return record;
+}
+
+/**
+ * The last conclusive health result of each source, by playlist ID, kept
+ * across restarts. Live checks stay in memory (`SourceHealthService`); this
+ * keeps only what a list needs before they run again: the state, when it
+ * was checked and since when a source has failed. No URL or credential is
+ * stored, so the entries need no redaction.
+ */
+@Injectable({ providedIn: 'root' })
+export class SourceHealthHistoryService {
+ private readonly state =
+ signal>(readHistory());
+ readonly entries = this.state.asReadonly();
+
+ constructor() {
+ inject(SourceHealthEvidenceService).connections.subscribe(
+ ({ id, playlist }) => {
+ if (!id) {
+ this.write(new Map());
+ } else if (!playlist) {
+ this.forget(id);
+ }
+ }
+ );
+ }
+
+ record(ids: readonly string[], snapshot: SourceHealthSnapshot): void {
+ const reachability = sourceReachabilityFromHealth(snapshot);
+ const next = new Map(this.state());
+ let changed = false;
+ for (const id of ids) {
+ const previous = next.get(id);
+ const entry = nextSourceHealthHistory(
+ previous,
+ reachability,
+ snapshot.checkedAt
+ );
+ if (entry && entry !== previous) {
+ next.set(id, entry);
+ changed = true;
+ }
+ }
+ if (changed) {
+ this.write(next);
+ }
+ }
+
+ forget(id: string): void {
+ if (!this.state().has(id)) {
+ return;
+ }
+ const next = new Map(this.state());
+ next.delete(id);
+ this.write(next);
+ }
+
+ private write(next: Map): void {
+ this.state.set(next);
+ try {
+ localStorage.setItem(
+ SOURCE_HEALTH_HISTORY_STORAGE_KEY,
+ JSON.stringify(toRecord(next))
+ );
+ } catch {
+ // The history is a convenience; the live checks still work.
+ }
+ }
+}
diff --git a/libs/portal/shared/data-access/src/lib/source-health.service.ts b/libs/portal/shared/data-access/src/lib/source-health.service.ts
index aaa599ac6..884073f5d 100644
--- a/libs/portal/shared/data-access/src/lib/source-health.service.ts
+++ b/libs/portal/shared/data-access/src/lib/source-health.service.ts
@@ -12,6 +12,7 @@ import {
sourceHealthUnknown,
sourceHealthError,
} from '@iptvnator/shared/interfaces';
+import { SourceHealthHistoryService } from './source-health-history.service';
import { SourceHealthProbesService } from './source-health-probes.service';
interface Job {
@@ -32,6 +33,7 @@ interface Job {
export class SourceHealthService {
private readonly probes = inject(SourceHealthProbesService);
private readonly runtime = inject(RuntimeCapabilitiesService);
+ private readonly history = inject(SourceHealthHistoryService);
readonly snapshots = signal>(
new Map()
);
@@ -301,5 +303,10 @@ export class SourceHealthService {
: previous?.lastSuccessAt,
};
this.snapshots.update((map) => new Map(map).set(key, snapshot));
+ // Every playlist with these connection details shares the result.
+ const ids = [...this.identities]
+ .filter(([, identity]) => identity === key)
+ .map(([id]) => id);
+ if (ids.length) this.history.record(ids, snapshot);
}
}
diff --git a/libs/portal/shared/data-access/src/lib/source-status.service.spec.ts b/libs/portal/shared/data-access/src/lib/source-status.service.spec.ts
new file mode 100644
index 000000000..b02855449
--- /dev/null
+++ b/libs/portal/shared/data-access/src/lib/source-status.service.spec.ts
@@ -0,0 +1,199 @@
+import { signal } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import {
+ PortalStatusService,
+ RuntimeCapabilitiesService,
+} from '@iptvnator/services';
+import {
+ PlaylistMeta,
+ SourceHealthHistoryEntry,
+ SourceHealthSnapshot,
+} from '@iptvnator/shared/interfaces';
+import { SourceHealthHistoryService } from './source-health-history.service';
+import { SourceHealthService } from './source-health.service';
+import { SourceStatusService } from './source-status.service';
+
+const base = {
+ count: 0,
+ importDate: '2026-01-01T00:00:00.000Z',
+ autoRefresh: false,
+};
+const m3u: PlaylistMeta = {
+ ...base,
+ _id: 'm',
+ title: 'm',
+ url: 'https://m.test/list',
+};
+const local: PlaylistMeta = { ...base, _id: 'l', title: 'l' };
+const xtream: PlaylistMeta = {
+ ...base,
+ _id: 'x',
+ title: 'x',
+ serverUrl: 'http://x.test',
+ username: 'u',
+ password: 'p',
+};
+
+describe('SourceStatusService', () => {
+ let snapshots: Map;
+ let history: ReturnType<
+ typeof signal