refactor(dashboard): split recently-watched into VOD and live rails

Per UX audit: a single "Recently Watched" rail mixed channels and VOD
into a grid where the two card formats fought for visual attention.
Split it into two rails — "Continue watching" (movies/series) and
"Live now on your favorites" (live channels) — so each surface uses
the card format that fits its content type.

Also relabel the rail header link from "Manage all" to "See all {N}"
when the total count exceeds what's rendered, so the user knows how
many items the link expands into; falls back to "See all" otherwise.

i18n: three new keys (LIVE_RECENT, SEE_ALL, SEE_ALL_COUNT) translated
across all 17 locales by per-locale agents reusing each file's existing
glossary; placeholder integrity verified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 4.7 committed 2026-05-19 00:18:46 +02:00
1 parent a27a29fcf2
commit a048c18763
23 files changed
+187 -12

No files matched your search

+3
View File
@@ -823,11 +823,14 @@
"CATEGORY_SORT_ARIA": "ترتيب الفئات",
"DASHBOARD": {
"CONTINUE_WATCHING": "متابعة المشاهدة",
"LIVE_RECENT": "يُبث الآن على المفضلة لديك",
"UNTITLED_SOURCE": "مصدر بدون عنوان",
"GLOBAL_FAVORITES": "المفضلة العامة",
"REMOVE_FROM_HISTORY": "إزالة من السجل",
"RECENT_SOURCES": "المصادر المستخدمة مؤخرًا",
"MANAGE_ALL": "إدارة الكل",
"SEE_ALL": "عرض الكل",
"SEE_ALL_COUNT": "عرض الكل ({{count}})",
"RECENTLY_WATCHED": "ما تمت مشاهدته مؤخرًا",
"XTREAM_RECENTLY_ADDED": "أُضيفت مؤخرًا على Xtream",
"NO_HISTORY": "لم يتم العثور على عناصر لهذا الفلتر.",
+3
View File
@@ -823,11 +823,14 @@
"CATEGORY_SORT_ARIA": "ترتيب التصنيفات",
"DASHBOARD": {
"CONTINUE_WATCHING": "كمل المشاهدة",
"LIVE_RECENT": "مباشر دابا على المفضلة ديالك",
"UNTITLED_SOURCE": "مصدر بلا عنوان",
"GLOBAL_FAVORITES": "المفضلة العامة",
"REMOVE_FROM_HISTORY": "حيد من السجل",
"RECENT_SOURCES": "المصادر المستعملة مؤخراً",
"MANAGE_ALL": "تدبير الكل",
"SEE_ALL": "شوف الكل",
"SEE_ALL_COUNT": "شوف الكل ({{count}})",
"RECENTLY_WATCHED": "متشافين مؤخراً",
"XTREAM_RECENTLY_ADDED": "تزادو مؤخراً على Xtream",
"NO_HISTORY": "ما تلقاو حتى عناصر لهاد الفلتر.",
+3
View File
@@ -823,11 +823,14 @@
"CATEGORY_SORT_ARIA": "Сартаваць катэгорыі",
"DASHBOARD": {
"CONTINUE_WATCHING": "Працягнуць прагляд",
"LIVE_RECENT": "Цяпер у эфіры на выбраных",
"UNTITLED_SOURCE": "Крыніца без назвы",
"GLOBAL_FAVORITES": "Усе фаварыты",
"REMOVE_FROM_HISTORY": "Выдаліць з гісторыі",
"RECENT_SOURCES": "Нядаўна выкарыстаныя крыніцы",
"MANAGE_ALL": "Кіраваць усімі",
"SEE_ALL": "Паказаць усё",
"SEE_ALL_COUNT": "Паказаць усё ({{count}})",
"RECENTLY_WATCHED": "Нядаўна прагледжаныя",
"XTREAM_RECENTLY_ADDED": "Нядаўна дададзеныя на Xtream",
"NO_HISTORY": "Не знойдзена элементаў для гэтага фільтра.",
+3
View File
@@ -823,11 +823,14 @@
"CATEGORY_SORT_ARIA": "Kategorien sortieren",
"DASHBOARD": {
"CONTINUE_WATCHING": "Weiter schauen",
"LIVE_RECENT": "Jetzt live auf Ihren Favoriten",
"UNTITLED_SOURCE": "Unbenannte Quelle",
"GLOBAL_FAVORITES": "Globale Favoriten",
"REMOVE_FROM_HISTORY": "Aus Verlauf entfernen",
"RECENT_SOURCES": "Zuletzt verwendete Quellen",
"MANAGE_ALL": "Alle verwalten",
"SEE_ALL": "Alle anzeigen",
"SEE_ALL_COUNT": "Alle {{count}} anzeigen",
"RECENTLY_WATCHED": "Zuletzt angesehen",
"XTREAM_RECENTLY_ADDED": "Neu auf Xtream",
"NO_HISTORY": "Keine Elemente für diesen Filter gefunden.",
+3
View File
@@ -823,11 +823,14 @@
"CATEGORY_SORT_ARIA": "Ταξινόμηση κατηγοριών",
"DASHBOARD": {
"CONTINUE_WATCHING": "Συνέχιση παρακολούθησης",
"LIVE_RECENT": "Ζωντανά τώρα στα αγαπημένα σας",
"UNTITLED_SOURCE": "Πηγή χωρίς τίτλο",
"GLOBAL_FAVORITES": "Βασικά Αγαπημένα",
"REMOVE_FROM_HISTORY": "Αφαίρεση από το ιστορικό",
"RECENT_SOURCES": "Πρόσφατα χρησιμοποιημένες πηγές",
"MANAGE_ALL": "Διαχείριση όλων",
"SEE_ALL": "Δείτε όλα",
"SEE_ALL_COUNT": "Δείτε όλα {{count}}",
"RECENTLY_WATCHED": "Πρόσφατα προβληθέντα",
"XTREAM_RECENTLY_ADDED": "Πρόσφατα προστέθηκαν στο Xtream",
"NO_HISTORY": "Δεν βρέθηκαν στοιχεία για αυτό το φίλτρο.",
+3
View File
@@ -823,11 +823,14 @@
"CATEGORY_SORT_ARIA": "Sort categories",
"DASHBOARD": {
"CONTINUE_WATCHING": "Continue Watching",
"LIVE_RECENT": "Live now on your favorites",
"UNTITLED_SOURCE": "Untitled source",
"GLOBAL_FAVORITES": "Global Favorites",
"REMOVE_FROM_HISTORY": "Remove from history",
"RECENT_SOURCES": "Recently Used Sources",
"MANAGE_ALL": "Manage all",
"SEE_ALL": "See all",
"SEE_ALL_COUNT": "See all {{count}}",
"RECENTLY_WATCHED": "Recently Watched",
"XTREAM_RECENTLY_ADDED": "Recently Added on Xtream",
"NO_HISTORY": "No items found for this filter.",
+3
View File
@@ -823,11 +823,14 @@
"CATEGORY_SORT_ARIA": "Ordenar categorías",
"DASHBOARD": {
"CONTINUE_WATCHING": "Continuar viendo",
"LIVE_RECENT": "Ahora en vivo en tus favoritos",
"UNTITLED_SOURCE": "Fuente sin título",
"GLOBAL_FAVORITES": "Favoritos globales",
"REMOVE_FROM_HISTORY": "Quitar del historial",
"RECENT_SOURCES": "Fuentes usadas recientemente",
"MANAGE_ALL": "Gestionar todo",
"SEE_ALL": "Ver todo",
"SEE_ALL_COUNT": "Ver todos ({{count}})",
"RECENTLY_WATCHED": "Vistos recientemente",
"XTREAM_RECENTLY_ADDED": "Añadidos recientemente en Xtream",
"NO_HISTORY": "No se encontraron elementos para este filtro.",
+3
View File
@@ -823,11 +823,14 @@
"CATEGORY_SORT_ARIA": "Trier les catégories",
"DASHBOARD": {
"CONTINUE_WATCHING": "Reprendre la lecture",
"LIVE_RECENT": "En direct sur vos favoris",
"UNTITLED_SOURCE": "Source sans titre",
"GLOBAL_FAVORITES": "Favoris globaux",
"REMOVE_FROM_HISTORY": "Retirer de l'historique",
"RECENT_SOURCES": "Sources récemment utilisées",
"MANAGE_ALL": "Tout gérer",
"SEE_ALL": "Voir tout",
"SEE_ALL_COUNT": "Voir les {{count}}",
"RECENTLY_WATCHED": "Récemment regardés",
"XTREAM_RECENTLY_ADDED": "Récemment ajoutés sur Xtream",
"NO_HISTORY": "Aucun élément trouvé pour ce filtre.",
+3
View File
@@ -823,11 +823,14 @@
"CATEGORY_SORT_ARIA": "Ordina categorie",
"DASHBOARD": {
"CONTINUE_WATCHING": "Continua a guardare",
"LIVE_RECENT": "Live ora sui tuoi preferiti",
"UNTITLED_SOURCE": "Sorgente senza titolo",
"GLOBAL_FAVORITES": "Preferiti globali",
"REMOVE_FROM_HISTORY": "Rimuovi dalla cronologia",
"RECENT_SOURCES": "Sorgenti usate di recente",
"MANAGE_ALL": "Gestisci tutto",
"SEE_ALL": "Vedi tutto",
"SEE_ALL_COUNT": "Vedi tutti ({{count}})",
"RECENTLY_WATCHED": "Visti di recente",
"XTREAM_RECENTLY_ADDED": "Aggiunti di recente su Xtream",
"NO_HISTORY": "Nessun elemento corrisponde a questo filtro.",
+3
View File
@@ -823,11 +823,14 @@
"CATEGORY_SORT_ARIA": "カテゴリを並び替え",
"DASHBOARD": {
"CONTINUE_WATCHING": "視聴を再開",
"LIVE_RECENT": "お気に入りで現在放送中",
"UNTITLED_SOURCE": "無題のソース",
"GLOBAL_FAVORITES": "グローバルお気に入り",
"REMOVE_FROM_HISTORY": "履歴から削除",
"RECENT_SOURCES": "最近使用したソース",
"MANAGE_ALL": "すべて管理",
"SEE_ALL": "すべて表示",
"SEE_ALL_COUNT": "すべて表示 ({{count}})",
"RECENTLY_WATCHED": "最近視聴した項目",
"XTREAM_RECENTLY_ADDED": "Xtreamに最近追加された項目",
"NO_HISTORY": "このフィルターに該当する項目はありません。",
+3
View File
@@ -823,11 +823,14 @@
"CATEGORY_SORT_ARIA": "카테고리 정렬",
"DASHBOARD": {
"CONTINUE_WATCHING": "이어서 시청",
"LIVE_RECENT": "즐겨찾기에서 지금 라이브 중인 채널입니다",
"UNTITLED_SOURCE": "제목 없는 소스",
"GLOBAL_FAVORITES": "전체 즐겨찾기",
"REMOVE_FROM_HISTORY": "기록에서 제거",
"RECENT_SOURCES": "최근에 사용한 소스",
"MANAGE_ALL": "모두 관리",
"SEE_ALL": "모두 보기",
"SEE_ALL_COUNT": "{{count}}개 모두 보기",
"RECENTLY_WATCHED": "최근에 시청한 항목",
"XTREAM_RECENTLY_ADDED": "Xtream에 최근 추가됨",
"NO_HISTORY": "이 필터에 해당하는 항목이 없습니다.",
+3
View File
@@ -823,11 +823,14 @@
"CATEGORY_SORT_ARIA": "Categorieën sorteren",
"DASHBOARD": {
"CONTINUE_WATCHING": "Verder kijken",
"LIVE_RECENT": "Nu live op je favorieten",
"UNTITLED_SOURCE": "Naamloze bron",
"GLOBAL_FAVORITES": "Algemene favorieten",
"REMOVE_FROM_HISTORY": "Verwijderen uit geschiedenis",
"RECENT_SOURCES": "Recent gebruikte bronnen",
"MANAGE_ALL": "Alles beheren",
"SEE_ALL": "Alles bekijken",
"SEE_ALL_COUNT": "Alle {{count}} bekijken",
"RECENTLY_WATCHED": "Recent bekeken",
"XTREAM_RECENTLY_ADDED": "Recent toegevoegd op Xtream",
"NO_HISTORY": "Geen items gevonden voor dit filter.",
+3
View File
@@ -823,11 +823,14 @@
"CATEGORY_SORT_ARIA": "Sortuj kategorie",
"DASHBOARD": {
"CONTINUE_WATCHING": "Kontynuuj oglądanie",
"LIVE_RECENT": "Na żywo w Twoich ulubionych",
"UNTITLED_SOURCE": "Źródło bez tytułu",
"GLOBAL_FAVORITES": "Globalne ulubione",
"REMOVE_FROM_HISTORY": "Usuń z historii",
"RECENT_SOURCES": "Ostatnio używane źródła",
"MANAGE_ALL": "Zarządzaj wszystkimi",
"SEE_ALL": "Zobacz wszystkie",
"SEE_ALL_COUNT": "Zobacz wszystkie ({{count}})",
"RECENTLY_WATCHED": "Ostatnio oglądane",
"XTREAM_RECENTLY_ADDED": "Ostatnio dodane w Xtream",
"NO_HISTORY": "Brak elementów dla tego filtra.",
+3
View File
@@ -823,11 +823,14 @@
"CATEGORY_SORT_ARIA": "Ordenar categorias",
"DASHBOARD": {
"CONTINUE_WATCHING": "Continuar assistindo",
"LIVE_RECENT": "Ao vivo agora nos seus favoritos",
"UNTITLED_SOURCE": "Fonte sem título",
"GLOBAL_FAVORITES": "Favoritos globais",
"REMOVE_FROM_HISTORY": "Remover do histórico",
"RECENT_SOURCES": "Fontes usadas recentemente",
"MANAGE_ALL": "Gerenciar tudo",
"SEE_ALL": "Ver todos",
"SEE_ALL_COUNT": "Ver todos ({{count}})",
"RECENTLY_WATCHED": "Assistidos recentemente",
"XTREAM_RECENTLY_ADDED": "Adicionados recentemente no Xtream",
"NO_HISTORY": "Nenhum item encontrado para este filtro.",
+3
View File
@@ -823,11 +823,14 @@
"CATEGORY_SORT_ARIA": "Сортировать категории",
"DASHBOARD": {
"CONTINUE_WATCHING": "Продолжить просмотр",
"LIVE_RECENT": "Сейчас в эфире на избранных каналах",
"UNTITLED_SOURCE": "Источник без названия",
"GLOBAL_FAVORITES": "Глобальные избранные",
"REMOVE_FROM_HISTORY": "Удалить из истории",
"RECENT_SOURCES": "Недавно использованные источники",
"MANAGE_ALL": "Управлять всем",
"SEE_ALL": "Смотреть все",
"SEE_ALL_COUNT": "Смотреть все {{count}}",
"RECENTLY_WATCHED": "Недавно просмотренные",
"XTREAM_RECENTLY_ADDED": "Недавно добавленные на Xtream",
"NO_HISTORY": "Для этого фильтра элементы не найдены.",
+3
View File
@@ -823,11 +823,14 @@
"CATEGORY_SORT_ARIA": "Kategorileri sırala",
"DASHBOARD": {
"CONTINUE_WATCHING": "İzlemeye Devam Et",
"LIVE_RECENT": "Favorilerinizde şu an canlı",
"UNTITLED_SOURCE": "Adsız kaynak",
"GLOBAL_FAVORITES": "Tüm Favoriler",
"REMOVE_FROM_HISTORY": "Geçmişten kaldır",
"RECENT_SOURCES": "Son Kullanılan Kaynaklar",
"MANAGE_ALL": "Tümünü yönet",
"SEE_ALL": "Tümünü gör",
"SEE_ALL_COUNT": "{{count}} öğenin tümünü gör",
"RECENTLY_WATCHED": "Son İzlenenler",
"XTREAM_RECENTLY_ADDED": "Xtream'de Yeni Eklenenler",
"NO_HISTORY": "Bu filtre için öğe bulunamadı.",
+3
View File
@@ -823,11 +823,14 @@
"CATEGORY_SORT_ARIA": "排序分类",
"DASHBOARD": {
"CONTINUE_WATCHING": "继续观看",
"LIVE_RECENT": "收藏频道正在播放",
"UNTITLED_SOURCE": "未命名源",
"GLOBAL_FAVORITES": "全局收藏",
"REMOVE_FROM_HISTORY": "从历史记录中移除",
"RECENT_SOURCES": "最近使用的源",
"MANAGE_ALL": "管理全部",
"SEE_ALL": "查看全部",
"SEE_ALL_COUNT": "查看全部 {{count}} 个",
"RECENTLY_WATCHED": "最近观看",
"XTREAM_RECENTLY_ADDED": "Xtream 最近添加",
"NO_HISTORY": "没有项目匹配此筛选。",
+3
View File
@@ -823,11 +823,14 @@
"CATEGORY_SORT_ARIA": "排序分類",
"DASHBOARD": {
"CONTINUE_WATCHING": "繼續觀看",
"LIVE_RECENT": "我的最愛正在直播",
"UNTITLED_SOURCE": "未命名的來源",
"GLOBAL_FAVORITES": "全域我的最愛",
"REMOVE_FROM_HISTORY": "從歷史記錄中移除",
"RECENT_SOURCES": "最近使用的來源",
"MANAGE_ALL": "管理全部",
"SEE_ALL": "查看全部",
"SEE_ALL_COUNT": "查看全部 {{count}} 個",
"RECENTLY_WATCHED": "最近觀看",
"XTREAM_RECENTLY_ADDED": "Xtream 最近新增",
"NO_HISTORY": "找不到符合此篩選條件的項目。",
@@ -419,6 +419,75 @@ describe('DashboardDataService', () => {
);
});
it('splits global recent items into VOD and live computed signals so the dashboard can render two rails', async () => {
// Seed an Xtream movie and an Xtream series alongside the M3U live
// channel already present in defaultPlaylists, then verify the
// splits land on the right computed signal.
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
{
id: 401,
category_id: 18,
title: 'Recent Movie',
rating: '7.8',
viewed_at: '2026-04-21T10:00:00.000Z',
poster_url: 'https://example.com/recent-movie.png',
xtream_id: 7001,
type: 'movie',
playlist_id: 'xtream-1',
playlist_name: 'Xtream Playlist',
},
{
id: 402,
category_id: 19,
title: 'Recent Series',
rating: '8.1',
viewed_at: '2026-04-22T10:00:00.000Z',
poster_url: 'https://example.com/recent-series.png',
xtream_id: 7002,
type: 'series',
playlist_id: 'xtream-1',
playlist_name: 'Xtream Playlist',
},
]);
playlistsSignal.set([
...playlistsSignal(),
{
_id: 'xtream-1',
title: 'Xtream Playlist',
count: 1,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
serverUrl: 'https://example.com',
},
]);
await service.reloadGlobalRecentItems();
const vodTitles = service
.globalRecentVodItems()
.map((item) => item.title);
const liveTitles = service
.globalRecentLiveItems()
.map((item) => item.title);
expect(vodTitles).toEqual(
expect.arrayContaining(['Recent Movie', 'Recent Series'])
);
expect(vodTitles).not.toContain('Channel One');
expect(liveTitles).toContain('Channel One');
expect(liveTitles).not.toEqual(
expect.arrayContaining(['Recent Movie', 'Recent Series'])
);
// Splits stay mutually exclusive — sum of partitions equals the whole.
expect(
service.globalRecentVodItems().length +
service.globalRecentLiveItems().length
).toBe(service.globalRecentItems().length);
});
it('prioritizes recently used Xtream sources over newer imported M3U sources', async () => {
playlistsSignal.set([
{
@@ -196,6 +196,19 @@ export class DashboardDataService {
.slice(0, 200)
);
// Split the recent list by content type so the dashboard can render
// movies/series ("Continue watching") separately from live channels
// ("Live now on your favorites"). Two card formats — one rail each.
readonly globalRecentVodItems = computed<GlobalRecentItem[]>(() =>
this.globalRecentItems().filter(
(item) => item.type === 'movie' || item.type === 'series'
)
);
readonly globalRecentLiveItems = computed<GlobalRecentItem[]>(() =>
this.globalRecentItems().filter((item) => item.type === 'live')
);
readonly stalkerGlobalFavorites = computed<DashboardFavoriteItem[]>(() => {
this.languageTick();
return buildStalkerFavoriteItems(
@@ -14,9 +14,20 @@
[routerLink]="link"
[attr.data-test-id]="testId() ? testId() + '-manage-all' : null"
>
<span class="rail__see-all-label">{{
'WORKSPACE.DASHBOARD.MANAGE_ALL' | translate
}}</span>
<span class="rail__see-all-label">
@if ((totalCount() ?? items().length) > items().length) {
{{
'WORKSPACE.DASHBOARD.SEE_ALL_COUNT'
| translate
: {
count:
totalCount() ?? items().length,
}
}}
} @else {
{{ 'WORKSPACE.DASHBOARD.SEE_ALL' | translate }}
}
</span>
<mat-icon>chevron_right</mat-icon>
</a>
}
@@ -102,16 +102,33 @@
</section>
}
<!-- Recently watched rail -->
@if (recentlyWatchedCards().length > 0) {
<!-- Continue watching (movies + series) -->
@if (continueWatchingCards().length > 0) {
<lib-dashboard-rail
[label]="'WORKSPACE.DASHBOARD.RECENTLY_WATCHED' | translate"
[items]="recentlyWatchedCards()"
[totalCount]="data.globalRecentItems().length"
[label]="'WORKSPACE.DASHBOARD.CONTINUE_WATCHING' | translate"
[items]="continueWatchingCards()"
[totalCount]="data.globalRecentVodItems().length"
[seeAllLink]="['/workspace/global-recent']"
[testId]="'dashboard-recently-watched-rail'"
[testId]="'dashboard-continue-watching-rail'"
/>
} @else if (data.globalRecentLoading()) {
}
<!-- Live now on your favorites (channels you recently watched) -->
@if (liveOnFavoritesCards().length > 0) {
<lib-dashboard-rail
[label]="'WORKSPACE.DASHBOARD.LIVE_RECENT' | translate"
[items]="liveOnFavoritesCards()"
[totalCount]="data.globalRecentLiveItems().length"
[seeAllLink]="['/workspace/global-recent']"
[testId]="'dashboard-live-recent-rail'"
/>
}
@if (
continueWatchingCards().length === 0 &&
liveOnFavoritesCards().length === 0 &&
data.globalRecentLoading()
) {
<section
class="rails-page__skeleton-rail"
aria-hidden="true"
@@ -270,9 +270,20 @@ export class WorkspaceDashboardRailsComponent {
};
});
readonly recentlyWatchedCards = computed<DashboardRailCard[]>(() =>
// Split the mixed "recently watched" history into two rails. VOD items
// (movies/series) get one rail; live channels get their own. Two card
// formats — one rail each — so users can scan each surface at a glance
// instead of decoding a mixed grid of fallback posters and channel logos.
readonly continueWatchingCards = computed<DashboardRailCard[]>(() =>
this.data
.globalRecentItems()
.globalRecentVodItems()
.slice(0, RAIL_ITEM_LIMIT)
.map((item) => this.toRecentCard(item))
);
readonly liveOnFavoritesCards = computed<DashboardRailCard[]>(() =>
this.data
.globalRecentLiveItems()
.slice(0, RAIL_ITEM_LIMIT)
.map((item) => this.toRecentCard(item))
);