mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(dashboard): address PR review feedback
This commit is contained in:
1 parent
7083bd2af4
commit
2ecb5c28f4
30 files changed
+340
-104
No files matched your search
@@ -851,8 +851,8 @@
|
||||
"CATEGORY_SORT_ARIA": "ترتيب الفئات",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "متابعة المشاهدة",
|
||||
"LIVE_RECENT": "يُبث الآن على المفضلة لديك",
|
||||
"LIVE_CONTINUE": "متابعة البث المباشر",
|
||||
"LIVE_FAVORITES": "يُبث الآن على المفضلة لديك",
|
||||
"LIVE_RECENT": "متابعة البث المباشر",
|
||||
"UNTITLED_SOURCE": "مصدر بدون عنوان",
|
||||
"GLOBAL_FAVORITES": "المفضلة العامة",
|
||||
"REMOVE_FROM_HISTORY": "إزالة من السجل",
|
||||
|
||||
@@ -851,8 +851,8 @@
|
||||
"CATEGORY_SORT_ARIA": "ترتيب التصنيفات",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "كمل المشاهدة",
|
||||
"LIVE_RECENT": "مباشر دابا على المفضلة ديالك",
|
||||
"LIVE_CONTINUE": "كمل مع المباشر",
|
||||
"LIVE_FAVORITES": "مباشر دابا على المفضلة ديالك",
|
||||
"LIVE_RECENT": "كمل مع المباشر",
|
||||
"UNTITLED_SOURCE": "مصدر بلا عنوان",
|
||||
"GLOBAL_FAVORITES": "المفضلة العامة",
|
||||
"REMOVE_FROM_HISTORY": "حيد من السجل",
|
||||
|
||||
@@ -851,8 +851,8 @@
|
||||
"CATEGORY_SORT_ARIA": "Сартаваць катэгорыі",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "Працягнуць прагляд",
|
||||
"LIVE_RECENT": "Цяпер у эфіры на выбраных",
|
||||
"LIVE_CONTINUE": "Працягнуць прагляд эфіру",
|
||||
"LIVE_FAVORITES": "Цяпер у эфіры на выбраных",
|
||||
"LIVE_RECENT": "Працягнуць прагляд эфіру",
|
||||
"UNTITLED_SOURCE": "Крыніца без назвы",
|
||||
"GLOBAL_FAVORITES": "Усе фаварыты",
|
||||
"REMOVE_FROM_HISTORY": "Выдаліць з гісторыі",
|
||||
|
||||
@@ -851,8 +851,8 @@
|
||||
"CATEGORY_SORT_ARIA": "Kategorien sortieren",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "Weiter schauen",
|
||||
"LIVE_RECENT": "Jetzt live auf Ihren Favoriten",
|
||||
"LIVE_CONTINUE": "Mit Live-TV weiterschauen",
|
||||
"LIVE_FAVORITES": "Jetzt live auf Ihren Favoriten",
|
||||
"LIVE_RECENT": "Mit Live-TV weiterschauen",
|
||||
"UNTITLED_SOURCE": "Unbenannte Quelle",
|
||||
"GLOBAL_FAVORITES": "Globale Favoriten",
|
||||
"REMOVE_FROM_HISTORY": "Aus Verlauf entfernen",
|
||||
|
||||
@@ -851,8 +851,8 @@
|
||||
"CATEGORY_SORT_ARIA": "Ταξινόμηση κατηγοριών",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "Συνέχιση παρακολούθησης",
|
||||
"LIVE_RECENT": "Ζωντανά τώρα στα αγαπημένα σας",
|
||||
"LIVE_CONTINUE": "Συνέχιση με ζωντανή TV",
|
||||
"LIVE_FAVORITES": "Ζωντανά τώρα στα αγαπημένα σας",
|
||||
"LIVE_RECENT": "Συνέχιση με ζωντανή TV",
|
||||
"UNTITLED_SOURCE": "Πηγή χωρίς τίτλο",
|
||||
"GLOBAL_FAVORITES": "Βασικά Αγαπημένα",
|
||||
"REMOVE_FROM_HISTORY": "Αφαίρεση από το ιστορικό",
|
||||
|
||||
@@ -851,8 +851,8 @@
|
||||
"CATEGORY_SORT_ARIA": "Sort categories",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "Continue Watching",
|
||||
"LIVE_RECENT": "Live now on your favorites",
|
||||
"LIVE_CONTINUE": "Continue with live TV",
|
||||
"LIVE_FAVORITES": "Live now on your favorites",
|
||||
"LIVE_RECENT": "Continue with live TV",
|
||||
"UNTITLED_SOURCE": "Untitled source",
|
||||
"GLOBAL_FAVORITES": "Global Favorites",
|
||||
"REMOVE_FROM_HISTORY": "Remove from history",
|
||||
|
||||
@@ -851,8 +851,8 @@
|
||||
"CATEGORY_SORT_ARIA": "Ordenar categorías",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "Continuar viendo",
|
||||
"LIVE_RECENT": "Ahora en vivo en tus favoritos",
|
||||
"LIVE_CONTINUE": "Continuar con TV en vivo",
|
||||
"LIVE_FAVORITES": "Ahora en vivo en tus favoritos",
|
||||
"LIVE_RECENT": "Continuar con TV en vivo",
|
||||
"UNTITLED_SOURCE": "Fuente sin título",
|
||||
"GLOBAL_FAVORITES": "Favoritos globales",
|
||||
"REMOVE_FROM_HISTORY": "Quitar del historial",
|
||||
|
||||
@@ -851,8 +851,8 @@
|
||||
"CATEGORY_SORT_ARIA": "Trier les catégories",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "Reprendre la lecture",
|
||||
"LIVE_RECENT": "En direct sur vos favoris",
|
||||
"LIVE_CONTINUE": "Reprendre la TV en direct",
|
||||
"LIVE_FAVORITES": "En direct sur vos favoris",
|
||||
"LIVE_RECENT": "Reprendre la TV en direct",
|
||||
"UNTITLED_SOURCE": "Source sans titre",
|
||||
"GLOBAL_FAVORITES": "Favoris globaux",
|
||||
"REMOVE_FROM_HISTORY": "Retirer de l'historique",
|
||||
|
||||
@@ -851,8 +851,8 @@
|
||||
"CATEGORY_SORT_ARIA": "Ordina categorie",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "Continua a guardare",
|
||||
"LIVE_RECENT": "Live ora sui tuoi preferiti",
|
||||
"LIVE_CONTINUE": "Continua con la TV live",
|
||||
"LIVE_FAVORITES": "Live ora sui tuoi preferiti",
|
||||
"LIVE_RECENT": "Continua con la TV live",
|
||||
"UNTITLED_SOURCE": "Sorgente senza titolo",
|
||||
"GLOBAL_FAVORITES": "Preferiti globali",
|
||||
"REMOVE_FROM_HISTORY": "Rimuovi dalla cronologia",
|
||||
|
||||
@@ -851,8 +851,8 @@
|
||||
"CATEGORY_SORT_ARIA": "カテゴリを並び替え",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "視聴を再開",
|
||||
"LIVE_RECENT": "お気に入りで現在放送中",
|
||||
"LIVE_CONTINUE": "ライブTVの視聴を再開",
|
||||
"LIVE_FAVORITES": "お気に入りで現在放送中",
|
||||
"LIVE_RECENT": "ライブTVの視聴を再開",
|
||||
"UNTITLED_SOURCE": "無題のソース",
|
||||
"GLOBAL_FAVORITES": "グローバルお気に入り",
|
||||
"REMOVE_FROM_HISTORY": "履歴から削除",
|
||||
|
||||
@@ -851,8 +851,8 @@
|
||||
"CATEGORY_SORT_ARIA": "카테고리 정렬",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "이어서 시청",
|
||||
"LIVE_RECENT": "즐겨찾기에서 지금 라이브 중인 채널입니다",
|
||||
"LIVE_CONTINUE": "라이브 TV 이어서 시청",
|
||||
"LIVE_FAVORITES": "즐겨찾기에서 지금 라이브 중인 채널입니다",
|
||||
"LIVE_RECENT": "라이브 TV 이어서 시청",
|
||||
"UNTITLED_SOURCE": "제목 없는 소스",
|
||||
"GLOBAL_FAVORITES": "전체 즐겨찾기",
|
||||
"REMOVE_FROM_HISTORY": "기록에서 제거",
|
||||
|
||||
@@ -851,8 +851,8 @@
|
||||
"CATEGORY_SORT_ARIA": "Categorieën sorteren",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "Verder kijken",
|
||||
"LIVE_RECENT": "Nu live op je favorieten",
|
||||
"LIVE_CONTINUE": "Verder kijken met live tv",
|
||||
"LIVE_FAVORITES": "Nu live op je favorieten",
|
||||
"LIVE_RECENT": "Verder kijken met live tv",
|
||||
"UNTITLED_SOURCE": "Naamloze bron",
|
||||
"GLOBAL_FAVORITES": "Algemene favorieten",
|
||||
"REMOVE_FROM_HISTORY": "Verwijderen uit geschiedenis",
|
||||
|
||||
@@ -851,8 +851,8 @@
|
||||
"CATEGORY_SORT_ARIA": "Sortuj kategorie",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "Kontynuuj oglądanie",
|
||||
"LIVE_RECENT": "Na żywo w Twoich ulubionych",
|
||||
"LIVE_CONTINUE": "Kontynuuj oglądanie na żywo",
|
||||
"LIVE_FAVORITES": "Na żywo w Twoich ulubionych",
|
||||
"LIVE_RECENT": "Kontynuuj oglądanie na żywo",
|
||||
"UNTITLED_SOURCE": "Źródło bez tytułu",
|
||||
"GLOBAL_FAVORITES": "Globalne ulubione",
|
||||
"REMOVE_FROM_HISTORY": "Usuń z historii",
|
||||
|
||||
@@ -851,8 +851,8 @@
|
||||
"CATEGORY_SORT_ARIA": "Ordenar categorias",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "Continuar assistindo",
|
||||
"LIVE_RECENT": "Ao vivo agora nos seus favoritos",
|
||||
"LIVE_CONTINUE": "Continuar com TV ao vivo",
|
||||
"LIVE_FAVORITES": "Ao vivo agora nos seus favoritos",
|
||||
"LIVE_RECENT": "Continuar com TV ao vivo",
|
||||
"UNTITLED_SOURCE": "Fonte sem título",
|
||||
"GLOBAL_FAVORITES": "Favoritos globais",
|
||||
"REMOVE_FROM_HISTORY": "Remover do histórico",
|
||||
|
||||
@@ -851,8 +851,8 @@
|
||||
"CATEGORY_SORT_ARIA": "Сортировать категории",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "Продолжить просмотр",
|
||||
"LIVE_RECENT": "Сейчас в эфире на избранных каналах",
|
||||
"LIVE_CONTINUE": "Продолжить просмотр прямого эфира",
|
||||
"LIVE_FAVORITES": "Сейчас в эфире на избранных каналах",
|
||||
"LIVE_RECENT": "Продолжить просмотр прямого эфира",
|
||||
"UNTITLED_SOURCE": "Источник без названия",
|
||||
"GLOBAL_FAVORITES": "Глобальные избранные",
|
||||
"REMOVE_FROM_HISTORY": "Удалить из истории",
|
||||
|
||||
@@ -851,8 +851,8 @@
|
||||
"CATEGORY_SORT_ARIA": "Kategorileri sırala",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "İzlemeye Devam Et",
|
||||
"LIVE_RECENT": "Favorilerinizde şu an canlı",
|
||||
"LIVE_CONTINUE": "Canlı TV ile devam et",
|
||||
"LIVE_FAVORITES": "Favorilerinizde şu an canlı",
|
||||
"LIVE_RECENT": "Canlı TV ile devam et",
|
||||
"UNTITLED_SOURCE": "Adsız kaynak",
|
||||
"GLOBAL_FAVORITES": "Tüm Favoriler",
|
||||
"REMOVE_FROM_HISTORY": "Geçmişten kaldır",
|
||||
|
||||
@@ -851,8 +851,8 @@
|
||||
"CATEGORY_SORT_ARIA": "排序分类",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "继续观看",
|
||||
"LIVE_RECENT": "收藏频道正在播放",
|
||||
"LIVE_CONTINUE": "继续观看直播",
|
||||
"LIVE_FAVORITES": "收藏频道正在播放",
|
||||
"LIVE_RECENT": "继续观看直播",
|
||||
"UNTITLED_SOURCE": "未命名源",
|
||||
"GLOBAL_FAVORITES": "全局收藏",
|
||||
"REMOVE_FROM_HISTORY": "从历史记录中移除",
|
||||
|
||||
@@ -851,8 +851,8 @@
|
||||
"CATEGORY_SORT_ARIA": "排序分類",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "繼續觀看",
|
||||
"LIVE_RECENT": "我的最愛正在直播",
|
||||
"LIVE_CONTINUE": "繼續觀看直播電視",
|
||||
"LIVE_FAVORITES": "我的最愛正在直播",
|
||||
"LIVE_RECENT": "繼續觀看直播電視",
|
||||
"UNTITLED_SOURCE": "未命名的來源",
|
||||
"GLOBAL_FAVORITES": "全域我的最愛",
|
||||
"REMOVE_FROM_HISTORY": "從歷史記錄中移除",
|
||||
|
||||
@@ -35,11 +35,11 @@ Core implementation:
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Hero — Continue Watching (most recent item) │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ Recently Watched · See all → │
|
||||
│ Continue Watching · See all → │
|
||||
│ [poster][poster][poster][poster] →→ │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ Global Favorites · See all → │
|
||||
│ [poster][poster][poster] →→ │
|
||||
│ Live now on your favorites / Continue with live TV · See all → │
|
||||
│ [channel][channel][channel][channel] →→ │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ Recently Used Sources · See all → │
|
||||
│ [tile][tile][tile][tile] →→ │
|
||||
@@ -63,6 +63,9 @@ Render rules:
|
||||
5. The continue-watching hero prefers a stored Xtream `backdrop_url`; when it
|
||||
is missing the UI falls back to a blurred poster treatment instead of
|
||||
showing a flat panel.
|
||||
6. The mixed global favorites rail is not rendered on the dashboard. Live
|
||||
favorites are promoted into the live rail, while mixed favorites stay on
|
||||
`/workspace/global-favorites`.
|
||||
|
||||
## Rail Contract
|
||||
|
||||
@@ -83,10 +86,19 @@ Render rules:
|
||||
## Data Flow
|
||||
|
||||
1. `WorkspaceDashboardRailsComponent` injects `DashboardDataService`.
|
||||
2. It derives five signals via `computed()`:
|
||||
2. It derives the dashboard surface via `computed()`:
|
||||
1. `hero` — first item of `globalRecentItems()`.
|
||||
2. `recentlyWatchedCards` — maps `globalRecentItems()` to rail cards.
|
||||
3. `xtreamRecentlyAddedCards` — maps `xtreamRecentlyAddedItems()` to rail
|
||||
2. `continueWatchingCards` — maps `globalRecentVodItems()` to movie/series
|
||||
cover cards. Xtream playback positions are bulk-loaded per playlist so
|
||||
hero and cards can show progress, remaining time, and series season/
|
||||
episode badges. Series lookup uses keyed maps for both direct episode ids
|
||||
and series ids; card renders must not scan the full playback-position map.
|
||||
3. `liveOnFavoritesCardsEnriched` — maps favorited live channels first,
|
||||
falling back to recently watched live channels when no live favorites
|
||||
exist. M3U cards carry an `epg_lookup_key` using the app-wide XMLTV
|
||||
fallback order (`tvg-id` -> `tvg-name` -> channel name); EPG enrichment
|
||||
must use that key before falling back to the card title.
|
||||
4. `xtreamRecentlyAddedCards` — maps `xtreamRecentlyAddedItems()` to rail
|
||||
cards. Aggregates newly added VOD and series across *all* Xtream
|
||||
playlists via `DashboardDataService.reloadXtreamRecentlyAddedItems()`,
|
||||
which calls `getGlobalRecentlyAdded('all', limit, 'xtream')` with the
|
||||
@@ -95,7 +107,6 @@ Render rules:
|
||||
playlists never see it. Cards carry a `playlist_name · type` subtitle
|
||||
so users can tell which provider each item came from. Driven by an
|
||||
effect that re-runs whenever the Xtream playlist count changes.
|
||||
4. `favoriteCards` — maps `globalFavoriteItems()` to rail cards.
|
||||
5. `sourceCards` — maps `recentPlaylists()` to rail cards. `recentPlaylists()`
|
||||
ranks M3U, Xtream, and Stalker sources by their latest recent activity
|
||||
from `globalRecentItems()`, then falls back to playlist
|
||||
@@ -114,6 +125,9 @@ Render rules:
|
||||
7. The dashboard feature triggers a fresh reload of DB-backed recent/favorite
|
||||
rows on dashboard entry so newly backfilled backdrop data is visible as soon
|
||||
as the user returns from a detail page.
|
||||
8. Playback-position reloads are keyed by the VOD/series recent set and should
|
||||
call `reloadPlaybackPositions()` through `untracked()` so live-only recent
|
||||
changes do not trigger unnecessary IPC round-trips.
|
||||
|
||||
## Empty State
|
||||
|
||||
@@ -141,6 +155,9 @@ The welcome state is rendered via the existing
|
||||
route without switching the active playlist in the header switcher.
|
||||
6. `Recently Used Sources` reflects recent source usage across all provider
|
||||
types, not just recent imports.
|
||||
7. The live rail title key must match the rendered source: favorites use
|
||||
`WORKSPACE.DASHBOARD.LIVE_FAVORITES`; recently watched fallback uses
|
||||
`WORKSPACE.DASHBOARD.LIVE_RECENT`.
|
||||
|
||||
## Adding Or Changing Rails
|
||||
|
||||
|
||||
@@ -17,6 +17,10 @@ interface PlaylistRecentLabels {
|
||||
m3u: string;
|
||||
}
|
||||
|
||||
function firstNonEmpty(...values: Array<string | undefined>): string | undefined {
|
||||
return values.map((value) => value?.trim()).find(Boolean);
|
||||
}
|
||||
|
||||
function getPlaylistRecentItems(
|
||||
playlist: PlaylistMeta
|
||||
): PlaylistRecentlyViewedItem[] {
|
||||
@@ -76,9 +80,11 @@ function mapM3uPlaylistRecentItems(
|
||||
acc.push({
|
||||
id: rawItem.id || channelUrl,
|
||||
title:
|
||||
rawItem.title?.trim() ||
|
||||
rawItem.tvg_name?.trim() ||
|
||||
rawItem.channel_id ||
|
||||
firstNonEmpty(
|
||||
rawItem.title,
|
||||
rawItem.tvg_name,
|
||||
rawItem.channel_id
|
||||
) ||
|
||||
channelUrl,
|
||||
type: 'live',
|
||||
playlist_id: playlist._id,
|
||||
@@ -87,6 +93,12 @@ function mapM3uPlaylistRecentItems(
|
||||
category_id: rawItem.category_id || 'live',
|
||||
xtream_id: channelUrl,
|
||||
poster_url: rawItem.poster_url || undefined,
|
||||
epg_lookup_key: firstNonEmpty(
|
||||
rawItem.tvg_id,
|
||||
rawItem.tvg_name,
|
||||
rawItem.title,
|
||||
rawItem.channel_id
|
||||
),
|
||||
source: 'm3u',
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,11 @@ export interface PortalActivityItem {
|
||||
category_id: string | number;
|
||||
xtream_id: string | number;
|
||||
poster_url?: string;
|
||||
/**
|
||||
* Preferred XMLTV lookup key for live EPG enrichment. For M3U channels this
|
||||
* follows the app-wide tvg.id -> tvg.name -> display name fallback chain.
|
||||
*/
|
||||
epg_lookup_key?: string;
|
||||
/**
|
||||
* Wide backdrop URL (typically 16:9), persisted when the user first views
|
||||
* the detail page. Absent for stalker and for items that have never had
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { isSelectedEpgDayToday } from './multi-epg-container.component';
|
||||
|
||||
describe('isSelectedEpgDayToday', () => {
|
||||
it('returns true only when the selected EPG day is the actual current day', () => {
|
||||
const now = new Date('2026-05-21T20:00:00.000Z');
|
||||
|
||||
expect(isSelectedEpgDayToday('20260521', now)).toBe(true);
|
||||
expect(isSelectedEpgDayToday('20260520', now)).toBe(false);
|
||||
expect(isSelectedEpgDayToday('20260522', now)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,13 @@ interface ProgramSearchResult extends EpgProgram {
|
||||
display_name?: string | null;
|
||||
}
|
||||
|
||||
export function isSelectedEpgDayToday(
|
||||
selectedDay: string,
|
||||
now: Date = new Date()
|
||||
): boolean {
|
||||
return selectedDay === format(now, 'yyyyMMdd');
|
||||
}
|
||||
|
||||
@Component({
|
||||
imports: [
|
||||
DatePipe,
|
||||
@@ -250,6 +257,9 @@ export class MultiEpgContainerComponent
|
||||
*/
|
||||
isProgramAiringNow(program: EnrichedProgram): boolean {
|
||||
const nowX = this.currentTimeLine();
|
||||
if (!isSelectedEpgDayToday(this.today())) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
nowX >= program.startPosition &&
|
||||
nowX <= program.startPosition + program.width
|
||||
|
||||
@@ -242,6 +242,7 @@ describe('DashboardDataService', () => {
|
||||
playlist_id: 'm3u-1',
|
||||
source: 'm3u',
|
||||
poster_url: 'https://example.com/logo-1.png',
|
||||
epg_lookup_key: 'tvg-1',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'https://example.com/stream-2.m3u8',
|
||||
@@ -250,6 +251,7 @@ describe('DashboardDataService', () => {
|
||||
playlist_id: 'm3u-1',
|
||||
source: 'm3u',
|
||||
poster_url: 'https://example.com/logo-2.png',
|
||||
epg_lookup_key: 'tvg-2',
|
||||
}),
|
||||
])
|
||||
);
|
||||
@@ -436,6 +438,7 @@ describe('DashboardDataService', () => {
|
||||
playlist_id: 'm3u-1',
|
||||
source: 'm3u',
|
||||
xtream_id: 'https://example.com/stream-1.m3u8',
|
||||
epg_lookup_key: 'tvg-1',
|
||||
}),
|
||||
])
|
||||
);
|
||||
@@ -696,10 +699,27 @@ describe('DashboardDataService', () => {
|
||||
const series = service
|
||||
.globalRecentVodItems()
|
||||
.find((item) => item.title === 'Shadow Bay');
|
||||
const position = service.getPlaybackPositionForItem(series!);
|
||||
const positionsMap = service.playbackPositions$();
|
||||
const failOnLinearScan = jest.fn(() => {
|
||||
throw new Error('series lookup should not scan all positions');
|
||||
});
|
||||
(
|
||||
positionsMap as unknown as {
|
||||
values: () => IterableIterator<PlaybackPositionData>;
|
||||
}
|
||||
).values =
|
||||
failOnLinearScan as unknown as () => IterableIterator<PlaybackPositionData>;
|
||||
|
||||
expect(series).toBeDefined();
|
||||
if (!series) {
|
||||
throw new Error('expected Shadow Bay recent item');
|
||||
}
|
||||
|
||||
const position = service.getPlaybackPositionForItem(series);
|
||||
expect(position?.contentType).toBe('episode');
|
||||
expect(position?.seasonNumber).toBe(3);
|
||||
expect(position?.episodeNumber).toBe(7);
|
||||
expect(failOnLinearScan).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exposes a live-only slice of global favorites so the dashboard can promote favorited channels into the Live rail', async () => {
|
||||
|
||||
@@ -65,6 +65,28 @@ function playbackPositionMapKey(
|
||||
return `${playlistId}::${contentXtreamId}::${contentType}`;
|
||||
}
|
||||
|
||||
function seriesPlaybackPositionMapKey(
|
||||
playlistId: string,
|
||||
seriesXtreamId: number
|
||||
): string {
|
||||
return `${playlistId}::${seriesXtreamId}`;
|
||||
}
|
||||
|
||||
function newestPlaybackPosition(
|
||||
current: PlaybackPositionData | null | undefined,
|
||||
candidate: PlaybackPositionData | null | undefined
|
||||
): PlaybackPositionData | null {
|
||||
if (!current) {
|
||||
return candidate ?? null;
|
||||
}
|
||||
if (!candidate) {
|
||||
return current;
|
||||
}
|
||||
return (candidate.updatedAt ?? '') > (current.updatedAt ?? '')
|
||||
? candidate
|
||||
: current;
|
||||
}
|
||||
|
||||
/** @deprecated Use {@link PortalRecentItem} from `@iptvnator/shared/interfaces` instead. */
|
||||
export type GlobalRecentItem = PortalRecentItem;
|
||||
/** @deprecated Use {@link PortalFavoriteItem} from `@iptvnator/shared/interfaces` instead. */
|
||||
@@ -239,6 +261,9 @@ export class DashboardDataService {
|
||||
private readonly playbackPositionsMap = signal<
|
||||
Map<string, PlaybackPositionData>
|
||||
>(new Map());
|
||||
private readonly playbackPositionsBySeriesMap = signal<
|
||||
Map<string, PlaybackPositionData>
|
||||
>(new Map());
|
||||
|
||||
readonly playbackPositions$ = this.playbackPositionsMap.asReadonly();
|
||||
|
||||
@@ -267,29 +292,17 @@ export class DashboardDataService {
|
||||
|
||||
// Series recent_items rows carry either the series id (the series
|
||||
// landing page writes the series itself) or, for direct-play flows,
|
||||
// the episode id. Match both shapes and prefer the most recently
|
||||
// updated episode position so the card can show resume progress and
|
||||
// a "S2 · E5" badge regardless of how the row was originally saved.
|
||||
let best: PlaybackPositionData | null = null;
|
||||
for (const position of this.playbackPositionsMap().values()) {
|
||||
if (position.contentType !== 'episode') continue;
|
||||
if (
|
||||
position.playlistId &&
|
||||
position.playlistId !== item.playlist_id
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const matchesEpisode = position.contentXtreamId === xtreamId;
|
||||
const matchesSeries = position.seriesXtreamId === xtreamId;
|
||||
if (!matchesEpisode && !matchesSeries) continue;
|
||||
if (
|
||||
!best ||
|
||||
(position.updatedAt ?? '') > (best.updatedAt ?? '')
|
||||
) {
|
||||
best = position;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
// the episode id. Match both shapes through keyed maps so card renders
|
||||
// do not scan every saved playback position.
|
||||
const episodePosition =
|
||||
this.playbackPositionsMap().get(
|
||||
playbackPositionMapKey(item.playlist_id, xtreamId, 'episode')
|
||||
) ?? null;
|
||||
const seriesPosition =
|
||||
this.playbackPositionsBySeriesMap().get(
|
||||
seriesPlaybackPositionMapKey(item.playlist_id, xtreamId)
|
||||
) ?? null;
|
||||
return newestPlaybackPosition(episodePosition, seriesPosition);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -307,10 +320,12 @@ export class DashboardDataService {
|
||||
}
|
||||
if (playlistIds.size === 0) {
|
||||
this.playbackPositionsMap.set(new Map());
|
||||
this.playbackPositionsBySeriesMap.set(new Map());
|
||||
return;
|
||||
}
|
||||
|
||||
const next = new Map<string, PlaybackPositionData>();
|
||||
const nextBySeries = new Map<string, PlaybackPositionData>();
|
||||
for (const playlistId of playlistIds) {
|
||||
try {
|
||||
const positions =
|
||||
@@ -326,6 +341,22 @@ export class DashboardDataService {
|
||||
),
|
||||
position
|
||||
);
|
||||
if (
|
||||
position.contentType === 'episode' &&
|
||||
Number.isFinite(position.seriesXtreamId)
|
||||
) {
|
||||
const seriesKey = seriesPlaybackPositionMapKey(
|
||||
playlistId,
|
||||
position.seriesXtreamId as number
|
||||
);
|
||||
nextBySeries.set(
|
||||
seriesKey,
|
||||
newestPlaybackPosition(
|
||||
nextBySeries.get(seriesKey),
|
||||
position
|
||||
) as PlaybackPositionData
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
@@ -336,7 +367,10 @@ export class DashboardDataService {
|
||||
}
|
||||
}
|
||||
|
||||
this.ngZone.run(() => this.playbackPositionsMap.set(next));
|
||||
this.ngZone.run(() => {
|
||||
this.playbackPositionsMap.set(next);
|
||||
this.playbackPositionsBySeriesMap.set(nextBySeries);
|
||||
});
|
||||
}
|
||||
|
||||
readonly stalkerGlobalFavorites = computed<DashboardFavoriteItem[]>(() => {
|
||||
@@ -1041,6 +1075,11 @@ export class DashboardDataService {
|
||||
category_id: 'live',
|
||||
xtream_id: matchedFavoriteId,
|
||||
poster_url: channel.tvg?.logo || undefined,
|
||||
epg_lookup_key:
|
||||
channel.tvg?.id?.trim() ||
|
||||
channel.tvg?.name?.trim() ||
|
||||
channel.name?.trim() ||
|
||||
undefined,
|
||||
source: 'm3u',
|
||||
});
|
||||
return acc;
|
||||
|
||||
@@ -110,7 +110,8 @@
|
||||
<span
|
||||
class="rail__channel-progress"
|
||||
[class.rail__channel-progress--idle]="
|
||||
card.nowPlayingProgress == null
|
||||
card.nowPlayingProgress === null ||
|
||||
card.nowPlayingProgress === undefined
|
||||
"
|
||||
>
|
||||
<i
|
||||
@@ -173,7 +174,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (card.watchProgress != null) {
|
||||
@if (card.watchProgress !== null && card.watchProgress !== undefined) {
|
||||
<span
|
||||
class="rail__art-progress"
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface DashboardRailCard {
|
||||
link: string[];
|
||||
state?: Record<string, unknown>;
|
||||
actions?: DashboardRailAction[];
|
||||
epgLookupKey?: string;
|
||||
|
||||
/**
|
||||
* Optional EPG enrichment shown by the 'channel' rail layout. Populated
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@
|
||||
<h1 class="hero__title">{{ item.title }}</h1>
|
||||
<p class="hero__subtitle">{{ item.subtitle }}</p>
|
||||
|
||||
@if (item.watchProgress != null) {
|
||||
@if (item.watchProgress !== null && item.watchProgress !== undefined) {
|
||||
<span class="hero__progress" aria-hidden="true">
|
||||
<span
|
||||
class="hero__progress-bar"
|
||||
|
||||
+82
@@ -1,12 +1,17 @@
|
||||
import type { EpgProgram, PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
buildLiveEpgLookupKeys,
|
||||
buildPlaybackPositionReloadKey,
|
||||
buildDashboardSourceActions,
|
||||
calcEpgProgress,
|
||||
formatEpgTimeRange,
|
||||
formatRemainingLabel,
|
||||
getLiveEpgProgramForCard,
|
||||
liveRailTitleKeyForSource,
|
||||
playbackProgressPercent,
|
||||
resolveDashboardHeroArtwork,
|
||||
} from './workspace-dashboard-rails.component';
|
||||
import type { DashboardRailCard } from './dashboard-rail.component';
|
||||
|
||||
describe('buildDashboardSourceActions', () => {
|
||||
const basePlaylist = {
|
||||
@@ -201,6 +206,83 @@ describe('EPG enrichment helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Live rail helpers', () => {
|
||||
const channelCard = (
|
||||
overrides: Partial<DashboardRailCard> = {}
|
||||
): DashboardRailCard => ({
|
||||
id: 'card-1',
|
||||
title: 'Display Channel',
|
||||
subtitle: 'M3U · Live',
|
||||
icon: 'live_tv',
|
||||
contentType: 'live',
|
||||
link: ['/workspace', 'playlists', 'm3u-1'],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('uses explicit EPG lookup keys before falling back to card titles', () => {
|
||||
expect(
|
||||
buildLiveEpgLookupKeys([
|
||||
channelCard({
|
||||
title: 'Das Erste HD',
|
||||
epgLookupKey: 'ard.de',
|
||||
}),
|
||||
channelCard({
|
||||
id: 'card-2',
|
||||
title: 'Das Erste HD',
|
||||
epgLookupKey: 'ard.de',
|
||||
}),
|
||||
channelCard({
|
||||
id: 'card-3',
|
||||
title: 'Fallback News',
|
||||
}),
|
||||
])
|
||||
).toEqual(['ard.de', 'Fallback News']);
|
||||
});
|
||||
|
||||
it('reads EPG programs by explicit lookup key instead of display title', () => {
|
||||
const program = { title: 'Tagesschau' } as EpgProgram;
|
||||
const wrongProgram = { title: 'Wrong channel' } as EpgProgram;
|
||||
const card = channelCard({
|
||||
title: 'Das Erste HD',
|
||||
epgLookupKey: 'ard.de',
|
||||
});
|
||||
|
||||
expect(
|
||||
getLiveEpgProgramForCard(
|
||||
card,
|
||||
new Map<string, EpgProgram | null>([
|
||||
['Das Erste HD', wrongProgram],
|
||||
['ard.de', program],
|
||||
])
|
||||
)
|
||||
).toBe(program);
|
||||
});
|
||||
|
||||
it('uses honest, semantically named title keys for favorite and recent live rails', () => {
|
||||
expect(liveRailTitleKeyForSource('favorites')).toBe(
|
||||
'WORKSPACE.DASHBOARD.LIVE_FAVORITES'
|
||||
);
|
||||
expect(liveRailTitleKeyForSource('recent')).toBe(
|
||||
'WORKSPACE.DASHBOARD.LIVE_RECENT'
|
||||
);
|
||||
});
|
||||
|
||||
it('builds a stable playback-position reload key from VOD and series items only', () => {
|
||||
const first = buildPlaybackPositionReloadKey([
|
||||
{ playlist_id: 'b', xtream_id: 20, type: 'series' },
|
||||
{ playlist_id: 'live', xtream_id: 'stream-url', type: 'live' },
|
||||
{ playlist_id: 'a', xtream_id: 10, type: 'movie' },
|
||||
]);
|
||||
const second = buildPlaybackPositionReloadKey([
|
||||
{ playlist_id: 'a', xtream_id: 10, type: 'movie' },
|
||||
{ playlist_id: 'b', xtream_id: 20, type: 'series' },
|
||||
]);
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(first).toBe('a::movie::10|b::series::20');
|
||||
});
|
||||
});
|
||||
|
||||
describe('playback-position helpers', () => {
|
||||
describe('playbackProgressPercent', () => {
|
||||
it('returns null when there is no position or no duration', () => {
|
||||
|
||||
+68
-30
@@ -5,6 +5,7 @@ import {
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { interval, of, startWith, switchMap } from 'rxjs';
|
||||
@@ -258,6 +259,56 @@ export function calcEpgProgress(
|
||||
return Math.max(0, Math.min(100, ratio * 100));
|
||||
}
|
||||
|
||||
function liveEpgLookupKeyForCard(card: DashboardRailCard): string {
|
||||
return card.epgLookupKey?.trim() || card.title.trim();
|
||||
}
|
||||
|
||||
export function buildLiveEpgLookupKeys(
|
||||
cards: readonly DashboardRailCard[]
|
||||
): string[] {
|
||||
const seen = new Set<string>();
|
||||
const keys: string[] = [];
|
||||
for (const card of cards) {
|
||||
const key = liveEpgLookupKeyForCard(card);
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
keys.push(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function getLiveEpgProgramForCard(
|
||||
card: DashboardRailCard,
|
||||
epgMap: ReadonlyMap<string, EpgProgram | null>
|
||||
): EpgProgram | null {
|
||||
const key = liveEpgLookupKeyForCard(card);
|
||||
const program = epgMap.get(key);
|
||||
if (program) {
|
||||
return program;
|
||||
}
|
||||
|
||||
const titleKey = card.title.trim();
|
||||
return key !== titleKey ? (epgMap.get(titleKey) ?? null) : null;
|
||||
}
|
||||
|
||||
export function liveRailTitleKeyForSource(
|
||||
source: 'favorites' | 'recent'
|
||||
): string {
|
||||
return source === 'favorites'
|
||||
? 'WORKSPACE.DASHBOARD.LIVE_FAVORITES'
|
||||
: 'WORKSPACE.DASHBOARD.LIVE_RECENT';
|
||||
}
|
||||
|
||||
export function buildPlaybackPositionReloadKey(
|
||||
items: readonly Pick<GlobalRecentItem, 'playlist_id' | 'type' | 'xtream_id'>[]
|
||||
): string {
|
||||
return items
|
||||
.filter((item) => item.type === 'movie' || item.type === 'series')
|
||||
.map((item) => `${item.playlist_id}::${item.type}::${item.xtream_id}`)
|
||||
.sort()
|
||||
.join('|');
|
||||
}
|
||||
|
||||
// ── Playback-position helpers (used by both hero + Continue Watching cards)
|
||||
// — kept as plain functions so they're easy to unit-test without mounting the
|
||||
// component or the data service.
|
||||
@@ -431,9 +482,7 @@ export class WorkspaceDashboardRailsComponent {
|
||||
// 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'
|
||||
liveRailTitleKeyForSource(this.liveRailSource())
|
||||
);
|
||||
|
||||
readonly liveRailTotalCount = computed(() =>
|
||||
@@ -442,22 +491,17 @@ export class WorkspaceDashboardRailsComponent {
|
||||
: this.data.globalRecentLiveItems().length
|
||||
);
|
||||
|
||||
// Best-effort EPG lookup keyed by the channel's display name. This works
|
||||
// for M3U sources whose XMLTV channel ids resolve through the existing
|
||||
// `resolveChannelEpgLookupKey()` (tvg-id → tvg-name → name) chain.
|
||||
// Best-effort EPG lookup keyed by the app-wide M3U XMLTV chain
|
||||
// (tvg-id -> tvg-name -> name), with the card title as a final fallback.
|
||||
// Xtream/Stalker live items often have no XMLTV side-channel and will
|
||||
// simply return null — the card renders without the program row.
|
||||
private readonly liveChannelLookupKeys = computed(() => {
|
||||
const seen = new Set<string>();
|
||||
const keys: string[] = [];
|
||||
for (const card of this.liveOnFavoritesCards()) {
|
||||
const key = card.title.trim();
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
keys.push(key);
|
||||
}
|
||||
return keys;
|
||||
});
|
||||
private readonly liveChannelLookupKeys = computed(() =>
|
||||
buildLiveEpgLookupKeys(this.liveOnFavoritesCards())
|
||||
);
|
||||
|
||||
private readonly playbackPositionReloadKey = computed(() =>
|
||||
buildPlaybackPositionReloadKey(this.data.globalRecentVodItems())
|
||||
);
|
||||
|
||||
// Re-fetch on rail change AND on a 30s heartbeat so the progress bar
|
||||
// catches the boundary between programs without a full page revisit.
|
||||
@@ -486,7 +530,7 @@ export class WorkspaceDashboardRailsComponent {
|
||||
// 30s ticks even if the program identity is unchanged.
|
||||
const now = Date.now();
|
||||
return this.liveOnFavoritesCards().map((card) => {
|
||||
const program = epgMap.get(card.title.trim()) ?? null;
|
||||
const program = getLiveEpgProgramForCard(card, epgMap);
|
||||
if (!program) {
|
||||
return card;
|
||||
}
|
||||
@@ -552,19 +596,11 @@ export class WorkspaceDashboardRailsComponent {
|
||||
void this.data.reloadXtreamRecentlyAddedItems(RAIL_ITEM_LIMIT);
|
||||
});
|
||||
|
||||
// Reload playback positions whenever the set of VOD/series recent
|
||||
// items changes (new playlists with watch history, removed playlist,
|
||||
// newly tracked content). Using just the count + the playlist ids as
|
||||
// the dependency keeps unrelated tick churn out of the IPC path.
|
||||
// Reload playback positions when the VOD/series recent set changes.
|
||||
// The primitive key keeps live-only recent churn out of the IPC path.
|
||||
effect(() => {
|
||||
const playlistKeys = new Set<string>();
|
||||
for (const item of this.data.globalRecentVodItems()) {
|
||||
playlistKeys.add(item.playlist_id);
|
||||
}
|
||||
void this.data.reloadPlaybackPositions();
|
||||
// Sort so the effect dep is stable across re-renders for the
|
||||
// same set; otherwise the computed identity flips every tick.
|
||||
void [...playlistKeys].sort().join('|');
|
||||
this.playbackPositionReloadKey();
|
||||
untracked(() => void this.data.reloadPlaybackPositions());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -634,6 +670,7 @@ export class WorkspaceDashboardRailsComponent {
|
||||
imageUrl: item.poster_url,
|
||||
icon: this.typeIcon(item.type),
|
||||
contentType: item.type,
|
||||
epgLookupKey: item.epg_lookup_key,
|
||||
link: this.data.getRecentItemLink(item),
|
||||
state: this.data.getRecentItemNavigationState(item),
|
||||
watchProgress,
|
||||
@@ -649,6 +686,7 @@ export class WorkspaceDashboardRailsComponent {
|
||||
imageUrl: item.poster_url,
|
||||
icon: this.typeIcon(item.type),
|
||||
contentType: item.type,
|
||||
epgLookupKey: item.epg_lookup_key,
|
||||
link: this.data.getGlobalFavoriteLink(item),
|
||||
state: this.data.getGlobalFavoriteNavigationState(item),
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user