mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
feat(dashboard): show watch progress on hero and Continue Watching cards
Wire the playback-position store into DashboardDataService so the hero and Continue Watching cards can show how far through each title the user is. Per-playlist bulk fetch via getAllPlaybackPositions (one IPC call each, no N+1) cached in an in-memory Map keyed by playlist + content id + content type — supports both VOD and episode keys, since the same xtream-id can map to either. Hero: - Thin 4px progress bar under the subtitle when a position is known - "1h 04m left · 38% watched" meta line below it (formatRemainingLabel handles sub-minute, minute, hour, and hour+minute remainders) - Tighter padding (16 vs 20) and smaller poster (140 vs 160) per the v0.22 mockup spec — frees ~30px of vertical space without losing legibility Continue Watching cards: - 3px progress overlay pinned to the bottom of the poster art, painted in the unified blue primary (--app-selection-color) - Renders only when watchProgress is set, so live channels and untracked M3U items remain unaffected Live channels and M3U items never have positions in the schema, so the new UI is purely additive — both surfaces degrade gracefully to the prior layout when the lookup misses. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
bb6d1427fa
commit
9bb41f59b0
9 files changed
+545
-5
No files matched your search
@@ -5,7 +5,12 @@ import { TranslateService } from '@ngx-translate/core';
|
||||
import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from '@iptvnator/m3u-state';
|
||||
import { of } from 'rxjs';
|
||||
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
|
||||
import { Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
PlaybackPositionData,
|
||||
Playlist,
|
||||
PlaylistMeta,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { PORTAL_PLAYBACK_POSITIONS } from '@iptvnator/portal/shared/util';
|
||||
import { DashboardDataService } from './dashboard-data.service';
|
||||
|
||||
describe('DashboardDataService', () => {
|
||||
@@ -112,6 +117,16 @@ describe('DashboardDataService', () => {
|
||||
),
|
||||
};
|
||||
|
||||
const playbackPositionsMock = {
|
||||
savePlaybackPosition: jest.fn().mockResolvedValue(undefined),
|
||||
getPlaybackPosition: jest.fn().mockResolvedValue(null),
|
||||
getSeriesPlaybackPositions: jest.fn().mockResolvedValue([]),
|
||||
getAllPlaybackPositions: jest
|
||||
.fn<Promise<PlaybackPositionData[]>, [string]>()
|
||||
.mockResolvedValue([]),
|
||||
clearPlaybackPosition: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'electron', {
|
||||
value: {} as Window['electron'],
|
||||
@@ -140,6 +155,9 @@ describe('DashboardDataService', () => {
|
||||
dbServiceMock.removeRecentItem.mockClear();
|
||||
storeMock.dispatch.mockClear();
|
||||
|
||||
playbackPositionsMock.getAllPlaybackPositions.mockClear();
|
||||
playbackPositionsMock.getAllPlaybackPositions.mockResolvedValue([]);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
DashboardDataService,
|
||||
@@ -158,6 +176,10 @@ describe('DashboardDataService', () => {
|
||||
defaultLang: 'en',
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_PLAYBACK_POSITIONS,
|
||||
useValue: playbackPositionsMock,
|
||||
},
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(DashboardDataService);
|
||||
@@ -488,6 +510,133 @@ describe('DashboardDataService', () => {
|
||||
).toBe(service.globalRecentItems().length);
|
||||
});
|
||||
|
||||
it('loads playback positions for every playlist that owns VOD/series recent items and exposes them by content key', async () => {
|
||||
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
|
||||
{
|
||||
id: 101,
|
||||
category_id: 18,
|
||||
title: 'Atlantic City',
|
||||
rating: '7.3',
|
||||
viewed_at: '2026-04-21T10:00:00.000Z',
|
||||
poster_url: 'https://example.com/atlantic.png',
|
||||
xtream_id: 4242,
|
||||
type: 'movie',
|
||||
playlist_id: 'xtream-A',
|
||||
playlist_name: 'Xtream A',
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
category_id: 19,
|
||||
title: 'Color Orchard S02E04',
|
||||
rating: '8.1',
|
||||
viewed_at: '2026-04-22T10:00:00.000Z',
|
||||
poster_url: 'https://example.com/orchard.png',
|
||||
xtream_id: 909,
|
||||
type: 'series',
|
||||
playlist_id: 'xtream-B',
|
||||
playlist_name: 'Xtream B',
|
||||
},
|
||||
]);
|
||||
|
||||
playlistsSignal.set([
|
||||
...playlistsSignal(),
|
||||
{
|
||||
_id: 'xtream-A',
|
||||
title: 'Xtream A',
|
||||
count: 1,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
serverUrl: 'https://a.example.com',
|
||||
},
|
||||
{
|
||||
_id: 'xtream-B',
|
||||
title: 'Xtream B',
|
||||
count: 1,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
serverUrl: 'https://b.example.com',
|
||||
},
|
||||
]);
|
||||
|
||||
playbackPositionsMock.getAllPlaybackPositions.mockImplementation(
|
||||
(playlistId: string) => {
|
||||
if (playlistId === 'xtream-A') {
|
||||
return Promise.resolve([
|
||||
{
|
||||
contentXtreamId: 4242,
|
||||
contentType: 'vod',
|
||||
positionSeconds: 3600,
|
||||
durationSeconds: 6000,
|
||||
playlistId,
|
||||
} as PlaybackPositionData,
|
||||
]);
|
||||
}
|
||||
if (playlistId === 'xtream-B') {
|
||||
return Promise.resolve([
|
||||
{
|
||||
contentXtreamId: 909,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 900,
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 4,
|
||||
positionSeconds: 720,
|
||||
durationSeconds: 1800,
|
||||
playlistId,
|
||||
} as PlaybackPositionData,
|
||||
]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
);
|
||||
|
||||
await service.reloadGlobalRecentItems();
|
||||
await service.reloadPlaybackPositions();
|
||||
|
||||
// Bulk fetched once per playlist that owns a tracked item — and ONLY
|
||||
// for the playlists that own VOD/series recent items. The default M3U
|
||||
// playlist (live channel) must not be queried.
|
||||
const queriedPlaylists =
|
||||
playbackPositionsMock.getAllPlaybackPositions.mock.calls.map(
|
||||
(call) => call[0]
|
||||
);
|
||||
expect(new Set(queriedPlaylists)).toEqual(
|
||||
new Set(['xtream-A', 'xtream-B'])
|
||||
);
|
||||
|
||||
const vodItems = service.globalRecentVodItems();
|
||||
const movie = vodItems.find((item) => item.title === 'Atlantic City');
|
||||
const series = vodItems.find(
|
||||
(item) => item.title === 'Color Orchard S02E04'
|
||||
);
|
||||
|
||||
// Movie: vod position with 60% watched.
|
||||
const moviePos = service.getPlaybackPositionForItem(movie!);
|
||||
expect(moviePos?.positionSeconds).toBe(3600);
|
||||
expect(moviePos?.contentType).toBe('vod');
|
||||
|
||||
// Series: episode position keyed by the recent item's xtream_id (the
|
||||
// episode id, not the series id).
|
||||
const seriesPos = service.getPlaybackPositionForItem(series!);
|
||||
expect(seriesPos?.contentType).toBe('episode');
|
||||
expect(seriesPos?.positionSeconds).toBe(720);
|
||||
});
|
||||
|
||||
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
|
||||
// null — and `reloadPlaybackPositions` should skip the IPC entirely.
|
||||
const liveItem = service
|
||||
.globalRecentItems()
|
||||
.find((item) => item.type === 'live');
|
||||
expect(liveItem).toBeDefined();
|
||||
expect(service.getPlaybackPositionForItem(liveItem!)).toBeNull();
|
||||
|
||||
await service.reloadPlaybackPositions();
|
||||
expect(
|
||||
playbackPositionsMock.getAllPlaybackPositions
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prioritizes recently used Xtream sources over newer imported M3U sources', async () => {
|
||||
playlistsSignal.set([
|
||||
{
|
||||
|
||||
@@ -47,11 +47,24 @@ import {
|
||||
buildXtreamNavigationTarget,
|
||||
getGlobalFavoriteNavigation,
|
||||
getRecentItemNavigation,
|
||||
PORTAL_PLAYBACK_POSITIONS,
|
||||
WorkspaceNavigationTarget,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import type { PlaybackPositionData } from '@iptvnator/shared/interfaces';
|
||||
|
||||
export type DashboardContentKind = 'all' | 'channels' | 'vod' | 'series';
|
||||
|
||||
// Compound key for looking up a playback position by recent item — a single
|
||||
// playlist can contain the same xtream-id for a VOD and an episode (rare,
|
||||
// but the schema allows it), so contentType is part of the key.
|
||||
function playbackPositionMapKey(
|
||||
playlistId: string,
|
||||
contentXtreamId: number,
|
||||
contentType: 'vod' | 'episode'
|
||||
): string {
|
||||
return `${playlistId}::${contentXtreamId}::${contentType}`;
|
||||
}
|
||||
|
||||
/** @deprecated Use {@link PortalRecentItem} from `@iptvnator/shared/interfaces` instead. */
|
||||
export type GlobalRecentItem = PortalRecentItem;
|
||||
/** @deprecated Use {@link PortalFavoriteItem} from `@iptvnator/shared/interfaces` instead. */
|
||||
@@ -67,6 +80,7 @@ export class DashboardDataService {
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly ngZone = inject(NgZone);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
|
||||
private favoritesAutoRefreshEnabled = false;
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
@@ -209,6 +223,87 @@ export class DashboardDataService {
|
||||
this.globalRecentItems().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"
|
||||
// and a progress bar. Live channels never have positions; M3U content
|
||||
// doesn't either. Map starts empty and degrades cleanly on missing data.
|
||||
private readonly playbackPositionsMap = signal<
|
||||
Map<string, PlaybackPositionData>
|
||||
>(new Map());
|
||||
|
||||
readonly playbackPositions$ = this.playbackPositionsMap.asReadonly();
|
||||
|
||||
getPlaybackPositionForItem(
|
||||
item: PortalActivityItem
|
||||
): PlaybackPositionData | null {
|
||||
if (item.type !== 'movie' && item.type !== 'series') {
|
||||
return null;
|
||||
}
|
||||
const positionType: 'vod' | 'episode' =
|
||||
item.type === 'series' ? 'episode' : 'vod';
|
||||
const xtreamId =
|
||||
typeof item.xtream_id === 'number'
|
||||
? item.xtream_id
|
||||
: Number(item.xtream_id);
|
||||
if (!Number.isFinite(xtreamId)) {
|
||||
return null;
|
||||
}
|
||||
const key = playbackPositionMapKey(
|
||||
item.playlist_id,
|
||||
xtreamId,
|
||||
positionType
|
||||
);
|
||||
return this.playbackPositionsMap().get(key) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the in-memory positions map for every playlist that owns at
|
||||
* least one VOD/series recent item. Per-playlist bulk fetch is one IPC
|
||||
* round-trip each (vs N+1 per content item), so this stays cheap even
|
||||
* on heavy libraries.
|
||||
*/
|
||||
async reloadPlaybackPositions(): Promise<void> {
|
||||
const playlistIds = new Set<string>();
|
||||
for (const item of this.globalRecentItems()) {
|
||||
if (item.type === 'movie' || item.type === 'series') {
|
||||
playlistIds.add(item.playlist_id);
|
||||
}
|
||||
}
|
||||
if (playlistIds.size === 0) {
|
||||
this.playbackPositionsMap.set(new Map());
|
||||
return;
|
||||
}
|
||||
|
||||
const next = new Map<string, PlaybackPositionData>();
|
||||
for (const playlistId of playlistIds) {
|
||||
try {
|
||||
const positions =
|
||||
await this.playbackPositions.getAllPlaybackPositions(
|
||||
playlistId
|
||||
);
|
||||
for (const position of positions) {
|
||||
next.set(
|
||||
playbackPositionMapKey(
|
||||
playlistId,
|
||||
position.contentXtreamId,
|
||||
position.contentType
|
||||
),
|
||||
position
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[DashboardData] Failed to load playback positions for playlist',
|
||||
playlistId,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.ngZone.run(() => this.playbackPositionsMap.set(next));
|
||||
}
|
||||
|
||||
readonly stalkerGlobalFavorites = computed<DashboardFavoriteItem[]>(() => {
|
||||
this.languageTick();
|
||||
return buildStalkerFavoriteItems(
|
||||
|
||||
Reference in new issue
Block a user