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()) { + + } + +
+
+ {{ 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()) { + + } + + + } @else { + @if (canCancelBusy()) { + + } + @if (failing() && canRecheck()) { + + } @else if (canRefresh()) { + + } + + + } +
+
+ + + + @if (canRefresh()) { + + } + @if (compact()) { + + @if (hasAccountInfo()) { + + } + } @else { + + @if (copyUrl()) { + + } + + } + @if (canRecheck()) { + + } + + + + 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: ` + + @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> + >; + let health: { get: jest.Mock; check: jest.Mock; recheck: jest.Mock }; + let portal: { + getCachedStatus: jest.Mock; + checkPortalStatus: jest.Mock; + }; + + const create = (supportsSourceHealth: boolean) => { + TestBed.configureTestingModule({ + providers: [ + SourceStatusService, + { + provide: RuntimeCapabilitiesService, + useValue: { supportsSourceHealth }, + }, + { provide: SourceHealthService, useValue: health }, + { + provide: SourceHealthHistoryService, + useValue: { entries: history }, + }, + { provide: PortalStatusService, useValue: portal }, + ], + }); + return TestBed.inject(SourceStatusService); + }; + + beforeEach(() => { + snapshots = new Map(); + history = signal(new Map()); + health = { + get: jest.fn((p: PlaylistMeta) => snapshots.get(p._id)), + check: jest.fn().mockResolvedValue(undefined), + recheck: jest.fn().mockResolvedValue(undefined), + }; + portal = { + getCachedStatus: jest.fn().mockReturnValue(null), + checkPortalStatus: jest.fn().mockResolvedValue('active'), + }; + }); + afterEach(() => TestBed.resetTestingModule()); + + describe('desktop', () => { + it('has no status for a local file', () => { + expect(create(true).statusOf(local)).toBeNull(); + }); + + it('reports a live result with the kept failing run', () => { + snapshots.set('m', { + state: 'unavailable', + reason: 'network', + confirmedInactive: false, + checkedAt: 900, + }); + history.set( + new Map([ + ['m', { state: 'bad', checkedAt: 900, failingSince: 100 }], + ]) + ); + expect(create(true).statusOf(m3u)).toEqual({ + reachability: 'bad', + failingSince: 100, + checkedAt: 900, + }); + }); + + it('shows the kept result before and while the source is checked', () => { + history.set( + new Map([ + ['m', { state: 'bad', checkedAt: 50, failingSince: 10 }], + ]) + ); + const service = create(true); + expect(service.statusOf(m3u)).toEqual({ + reachability: 'bad', + failingSince: 10, + checkedAt: 50, + pending: false, + }); + snapshots.set('m', { + state: 'checking', + reason: 'unknown', + confirmedInactive: false, + checkedAt: 0, + }); + expect(service.statusOf(m3u)?.pending).toBe(true); + }); + + it('reports an unchecked source as unknown', () => { + expect(create(true).statusOf(m3u)).toEqual({ + reachability: 'unknown', + }); + }); + + it('delegates checks to the health service', () => { + const service = create(true); + const controller = new AbortController(); + service.ensureChecked(m3u, controller.signal); + service.ensureChecked(local, controller.signal); + service.recheck(m3u); + expect(health.check).toHaveBeenCalledTimes(1); + expect(health.check).toHaveBeenCalledWith(m3u, { + signal: controller.signal, + }); + expect(health.recheck).toHaveBeenCalledWith(m3u); + }); + }); + + describe('browser build', () => { + it('tracks only Xtream accounts', () => { + const service = create(false); + expect(service.statusOf(m3u)).toBeNull(); + expect(service.statusOf(xtream)).toEqual({ + reachability: 'unknown', + }); + }); + + it('shows checking, then the portal status', async () => { + let resolve!: (status: string) => void; + portal.checkPortalStatus.mockReturnValue( + new Promise((r) => (resolve = r)) + ); + const service = create(false); + service.ensureChecked(xtream, new AbortController().signal); + expect(service.statusOf(xtream)?.reachability).toBe('checking'); + resolve('expired'); + await Promise.resolve(); + await Promise.resolve(); + expect(service.statusOf(xtream)?.reachability).toBe('expired'); + }); + + it('serves a cached portal status without a request', () => { + portal.getCachedStatus.mockReturnValue('inactive'); + const service = create(false); + service.ensureChecked(xtream, new AbortController().signal); + expect(portal.checkPortalStatus).not.toHaveBeenCalled(); + expect(service.statusOf(xtream)?.reachability).toBe('bad'); + }); + + it('drops a result that lands after the list closed', async () => { + const service = create(false); + const controller = new AbortController(); + service.ensureChecked(xtream, controller.signal); + controller.abort(); + await Promise.resolve(); + await Promise.resolve(); + expect(service.statusOf(xtream)?.reachability).toBe('unknown'); + }); + + it('bypasses the cache on recheck', () => { + create(false).recheck(xtream); + expect(portal.checkPortalStatus).toHaveBeenCalledWith( + 'http://x.test', + 'u', + 'p', + { skipCache: true } + ); + }); + }); +}); diff --git a/libs/portal/shared/data-access/src/lib/source-status.service.ts b/libs/portal/shared/data-access/src/lib/source-status.service.ts new file mode 100644 index 000000000..185fcdcde --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/source-status.service.ts @@ -0,0 +1,171 @@ +import { Injectable, Injector, inject, signal } from '@angular/core'; +import { + PortalStatus, + PortalStatusService, + RuntimeCapabilitiesService, +} from '@iptvnator/services'; +import { + isConclusiveReachability, + PlaylistMeta, + sourceHealthType, + sourceReachabilityFromHealth, + sourceReachabilityFromPortalStatus, + SourceStatus, +} from '@iptvnator/shared/interfaces'; +import { SourceHealthHistoryService } from './source-health-history.service'; +import { SourceHealthService } from './source-health.service'; + +type XtreamAccount = PlaylistMeta & { + serverUrl: string; + username: string; + password: string; +}; + +function isXtreamAccount(playlist: PlaylistMeta): playlist is XtreamAccount { + return !!(playlist.serverUrl && playlist.username && playlist.password); +} + +/** + * One status per network source for every list that shows one: the Sources + * page, its status filter and the playlist switcher. + * + * - Desktop: the live health check, else the last result kept across + * restarts, so a source that failed yesterday is listed as failing before + * it is checked again. + * - Browser build: the Xtream portal status only, as before; it has no + * probes for M3U URLs or Stalker portals. + * + * Local files have no status (`null`). + */ +@Injectable({ providedIn: 'root' }) +export class SourceStatusService { + private readonly runtime = inject(RuntimeCapabilitiesService); + private readonly injector = inject(Injector); + private readonly history = inject(SourceHealthHistoryService); + private readonly portalStatus = inject(PortalStatusService); + private readonly portalStatuses = signal>( + new Map() + ); + + /** Whether the status of every network source is known (desktop). */ + get supportsStatusFilter(): boolean { + return this.runtime.supportsSourceHealth; + } + + tracks(playlist: PlaylistMeta): boolean { + return this.runtime.supportsSourceHealth + ? !!sourceHealthType(playlist) + : isXtreamAccount(playlist); + } + + statusOf(playlist: PlaylistMeta): SourceStatus | null { + if (!this.tracks(playlist)) { + return null; + } + if (!this.runtime.supportsSourceHealth) { + const status = this.portalStatuses().get(playlist._id); + return { + reachability: status + ? sourceReachabilityFromPortalStatus(status) + : 'unknown', + }; + } + const live = this.health.get(playlist); + const reachability = sourceReachabilityFromHealth(live); + const kept = this.history.entries().get(playlist._id); + if (isConclusiveReachability(reachability)) { + return { + reachability, + failingSince: kept?.failingSince, + checkedAt: live?.checkedAt, + }; + } + if (kept) { + return { + reachability: kept.state, + failingSince: kept.failingSince, + checkedAt: kept.checkedAt, + pending: reachability === 'checking', + }; + } + return { reachability }; + } + + /** A passive check: cached results count; the signal cancels it. */ + ensureChecked(playlist: PlaylistMeta, signal: AbortSignal): void { + if (!this.tracks(playlist)) { + return; + } + if (this.runtime.supportsSourceHealth) { + void this.health.check(playlist, { signal }); + } else if (isXtreamAccount(playlist)) { + void this.checkPortal(playlist, false, signal); + } + } + + /** "Check again" / "Retry": a fresh check that bypasses every cache. */ + recheck(playlist: PlaylistMeta): void { + if (!this.tracks(playlist)) { + return; + } + if (this.runtime.supportsSourceHealth) { + void this.health.recheck(playlist); + } else if (isXtreamAccount(playlist)) { + void this.checkPortal(playlist, true); + } + } + + private get health(): SourceHealthService { + return this.injector.get(SourceHealthService); + } + + private async checkPortal( + playlist: XtreamAccount, + fresh: boolean, + signal?: AbortSignal + ): Promise { + const { _id, serverUrl, username, password } = playlist; + const cached = fresh + ? null + : this.portalStatus.getCachedStatus(serverUrl, username, password); + if (cached) { + this.setPortalStatus(_id, cached); + return; + } + if (fresh || !this.portalStatuses().has(_id)) { + this.setPortalStatus(_id, 'checking'); + } + let status: PortalStatus; + try { + status = await this.portalStatus.checkPortalStatus( + serverUrl, + username, + password, + fresh ? { skipCache: true } : undefined + ); + } catch { + status = 'unavailable'; + } + if (signal?.aborted) { + // A slow portal must not land after the list has closed; drop a + // placeholder so the next opening checks again. + if (this.portalStatuses().get(_id) === 'checking') { + this.setPortalStatus(_id, null); + } + return; + } + this.setPortalStatus(_id, status); + } + + private setPortalStatus(id: string, status: PortalStatus | null): void { + this.portalStatuses.update((current) => { + const next = new Map(current); + if (status) { + next.set(id, status); + } else { + next.delete(id); + } + return next; + }); + } +} diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 6c2aec4dd..0bf41c87b 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -112,4 +112,5 @@ export * from './lib/xtream-connection-test'; export * from './lib/portal-request-failure.util'; export * from './lib/source-health'; +export * from './lib/source-reachability'; export * from './lib/server-storage.contract'; diff --git a/libs/shared/interfaces/src/lib/playlist-display-label.util.spec.ts b/libs/shared/interfaces/src/lib/playlist-display-label.util.spec.ts index 2833c2cae..bb96e2da4 100644 --- a/libs/shared/interfaces/src/lib/playlist-display-label.util.spec.ts +++ b/libs/shared/interfaces/src/lib/playlist-display-label.util.spec.ts @@ -16,7 +16,9 @@ describe('playlistDisplayLabel', () => { // The default :80 is dropped by URL normalization, which reads better // anyway; a non-default port is kept (see the case above). expect( - playlistDisplayLabel('http://marveltv.info:80 usr== Tl24uy0xGi pas=== pNt9PQMmL2') + playlistDisplayLabel( + 'http://marveltv.info:80 usr== Tl24uy0xGi pas=== pNt9PQMmL2' + ) ).toBe('marveltv.info'); }); @@ -67,7 +69,9 @@ describe('playlistDisplayLabel', () => { describe('playlistNameHasCredentials', () => { it('detects query-string credentials', () => { expect( - playlistNameHasCredentials('http://x.tv/get.php?username=a&password=b') + playlistNameHasCredentials( + 'http://x.tv/get.php?username=a&password=b' + ) ).toBe(true); }); @@ -81,4 +85,13 @@ describe('playlistNameHasCredentials', () => { expect(playlistNameHasCredentials('cord-cutter')).toBe(false); expect(playlistNameHasCredentials('http://plain.tv:8080')).toBe(false); }); + + it('answers the same for the same name on every call', () => { + const name = 'Portal usr== abc pas== def'; + expect([1, 2, 3].map(() => playlistNameHasCredentials(name))).toEqual([ + true, + true, + true, + ]); + }); }); diff --git a/libs/shared/interfaces/src/lib/playlist-display-label.util.ts b/libs/shared/interfaces/src/lib/playlist-display-label.util.ts index 6bab72a31..8ded1fca6 100644 --- a/libs/shared/interfaces/src/lib/playlist-display-label.util.ts +++ b/libs/shared/interfaces/src/lib/playlist-display-label.util.ts @@ -12,7 +12,8 @@ */ /** Query keys whose presence means the string is a credential-bearing URL. */ -const CREDENTIAL_KEYS = /(?:^|[?&;])(?:username|password|user|pass|token|auth)=/i; +const CREDENTIAL_KEYS = + /(?:^|[?&;])(?:username|password|user|pass|token|auth)=/i; /** Hand-typed credential fragments, e.g. "usr== Tl24uy0xGi pas== abc". */ const INLINE_CREDENTIALS = @@ -56,6 +57,9 @@ export function playlistNameHasCredentials( rawName: string | null | undefined ): boolean { const name = rawName ?? ''; + // INLINE_CREDENTIALS is global for `replace`; `test` on a global pattern + // resumes at its lastIndex, so a second call would skip the match. + INLINE_CREDENTIALS.lastIndex = 0; return CREDENTIAL_KEYS.test(name) || INLINE_CREDENTIALS.test(name); } diff --git a/libs/shared/interfaces/src/lib/source-reachability.spec.ts b/libs/shared/interfaces/src/lib/source-reachability.spec.ts new file mode 100644 index 000000000..33ac2eacd --- /dev/null +++ b/libs/shared/interfaces/src/lib/source-reachability.spec.ts @@ -0,0 +1,101 @@ +import { + nextSourceHealthHistory, + sourceReachabilityFromHealth, + sourceReachabilityFromPortalStatus, + sourceStatusBucket, +} from './source-reachability'; + +describe('source reachability', () => { + it.each([ + [{ state: 'active', reason: 'available' }, 'ok'], + [{ state: 'expired', reason: 'expired' }, 'expired'], + [{ state: 'inactive', reason: 'disabled' }, 'bad'], + [{ state: 'inactive', reason: 'auth' }, 'bad'], + [{ state: 'checking', reason: 'unknown' }, 'checking'], + [{ state: 'unavailable', reason: 'network' }, 'bad'], + [{ state: 'unavailable', reason: 'timeout' }, 'bad'], + [{ state: 'unavailable', reason: 'http' }, 'bad'], + [{ state: 'unavailable', reason: 'invalid' }, 'bad'], + // Checks that never reached the source say nothing about it. + [{ state: 'unavailable', reason: 'paused' }, 'unknown'], + [{ state: 'unavailable', reason: 'cancelled' }, 'unknown'], + [{ state: 'unavailable', reason: 'unknown' }, 'unknown'], + [{ state: 'unknown', reason: 'unknown' }, 'unknown'], + ] as const)('maps %o to %s', (snapshot, expected) => { + expect(sourceReachabilityFromHealth(snapshot)).toBe(expected); + }); + + it('treats a source without a snapshot as never checked', () => { + expect(sourceReachabilityFromHealth(undefined)).toBe('unknown'); + }); + + it.each([ + ['active', 'ok'], + ['expired', 'expired'], + ['inactive', 'bad'], + ['unavailable', 'bad'], + ['checking', 'checking'], + ] as const)('maps the portal status %s to %s', (status, expected) => { + expect(sourceReachabilityFromPortalStatus(status)).toBe(expected); + }); + + it('files expired accounts with the sources that do not respond', () => { + expect(sourceStatusBucket('ok')).toBe('ok'); + expect(sourceStatusBucket('bad')).toBe('bad'); + expect(sourceStatusBucket('expired')).toBe('bad'); + expect(sourceStatusBucket('checking')).toBeNull(); + expect(sourceStatusBucket('unknown')).toBeNull(); + }); +}); + +describe('nextSourceHealthHistory', () => { + it('starts a failing run at the first failed check', () => { + expect(nextSourceHealthHistory(undefined, 'bad', 100)).toEqual({ + state: 'bad', + checkedAt: 100, + failingSince: 100, + }); + }); + + it('keeps the start of the run across later failures', () => { + const first = nextSourceHealthHistory( + { state: 'ok', checkedAt: 50, lastSuccessAt: 50 }, + 'bad', + 100 + ); + const second = nextSourceHealthHistory(first, 'expired', 400); + expect(second).toEqual({ + state: 'expired', + checkedAt: 400, + failingSince: 100, + lastSuccessAt: 50, + }); + }); + + it('ends the run when the source answers again', () => { + expect( + nextSourceHealthHistory( + { state: 'bad', checkedAt: 100, failingSince: 10 }, + 'ok', + 500 + ) + ).toEqual({ state: 'ok', checkedAt: 500, lastSuccessAt: 500 }); + }); + + it('keeps the previous entry for an inconclusive check', () => { + const previous = { + state: 'bad', + checkedAt: 100, + failingSince: 10, + } as const; + expect(nextSourceHealthHistory(previous, 'unknown', 900)).toBe( + previous + ); + expect(nextSourceHealthHistory(previous, 'checking', 900)).toBe( + previous + ); + expect( + nextSourceHealthHistory(undefined, 'unknown', 900) + ).toBeUndefined(); + }); +}); diff --git a/libs/shared/interfaces/src/lib/source-reachability.ts b/libs/shared/interfaces/src/lib/source-reachability.ts new file mode 100644 index 000000000..5e9410ddd --- /dev/null +++ b/libs/shared/interfaces/src/lib/source-reachability.ts @@ -0,0 +1,132 @@ +import type { SourceHealthSnapshot } from './source-health'; + +/** + * What a source list shows about one network source, folded from the six + * health states: `ok` answers, `bad` does not, `expired` answers with an + * expired account, `checking` is mid-check with nothing known yet, and + * `unknown` was never checked or the check was inconclusive. + */ +export type SourceReachability = + 'ok' | 'bad' | 'expired' | 'checking' | 'unknown'; + +/** A reachability a check actually established. */ +export type ConclusiveSourceReachability = Extract< + SourceReachability, + 'ok' | 'bad' | 'expired' +>; + +/** The two choices of a "responds / does not respond" status filter. */ +export type SourceStatusBucket = 'ok' | 'bad'; + +/** A network source's status as a list shows it. */ +export interface SourceStatus { + reachability: SourceReachability; + /** When the current run of failed checks began. */ + failingSince?: number; + checkedAt?: number; + /** A check is running; `reachability` is the last known result. */ + pending?: boolean; +} + +/** + * Health reasons that mean the source itself did not answer properly. A + * paused, cancelled or unknown check says nothing about the source. + */ +const UNREACHABLE_REASONS = new Set(['network', 'timeout', 'http', 'invalid']); + +export function sourceReachabilityFromHealth( + snapshot: Pick | undefined +): SourceReachability { + switch (snapshot?.state) { + case 'active': + return 'ok'; + case 'expired': + return 'expired'; + case 'inactive': + return 'bad'; + case 'checking': + return 'checking'; + case 'unavailable': + return UNREACHABLE_REASONS.has(snapshot.reason) ? 'bad' : 'unknown'; + default: + return 'unknown'; + } +} + +/** The browser build's Xtream portal status, by the same rules. */ +export function sourceReachabilityFromPortalStatus( + status: 'active' | 'inactive' | 'expired' | 'unavailable' | 'checking' +): SourceReachability { + switch (status) { + case 'active': + return 'ok'; + case 'expired': + return 'expired'; + case 'checking': + return 'checking'; + default: + return 'bad'; + } +} + +export function isConclusiveReachability( + reachability: SourceReachability +): reachability is ConclusiveSourceReachability { + return ( + reachability === 'ok' || + reachability === 'bad' || + reachability === 'expired' + ); +} + +/** + * Expired accounts count as not responding: the source answers, but the + * account cannot play anything, which is what the filter is for. + */ +export function sourceStatusBucket( + reachability: SourceReachability +): SourceStatusBucket | null { + if (reachability === 'ok') { + return 'ok'; + } + return reachability === 'bad' || reachability === 'expired' ? 'bad' : null; +} + +/** The last conclusive result kept for a source across restarts. */ +export interface SourceHealthHistoryEntry { + state: ConclusiveSourceReachability; + checkedAt: number; + /** When the current run of failed checks began; absent while `ok`. */ + failingSince?: number; + lastSuccessAt?: number; +} + +/** + * Folds one check into the kept history. An inconclusive check keeps the + * previous entry; a failure keeps the start of the current failing run, so + * a source that has not answered for three days still says three days after + * every later failed check. + */ +export function nextSourceHealthHistory( + previous: SourceHealthHistoryEntry | undefined, + reachability: SourceReachability, + checkedAt: number +): SourceHealthHistoryEntry | undefined { + if (!isConclusiveReachability(reachability)) { + return previous; + } + if (reachability === 'ok') { + return { state: 'ok', checkedAt, lastSuccessAt: checkedAt }; + } + const wasFailing = previous !== undefined && previous.state !== 'ok'; + return { + state: reachability, + checkedAt, + failingSince: wasFailing + ? (previous.failingSince ?? previous.checkedAt) + : checkedAt, + ...(previous?.lastSuccessAt !== undefined + ? { lastSuccessAt: previous.lastSuccessAt } + : {}), + }; +} diff --git a/libs/shared/interfaces/src/lib/source-type-icons.util.spec.ts b/libs/shared/interfaces/src/lib/source-type-icons.util.spec.ts index e5c056835..d96ded873 100644 --- a/libs/shared/interfaces/src/lib/source-type-icons.util.spec.ts +++ b/libs/shared/interfaces/src/lib/source-type-icons.util.spec.ts @@ -1,6 +1,7 @@ import { getPlaylistSourceIcon, resolvePlaylistSourceIconKey, + resolvePlaylistSourceKind, SOURCE_TYPE_ICONS, } from './source-type-icons.util'; @@ -40,4 +41,14 @@ describe('source type icons', () => { SOURCE_TYPE_ICONS[expected] ); }); + + it.each([ + [{ macAddress: '00:1A:79:00:00:01', serverUrl: 'http://x' }, 'stalker'], + // An Xtream row missing a credential is still Xtream, not M3U: the + // old list filter dropped it from every type. + [{ serverUrl: 'http://xtream' }, 'xtream'], + [{}, 'm3u'], + ] as const)('puts %o in the %s family', (playlist, expected) => { + expect(resolvePlaylistSourceKind(playlist)).toBe(expected); + }); }); diff --git a/libs/shared/interfaces/src/lib/source-type-icons.util.ts b/libs/shared/interfaces/src/lib/source-type-icons.util.ts index 90084b579..8ee6bb0b8 100644 --- a/libs/shared/interfaces/src/lib/source-type-icons.util.ts +++ b/libs/shared/interfaces/src/lib/source-type-icons.util.ts @@ -48,3 +48,20 @@ export function getPlaylistSourceIcon( ): string { return SOURCE_TYPE_ICONS[resolvePlaylistSourceIconKey(playlist)]; } + +/** The provider family a stored playlist belongs to. */ +export type PlaylistSourceKind = 'm3u' | 'xtream' | 'stalker'; + +/** + * The provider family by the same precedence as the icon, so every type + * filter, count and label agrees with the icon on the row: a playlist + * counted as Xtream is also listed and drawn as Xtream. + */ +export function resolvePlaylistSourceKind( + playlist: Pick +): PlaylistSourceKind { + if (playlist.macAddress) { + return 'stalker'; + } + return playlist.serverUrl ? 'xtream' : 'm3u'; +} diff --git a/libs/shared/logging/src/index.ts b/libs/shared/logging/src/index.ts index 7a64299d0..4a5510b15 100644 --- a/libs/shared/logging/src/index.ts +++ b/libs/shared/logging/src/index.ts @@ -1,4 +1,5 @@ export { + isSensitiveKey, REDACTED_VALUE, redactSensitiveData, } from './lib/redact-sensitive-data'; @@ -24,3 +25,4 @@ export type { RendererPerformancePhaseMetadata, RendererPerformancePhaseOutcome, } from './lib/renderer-performance-phase'; +export { stripUrlCredentials } from './lib/strip-url-credentials'; diff --git a/libs/shared/logging/src/lib/redact-sensitive-data.ts b/libs/shared/logging/src/lib/redact-sensitive-data.ts index b20256120..ebec2bee5 100644 --- a/libs/shared/logging/src/lib/redact-sensitive-data.ts +++ b/libs/shared/logging/src/lib/redact-sensitive-data.ts @@ -54,7 +54,7 @@ const SENSITIVE_KEY_SUFFIXES = [ 'username', ]; -const XTREAM_CREDENTIAL_PATH_SEGMENTS = new Set([ +export const XTREAM_CREDENTIAL_PATH_SEGMENTS: ReadonlySet = new Set([ 'live', 'movie', 'series', @@ -79,7 +79,7 @@ function normalizeKey(key: string): string { return key.toLowerCase().replace(/[^a-z0-9]/g, ''); } -function isSensitiveKey(key: string): boolean { +export function isSensitiveKey(key: string): boolean { const normalized = normalizeKey(key); return ( SENSITIVE_KEY_NAMES.has(normalized) || @@ -196,7 +196,7 @@ function redactEmbeddedSensitivePairs(value: string): string { ); } -function looksLikeSearchParams(value: string): boolean { +export function looksLikeSearchParams(value: string): boolean { return /^[^=&\s]+=[^&]*(?:&[^=&\s]+=[^&]*)*$/u.test(value); } diff --git a/libs/shared/logging/src/lib/strip-url-credentials.spec.ts b/libs/shared/logging/src/lib/strip-url-credentials.spec.ts new file mode 100644 index 000000000..f768542ac --- /dev/null +++ b/libs/shared/logging/src/lib/strip-url-credentials.spec.ts @@ -0,0 +1,48 @@ +import { stripUrlCredentials } from './strip-url-credentials'; + +describe('stripUrlCredentials', () => { + it.each([ + [ + 'http://host.tv:8080/get.php?username=joe&password=hunter2&type=m3u_plus', + 'http://host.tv:8080/get.php?type=m3u_plus', + ], + ['http://joe:hunter2@host.tv/list.m3u', 'http://host.tv/list.m3u'], + [ + 'http://host.tv/live/joe/hunter2/123.ts', + 'http://host.tv/live/123.ts', + ], + [ + 'https://cdn.tv/playlist.m3u8?token=abc123&quality=hd', + 'https://cdn.tv/playlist.m3u8?quality=hd', + ], + ['http://portal.tv/c/?mac=00:1A:79:00:00:01', 'http://portal.tv/c/'], + [ + 'http://host.tv/list.m3u#user=joe&pass=hunter2', + 'http://host.tv/list.m3u', + ], + ['http://[::1]:3211/get.php?password=p', 'http://[::1]:3211/get.php'], + [ + 'https://iptv-org.github.io/iptv/index.m3u', + 'https://iptv-org.github.io/iptv/index.m3u', + ], + ])('shows %s as %s', (input, expected) => { + expect(stripUrlCredentials(input)).toBe(expected); + }); + + it('never returns a password from a credential-bearing URL', () => { + const urls = [ + 'http://joe:hunter2@host.tv/get.php?username=joe&password=hunter2', + 'http://host.tv/movie/joe/hunter2/9.mkv?auth=hunter2', + 'http://host.tv/x#password=hunter2', + ]; + for (const url of urls) { + expect(stripUrlCredentials(url)).not.toMatch(/hunter2/); + } + }); + + it('returns null for text that is not an absolute URL', () => { + expect(stripUrlCredentials('get.php?username=joe')).toBeNull(); + expect(stripUrlCredentials('')).toBeNull(); + expect(stripUrlCredentials('not a url')).toBeNull(); + }); +}); diff --git a/libs/shared/logging/src/lib/strip-url-credentials.ts b/libs/shared/logging/src/lib/strip-url-credentials.ts new file mode 100644 index 000000000..f2f51db81 --- /dev/null +++ b/libs/shared/logging/src/lib/strip-url-credentials.ts @@ -0,0 +1,72 @@ +import { + isSensitiveKey, + looksLikeSearchParams, + XTREAM_CREDENTIAL_PATH_SEGMENTS, +} from './redact-sensitive-data'; + +/** + * Short account keys some playlist URLs use. A log line keeps them for + * diagnosis; a list on screen has no reason to. + */ +const SHORT_ACCOUNT_KEYS = new Set(['user', 'usr', 'pass', 'pas']); + +function isDisplaySensitiveKey(key: string): boolean { + return SHORT_ACCOUNT_KEYS.has(key.toLowerCase()) || isSensitiveKey(key); +} + +function withoutSensitiveParams(params: URLSearchParams): string { + const kept = new URLSearchParams(); + params.forEach((value, key) => { + if (!isDisplaySensitiveKey(key)) { + kept.append(key, value); + } + }); + return kept.toString(); +} + +/** + * A URL safe to show on screen. Where the log redactor replaces secrets with + * a marker, this drops them: the userinfo, every query or fragment parameter + * the redactor treats as sensitive (`username`, `password`, `token`, `mac`, + * …), and the two account segments after `/live/`, `/movie/`, `/series/` or + * `/timeshift/` in an Xtream stream path. A list can then say where a source + * points without any part of its credentials. + * + * Returns null for text that is not an absolute URL, so a caller never + * renders the unparsed original by mistake. + */ +export function stripUrlCredentials(value: string): string | null { + let url: URL; + try { + url = new URL(value.trim()); + } catch { + return null; + } + + url.username = ''; + url.password = ''; + + const segments = url.pathname.split('/'); + const kept: string[] = []; + for (let index = 0; index < segments.length; index += 1) { + kept.push(segments[index]); + if ( + XTREAM_CREDENTIAL_PATH_SEGMENTS.has(segments[index]) && + index + 2 < segments.length + ) { + index += 2; + } + } + url.pathname = kept.join('/'); + + const search = withoutSensitiveParams(url.searchParams); + url.search = search ? `?${search}` : ''; + + const fragment = url.hash.slice(1); + if (looksLikeSearchParams(fragment)) { + const hash = withoutSensitiveParams(new URLSearchParams(fragment)); + url.hash = hash ? `#${hash}` : ''; + } + + return url.toString(); +} diff --git a/libs/ui/styles/_app-controls.scss b/libs/ui/styles/_app-controls.scss new file mode 100644 index 000000000..c605763b8 --- /dev/null +++ b/libs/ui/styles/_app-controls.scss @@ -0,0 +1,279 @@ +// Buttons, menus and the segmented control of the IPTVnator UI kit, over +// Angular Material: Material keeps the behaviour (focus, keyboard, overlay, +// ARIA), these mixins set the look through `mat.*-overrides()`. +// +// Button variants are roles, not looks: +// - `secondary-button` (`mat-flat-button`): every ordinary action and every +// dropdown trigger (label + 12px chevron, opening a `mat-menu`); +// - `ghost-button` (`mat-button`) and `ghost-icon-button` +// (`mat-icon-button`): row and header actions. +// No outline, no ripple: hover changes the fill only, and a disabled button +// is the same button at 40% opacity. Focus keeps the app's ring +// (`styles.scss` rings every `.mat-mdc-button-base`). +@use 'sass:map'; +@use '@angular/material' as mat; + +$radius: 10px; +$radius-sm: 8px; + +$_sizes: ( + md: ( + height: 40px, + radius: $radius, + padding: 16px, + icon-padding: 14px, + text: 13.5px, + icon: 16px, + gap: 8px, + ), + sm: ( + height: 32px, + radius: $radius-sm, + padding: 12px, + icon-padding: 10px, + text: 12.5px, + icon: 14px, + gap: 6px, + ), +); + +@function _size($size, $key) { + @return map.get($_sizes, $size, $key); +} + +@mixin _icon-size($size) { + .mat-icon { + width: $size; + height: $size; + font-size: $size; + line-height: $size; + } +} + +@mixin _disabled { + &[disabled], + &.mat-mdc-button-disabled { + opacity: 0.4; + } +} + +/// `$size`: `md` (40px) or `sm` (32px). +@mixin secondary-button($size: md) { + $fill: color-mix(in srgb, var(--app-on-surface) 10%, transparent); + @include mat.button-overrides( + ( + filled-container-color: $fill, + filled-disabled-container-color: $fill, + filled-label-text-color: var(--app-heading-color), + filled-disabled-label-text-color: var(--app-heading-color), + filled-container-height: _size($size, height), + filled-container-shape: _size($size, radius), + filled-horizontal-padding: _size($size, padding), + filled-label-text-size: _size($size, text), + filled-label-text-weight: 600, + filled-label-text-tracking: 0, + filled-icon-spacing: _size($size, gap), + filled-state-layer-color: var(--app-on-surface), + filled-hover-state-layer-opacity: 0.06, + filled-focus-state-layer-opacity: 0.06, + filled-pressed-state-layer-opacity: 0.1, + filled-ripple-color: transparent, + ) + ); + @include _icon-size(_size($size, icon)); + @include _disabled; +} + +/// A text action: `--app-body-color`, the heading colour on hover. +@mixin ghost-button($size: sm) { + @include mat.button-overrides( + ( + text-label-text-color: var(--app-body-color), + text-disabled-label-text-color: var(--app-body-color), + text-container-height: _size($size, height), + text-container-shape: _size($size, radius), + text-horizontal-padding: _size($size, padding), + text-with-icon-horizontal-padding: _size($size, icon-padding), + text-label-text-size: _size($size, text), + text-label-text-weight: 600, + text-label-text-tracking: 0, + text-icon-spacing: 6px, + text-state-layer-color: var(--app-on-surface), + text-hover-state-layer-opacity: 0.08, + text-focus-state-layer-opacity: 0.08, + text-pressed-state-layer-opacity: 0.12, + text-ripple-color: transparent, + ) + ); + @include _icon-size(14px); + @include _disabled; + + &:not([disabled]):hover { + @include mat.button-overrides( + ( + text-label-text-color: var(--app-heading-color), + ) + ); + } +} + +/// A square icon action with an 8px radius, not Material's circle. +@mixin ghost-icon-button($size: 32px, $icon-size: 16px) { + @include mat.icon-button-overrides( + ( + state-layer-size: $size, + container-shape: $radius-sm, + icon-size: $icon-size, + icon-color: var(--app-body-color), + disabled-icon-color: var(--app-body-color), + state-layer-color: var(--app-on-surface), + hover-state-layer-opacity: 0.08, + focus-state-layer-opacity: 0.08, + pressed-state-layer-opacity: 0.12, + ripple-color: transparent, + touch-target-display: none, + ) + ); + border-radius: $radius-sm; + @include _icon-size($icon-size); + @include _disabled; + + &:not([disabled]):hover { + @include mat.icon-button-overrides( + ( + icon-color: var(--app-heading-color), + ) + ); + } +} + +/// The trailing 12px chevron of a dropdown trigger. +@mixin trigger-chevron { + width: 12px; + height: 12px; + font-size: 12px; + line-height: 12px; + margin-inline-start: 2px; + color: var(--app-body-color); +} + +/// A `mat-menu` panel; apply to the class given to ``. +/// Items: 36px, 13px, 16px icons in the body colour; destructive items +/// carry `app-menu-item--danger` and sit after a divider; a keyboard hint +/// is a trailing `kbd.app-menu-item__kbd`. +@mixin menu-panel { + @include mat.menu-overrides( + ( + container-color: var(--app-widget-bg), + container-shape: $radius, + item-label-text-color: var(--app-heading-color), + item-label-text-size: 13px, + item-label-text-weight: 400, + item-icon-color: var(--app-body-color), + item-icon-size: 16px, + item-hover-state-layer-color: color-mix( + in srgb, + var(--app-on-surface) 8%, + transparent + ), + item-focus-state-layer-color: color-mix( + in srgb, + var(--app-on-surface) 10%, + transparent + ), + item-leading-spacing: 10px, + item-trailing-spacing: 10px, + item-with-icon-leading-spacing: 10px, + item-with-icon-trailing-spacing: 10px, + item-spacing: 10px, + divider-color: var(--app-separator), + divider-top-spacing: 4px, + divider-bottom-spacing: 4px, + ) + ); + min-width: 240px; + border: 1px solid var(--app-line-strong); + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35); + + .mat-mdc-menu-content { + padding: 6px; + } + + .mat-mdc-menu-item { + min-height: 36px; + border-radius: 6px; + } + + .mat-mdc-menu-item[aria-checked='true'] { + background: var(--app-list-selected-bg); + color: var(--app-list-selected-fg); + + .mat-icon { + color: inherit; + } + } + + .app-menu-item--danger, + .app-menu-item--danger .mat-icon { + color: var(--app-danger-text); + } + + .app-menu-item__kbd { + margin-inline-start: auto; + padding-inline-start: 16px; + font-family: var(--app-font-mono); + font-size: 11px; + font-weight: 500; + color: var(--app-muted-color); + } +} + +/// `mat-button-toggle-group` as a segmented control: a recessed track, no +/// dividers, the chosen segment filled. +@mixin segmented-control($height: 30px) { + @include mat.button-toggle-overrides( + ( + height: $height, + shape: 10px, + divider-color: transparent, + background-color: transparent, + text-color: var(--app-muted-color), + selected-state-background-color: color-mix( + in srgb, + var(--app-on-surface) 10%, + transparent + ), + selected-state-text-color: var(--app-heading-color), + state-layer-color: var(--app-on-surface), + hover-state-layer-opacity: 0.06, + focus-state-layer-opacity: 0.06, + label-text-size: 12.5px, + label-text-weight: 500, + label-text-tracking: 0, + ) + ); + display: inline-flex; + gap: 1px; + padding: 2px; + border: 1px solid var(--app-line-strong); + background: var(--app-content-bg); + + .mat-button-toggle { + border-radius: 8px; + border: 0; + } + + // The group already draws its frame; Material adds a left border per + // toggle as the divider. + .mat-button-toggle + .mat-button-toggle { + border-inline-start: 0; + } + + .mat-button-toggle-label-content { + padding: 0 10px; + } + + .mat-pseudo-checkbox { + display: none; + } +} diff --git a/libs/ui/styles/_index.scss b/libs/ui/styles/_index.scss index 756401421..6d8374666 100644 --- a/libs/ui/styles/_index.scss +++ b/libs/ui/styles/_index.scss @@ -12,4 +12,5 @@ @forward 'detail-view-actions'; @forward 'nav-list'; @forward 'focus-ring'; +@forward 'app-controls' as controls-*; @forward 'skeleton-shimmer' as skeleton-*;