refactor(dashboard): live rail now favorites-first; drop mixed Favorites rail

Two related dashboard fixes driven by the v0.22 mockup intent and the
UX-audit feedback that the rail labels lied about their data:

1. Live rail is favorites-first
   - Source: globalFavoriteLiveItems() — the channels the user actually
     starred. Falls back to globalRecentLiveItems() when no favorites
     exist so fresh-install users still see something useful.
   - Title flips with the source: "Live now on your favorites" when
     pulling from favorites, "Continue with live TV" when pulling from
     recent-watch history. The label is always honest about the data.
   - "See all" link routes to the right collection page for the source
     (/workspace/global-favorites vs /workspace/global-recent).

2. Mixed Global Favorites rail removed from the dashboard
   - The rail had movies, series, live channels, and radio all sharing
     one row — different card formats fighting for visual attention.
     UX wiki principle: similar elements should look alike within a
     scanning unit.
   - Live favorites are promoted into the live rail above (with current
     EPG). The full mixed catalogue is still one click away at
     /workspace/global-favorites where the collection page can give it
     proper per-type filters.
   - Net dashboard density goes from 5 rails to 4 — closer to the
     streaming-app sweet spot.

i18n: new WORKSPACE.DASHBOARD.LIVE_CONTINUE key, translated across all
17 locales by per-locale agents (placeholder integrity verified).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 4.7 committed 2026-05-19 01:01:22 +02:00
1 parent 9bb41f59b0
commit fe849b2de2
22 files changed
+139 -30

No files matched your search

+1
View File
@@ -824,6 +824,7 @@
"DASHBOARD": {
"CONTINUE_WATCHING": "متابعة المشاهدة",
"LIVE_RECENT": "يُبث الآن على المفضلة لديك",
"LIVE_CONTINUE": "متابعة البث المباشر",
"UNTITLED_SOURCE": "مصدر بدون عنوان",
"GLOBAL_FAVORITES": "المفضلة العامة",
"REMOVE_FROM_HISTORY": "إزالة من السجل",
+1
View File
@@ -824,6 +824,7 @@
"DASHBOARD": {
"CONTINUE_WATCHING": "كمل المشاهدة",
"LIVE_RECENT": "مباشر دابا على المفضلة ديالك",
"LIVE_CONTINUE": "كمل مع المباشر",
"UNTITLED_SOURCE": "مصدر بلا عنوان",
"GLOBAL_FAVORITES": "المفضلة العامة",
"REMOVE_FROM_HISTORY": "حيد من السجل",
+1
View File
@@ -824,6 +824,7 @@
"DASHBOARD": {
"CONTINUE_WATCHING": "Працягнуць прагляд",
"LIVE_RECENT": "Цяпер у эфіры на выбраных",
"LIVE_CONTINUE": "Працягнуць прагляд эфіру",
"UNTITLED_SOURCE": "Крыніца без назвы",
"GLOBAL_FAVORITES": "Усе фаварыты",
"REMOVE_FROM_HISTORY": "Выдаліць з гісторыі",
+1
View File
@@ -824,6 +824,7 @@
"DASHBOARD": {
"CONTINUE_WATCHING": "Weiter schauen",
"LIVE_RECENT": "Jetzt live auf Ihren Favoriten",
"LIVE_CONTINUE": "Mit Live-TV weiterschauen",
"UNTITLED_SOURCE": "Unbenannte Quelle",
"GLOBAL_FAVORITES": "Globale Favoriten",
"REMOVE_FROM_HISTORY": "Aus Verlauf entfernen",
+1
View File
@@ -824,6 +824,7 @@
"DASHBOARD": {
"CONTINUE_WATCHING": "Συνέχιση παρακολούθησης",
"LIVE_RECENT": "Ζωντανά τώρα στα αγαπημένα σας",
"LIVE_CONTINUE": "Συνέχιση με ζωντανή TV",
"UNTITLED_SOURCE": "Πηγή χωρίς τίτλο",
"GLOBAL_FAVORITES": "Βασικά Αγαπημένα",
"REMOVE_FROM_HISTORY": "Αφαίρεση από το ιστορικό",
+1
View File
@@ -824,6 +824,7 @@
"DASHBOARD": {
"CONTINUE_WATCHING": "Continue Watching",
"LIVE_RECENT": "Live now on your favorites",
"LIVE_CONTINUE": "Continue with live TV",
"UNTITLED_SOURCE": "Untitled source",
"GLOBAL_FAVORITES": "Global Favorites",
"REMOVE_FROM_HISTORY": "Remove from history",
+1
View File
@@ -824,6 +824,7 @@
"DASHBOARD": {
"CONTINUE_WATCHING": "Continuar viendo",
"LIVE_RECENT": "Ahora en vivo en tus favoritos",
"LIVE_CONTINUE": "Continuar con TV en vivo",
"UNTITLED_SOURCE": "Fuente sin título",
"GLOBAL_FAVORITES": "Favoritos globales",
"REMOVE_FROM_HISTORY": "Quitar del historial",
+1
View File
@@ -824,6 +824,7 @@
"DASHBOARD": {
"CONTINUE_WATCHING": "Reprendre la lecture",
"LIVE_RECENT": "En direct sur vos favoris",
"LIVE_CONTINUE": "Reprendre la TV en direct",
"UNTITLED_SOURCE": "Source sans titre",
"GLOBAL_FAVORITES": "Favoris globaux",
"REMOVE_FROM_HISTORY": "Retirer de l'historique",
+1
View File
@@ -824,6 +824,7 @@
"DASHBOARD": {
"CONTINUE_WATCHING": "Continua a guardare",
"LIVE_RECENT": "Live ora sui tuoi preferiti",
"LIVE_CONTINUE": "Continua con la TV live",
"UNTITLED_SOURCE": "Sorgente senza titolo",
"GLOBAL_FAVORITES": "Preferiti globali",
"REMOVE_FROM_HISTORY": "Rimuovi dalla cronologia",
+1
View File
@@ -824,6 +824,7 @@
"DASHBOARD": {
"CONTINUE_WATCHING": "視聴を再開",
"LIVE_RECENT": "お気に入りで現在放送中",
"LIVE_CONTINUE": "ライブTVの視聴を再開",
"UNTITLED_SOURCE": "無題のソース",
"GLOBAL_FAVORITES": "グローバルお気に入り",
"REMOVE_FROM_HISTORY": "履歴から削除",
+1
View File
@@ -824,6 +824,7 @@
"DASHBOARD": {
"CONTINUE_WATCHING": "이어서 시청",
"LIVE_RECENT": "즐겨찾기에서 지금 라이브 중인 채널입니다",
"LIVE_CONTINUE": "라이브 TV 이어서 시청",
"UNTITLED_SOURCE": "제목 없는 소스",
"GLOBAL_FAVORITES": "전체 즐겨찾기",
"REMOVE_FROM_HISTORY": "기록에서 제거",
+1
View File
@@ -824,6 +824,7 @@
"DASHBOARD": {
"CONTINUE_WATCHING": "Verder kijken",
"LIVE_RECENT": "Nu live op je favorieten",
"LIVE_CONTINUE": "Verder kijken met live tv",
"UNTITLED_SOURCE": "Naamloze bron",
"GLOBAL_FAVORITES": "Algemene favorieten",
"REMOVE_FROM_HISTORY": "Verwijderen uit geschiedenis",
+1
View File
@@ -824,6 +824,7 @@
"DASHBOARD": {
"CONTINUE_WATCHING": "Kontynuuj oglądanie",
"LIVE_RECENT": "Na żywo w Twoich ulubionych",
"LIVE_CONTINUE": "Kontynuuj oglądanie na żywo",
"UNTITLED_SOURCE": "Źródło bez tytułu",
"GLOBAL_FAVORITES": "Globalne ulubione",
"REMOVE_FROM_HISTORY": "Usuń z historii",
+1
View File
@@ -824,6 +824,7 @@
"DASHBOARD": {
"CONTINUE_WATCHING": "Continuar assistindo",
"LIVE_RECENT": "Ao vivo agora nos seus favoritos",
"LIVE_CONTINUE": "Continuar com TV ao vivo",
"UNTITLED_SOURCE": "Fonte sem título",
"GLOBAL_FAVORITES": "Favoritos globais",
"REMOVE_FROM_HISTORY": "Remover do histórico",
+1
View File
@@ -824,6 +824,7 @@
"DASHBOARD": {
"CONTINUE_WATCHING": "Продолжить просмотр",
"LIVE_RECENT": "Сейчас в эфире на избранных каналах",
"LIVE_CONTINUE": "Продолжить просмотр прямого эфира",
"UNTITLED_SOURCE": "Источник без названия",
"GLOBAL_FAVORITES": "Глобальные избранные",
"REMOVE_FROM_HISTORY": "Удалить из истории",
+1
View File
@@ -824,6 +824,7 @@
"DASHBOARD": {
"CONTINUE_WATCHING": "İzlemeye Devam Et",
"LIVE_RECENT": "Favorilerinizde şu an canlı",
"LIVE_CONTINUE": "Canlı TV ile devam et",
"UNTITLED_SOURCE": "Adsız kaynak",
"GLOBAL_FAVORITES": "Tüm Favoriler",
"REMOVE_FROM_HISTORY": "Geçmişten kaldır",
+1
View File
@@ -824,6 +824,7 @@
"DASHBOARD": {
"CONTINUE_WATCHING": "继续观看",
"LIVE_RECENT": "收藏频道正在播放",
"LIVE_CONTINUE": "继续观看直播",
"UNTITLED_SOURCE": "未命名源",
"GLOBAL_FAVORITES": "全局收藏",
"REMOVE_FROM_HISTORY": "从历史记录中移除",
+1
View File
@@ -824,6 +824,7 @@
"DASHBOARD": {
"CONTINUE_WATCHING": "繼續觀看",
"LIVE_RECENT": "我的最愛正在直播",
"LIVE_CONTINUE": "繼續觀看直播電視",
"UNTITLED_SOURCE": "未命名的來源",
"GLOBAL_FAVORITES": "全域我的最愛",
"REMOVE_FROM_HISTORY": "從歷史記錄中移除",
@@ -621,6 +621,69 @@ describe('DashboardDataService', () => {
expect(seriesPos?.positionSeconds).toBe(720);
});
it('exposes a live-only slice of global favorites so the dashboard can promote favorited channels into the Live rail', async () => {
// Seed two favorites: a movie and a live channel.
dbServiceMock.getAllGlobalFavorites.mockResolvedValue([
{
id: 500,
category_id: 18,
title: 'Favorite Movie',
rating: '7.0',
added_at: '2026-04-21T10:00:00.000Z',
poster_url: 'https://example.com/fav-movie.png',
xtream_id: 5500,
type: 'movie',
playlist_id: 'xtream-1',
playlist_name: 'Xtream Playlist',
},
{
id: 501,
category_id: 19,
title: 'Favorite Channel HD',
rating: '0',
added_at: '2026-04-22T10:00:00.000Z',
poster_url: 'https://example.com/fav-channel.png',
xtream_id: 5501,
type: 'live',
playlist_id: 'xtream-1',
playlist_name: 'Xtream Playlist',
},
]);
playlistsSignal.set([
...playlistsSignal(),
{
_id: 'xtream-1',
title: 'Xtream Playlist',
count: 2,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
serverUrl: 'https://example.com',
},
]);
await service.reloadGlobalFavorites();
const liveOnly = service.globalFavoriteLiveItems();
// Includes the Xtream "Favorite Channel HD" plus the two M3U live
// favorites already seeded by the default fixture.
expect(liveOnly.map((it) => it.title)).toEqual(
expect.arrayContaining([
'Favorite Channel HD',
'Channel One',
'Channel Two',
])
);
// Crucially, the movie favorite is excluded — the rail source must
// never leak VOD posters into the channel layout.
expect(liveOnly.map((it) => it.title)).not.toContain(
'Favorite Movie'
);
expect(
service.globalFavoriteLiveItems().every((it) => it.type === 'live')
).toBe(true);
});
it('returns null playback position for live channels (no position tracking in schema)', async () => {
// The default M3U playlist contains a live channel. Without
// reloading playback positions the lookup still gracefully returns
@@ -223,6 +223,14 @@ export class DashboardDataService {
this.globalRecentItems().filter((item) => item.type === 'live')
);
// Favorited live channels — the dashboard's "Live now" rail prefers this
// source so the EPG enrichment lands on the channels users actually care
// about. Falls back to globalRecentLiveItems when this is empty so a
// fresh-install user still sees something useful.
readonly globalFavoriteLiveItems = computed<DashboardFavoriteItem[]>(() =>
this.globalFavoriteItems().filter((item) => item.type === 'live')
);
// Playback positions, keyed by `${playlist_id}::${contentXtreamId}::${type}`.
// Loaded lazily on dashboard mount (and refreshed when recent items
// change) so the hero + Continue Watching cards can show "X min left"
@@ -131,13 +131,19 @@
/>
}
<!-- Live now on your favorites (channels you recently watched) -->
<!-- Live rail — favorited live channels first, falls back to
recently-watched live. Title flips based on source so the label
is always honest about what's rendered. -->
@if (liveOnFavoritesCardsEnriched().length > 0) {
<lib-dashboard-rail
[label]="'WORKSPACE.DASHBOARD.LIVE_RECENT' | translate"
[label]="liveRailTitleKey() | translate"
[items]="liveOnFavoritesCardsEnriched()"
[totalCount]="data.globalRecentLiveItems().length"
[seeAllLink]="['/workspace/global-recent']"
[totalCount]="liveRailTotalCount()"
[seeAllLink]="
liveRailSource() === 'favorites'
? ['/workspace/global-favorites']
: ['/workspace/global-recent']
"
layout="channel"
[testId]="'dashboard-live-recent-rail'"
/>
@@ -162,29 +168,11 @@
</section>
}
<!-- Favorites rail -->
@if (favoriteCards().length > 0) {
<lib-dashboard-rail
[label]="'WORKSPACE.DASHBOARD.GLOBAL_FAVORITES' | translate"
[items]="favoriteCards()"
[totalCount]="data.globalFavoriteItems().length"
[seeAllLink]="['/workspace/global-favorites']"
[testId]="'dashboard-global-favorites-rail'"
/>
} @else if (data.globalFavoritesLoading()) {
<section
class="rails-page__skeleton-rail"
aria-hidden="true"
aria-busy="true"
>
<div class="rails-page__skeleton-header"></div>
<div class="rails-page__skeleton-track">
@for (slot of skeletonSlots; track slot) {
<div class="rails-page__skeleton-card"></div>
}
</div>
</section>
}
<!-- Mixed Global Favorites rail intentionally NOT rendered on the
dashboard. Live favorites are promoted into the live rail above
(with current EPG), and the full mixed catalogue lives on the
dedicated /workspace/global-favorites page where the collection
UI can give it the per-type filters it needs. -->
<!-- Sources rail -->
@if (sourceCards().length > 0) {
@@ -403,11 +403,43 @@ export class WorkspaceDashboardRailsComponent {
.map((item) => this.toRecentCard(item))
);
readonly liveOnFavoritesCards = computed<DashboardRailCard[]>(() =>
this.data
// Live rail source — favorited live channels take precedence so the EPG
// enrichment lands on the channels the user actually cares about. When
// no favorites exist (fresh install, or a user who hasn't starred any
// channel yet), fall back to recently-watched live so the rail still
// has something to show. `liveRailSource` drives the title swap.
readonly liveRailSource = computed<'favorites' | 'recent'>(() =>
this.data.globalFavoriteLiveItems().length > 0
? 'favorites'
: 'recent'
);
readonly liveOnFavoritesCards = computed<DashboardRailCard[]>(() => {
if (this.liveRailSource() === 'favorites') {
return this.data
.globalFavoriteLiveItems()
.slice(0, RAIL_ITEM_LIMIT)
.map((item) => this.toFavoriteCard(item));
}
return this.data
.globalRecentLiveItems()
.slice(0, RAIL_ITEM_LIMIT)
.map((item) => this.toRecentCard(item))
.map((item) => this.toRecentCard(item));
});
// Title key flips with the source — "Live now on your favorites" when
// we're rendering favorites, "Continue with live TV" when we're falling
// back to recently-watched. Always honest about what the user is seeing.
readonly liveRailTitleKey = computed(() =>
this.liveRailSource() === 'favorites'
? 'WORKSPACE.DASHBOARD.LIVE_RECENT'
: 'WORKSPACE.DASHBOARD.LIVE_CONTINUE'
);
readonly liveRailTotalCount = computed(() =>
this.liveRailSource() === 'favorites'
? this.data.globalFavoriteLiveItems().length
: this.data.globalRecentLiveItems().length
);
// Best-effort EPG lookup keyed by the channel's display name. This works