mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
9bb41f59b0
commit
fe849b2de2
22 files changed
+139
-30
No files matched your search
@@ -824,6 +824,7 @@
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "متابعة المشاهدة",
|
||||
"LIVE_RECENT": "يُبث الآن على المفضلة لديك",
|
||||
"LIVE_CONTINUE": "متابعة البث المباشر",
|
||||
"UNTITLED_SOURCE": "مصدر بدون عنوان",
|
||||
"GLOBAL_FAVORITES": "المفضلة العامة",
|
||||
"REMOVE_FROM_HISTORY": "إزالة من السجل",
|
||||
|
||||
@@ -824,6 +824,7 @@
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "كمل المشاهدة",
|
||||
"LIVE_RECENT": "مباشر دابا على المفضلة ديالك",
|
||||
"LIVE_CONTINUE": "كمل مع المباشر",
|
||||
"UNTITLED_SOURCE": "مصدر بلا عنوان",
|
||||
"GLOBAL_FAVORITES": "المفضلة العامة",
|
||||
"REMOVE_FROM_HISTORY": "حيد من السجل",
|
||||
|
||||
@@ -824,6 +824,7 @@
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "Працягнуць прагляд",
|
||||
"LIVE_RECENT": "Цяпер у эфіры на выбраных",
|
||||
"LIVE_CONTINUE": "Працягнуць прагляд эфіру",
|
||||
"UNTITLED_SOURCE": "Крыніца без назвы",
|
||||
"GLOBAL_FAVORITES": "Усе фаварыты",
|
||||
"REMOVE_FROM_HISTORY": "Выдаліць з гісторыі",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -824,6 +824,7 @@
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "Συνέχιση παρακολούθησης",
|
||||
"LIVE_RECENT": "Ζωντανά τώρα στα αγαπημένα σας",
|
||||
"LIVE_CONTINUE": "Συνέχιση με ζωντανή TV",
|
||||
"UNTITLED_SOURCE": "Πηγή χωρίς τίτλο",
|
||||
"GLOBAL_FAVORITES": "Βασικά Αγαπημένα",
|
||||
"REMOVE_FROM_HISTORY": "Αφαίρεση από το ιστορικό",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -824,6 +824,7 @@
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "視聴を再開",
|
||||
"LIVE_RECENT": "お気に入りで現在放送中",
|
||||
"LIVE_CONTINUE": "ライブTVの視聴を再開",
|
||||
"UNTITLED_SOURCE": "無題のソース",
|
||||
"GLOBAL_FAVORITES": "グローバルお気に入り",
|
||||
"REMOVE_FROM_HISTORY": "履歴から削除",
|
||||
|
||||
@@ -824,6 +824,7 @@
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "이어서 시청",
|
||||
"LIVE_RECENT": "즐겨찾기에서 지금 라이브 중인 채널입니다",
|
||||
"LIVE_CONTINUE": "라이브 TV 이어서 시청",
|
||||
"UNTITLED_SOURCE": "제목 없는 소스",
|
||||
"GLOBAL_FAVORITES": "전체 즐겨찾기",
|
||||
"REMOVE_FROM_HISTORY": "기록에서 제거",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -824,6 +824,7 @@
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "Продолжить просмотр",
|
||||
"LIVE_RECENT": "Сейчас в эфире на избранных каналах",
|
||||
"LIVE_CONTINUE": "Продолжить просмотр прямого эфира",
|
||||
"UNTITLED_SOURCE": "Источник без названия",
|
||||
"GLOBAL_FAVORITES": "Глобальные избранные",
|
||||
"REMOVE_FROM_HISTORY": "Удалить из истории",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -824,6 +824,7 @@
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "继续观看",
|
||||
"LIVE_RECENT": "收藏频道正在播放",
|
||||
"LIVE_CONTINUE": "继续观看直播",
|
||||
"UNTITLED_SOURCE": "未命名源",
|
||||
"GLOBAL_FAVORITES": "全局收藏",
|
||||
"REMOVE_FROM_HISTORY": "从历史记录中移除",
|
||||
|
||||
@@ -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"
|
||||
|
||||
+15
-27
@@ -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) {
|
||||
|
||||
+35
-3
@@ -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
|
||||
|
||||
Reference in new issue
Block a user