diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index cc01eef00..19f31d108 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -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, [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([ { diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index fe351d3ab..a0dea87a5 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -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 + >(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 { + const playlistIds = new Set(); + 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(); + 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(() => { this.languageTick(); return buildStalkerFavoriteItems( diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html index a64d7d1cd..4a9c668c0 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html @@ -172,6 +172,17 @@ {{ card.icon }} } + + @if (card.watchProgress != null) { + + }
diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss index 3e1e31bb5..efa43f327 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss @@ -249,6 +249,28 @@ } } +// Thin "watched-up-to-here" bar pinned to the bottom of the poster art. +// Mirrors the Netflix / Plex resume-card pattern: the bar is the user's +// quick "did I finish this?" cue without needing to read the metadata +// strip below the card. +.rail__art-progress { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 3px; + background: rgba(0, 0, 0, 0.55); + z-index: 2; + pointer-events: none; + + i { + display: block; + height: 100%; + background: var(--app-selection-color, #78adff); + transition: width 0.4s ease; + } +} + .rail__art-bg { position: absolute; inset: 0; diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts index 8fa70fdf0..a3d221740 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts @@ -46,6 +46,14 @@ export interface DashboardRailCard { nowPlayingTimeRange?: string | null; /** 0–100, % through the current program. */ nowPlayingProgress?: number | null; + + /** + * 0–100 watched, attached for movies/series with a resume position so + * the cover layout can render a thin "watched up to here" bar at the + * bottom of the poster. Live cards and content without a tracked + * position leave this unset. + */ + watchProgress?: number | null; } /** diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html index de93401f6..515e3171b 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html @@ -76,6 +76,24 @@

{{ item.title }}

{{ item.subtitle }}

+ @if (item.watchProgress != null) { + + + @if (item.remainingLabel) { + {{ item.remainingLabel }} + · + } + {{ item.watchProgress }}% watched + + } + play_arrow {{ diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss index 7ba9317e0..adc359c32 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss @@ -40,8 +40,8 @@ position: relative; display: grid; grid-template-columns: minmax(0, var(--hero-poster-width)) minmax(0, 1fr); - gap: 24px; - padding: 20px; + gap: 20px; + padding: 16px; text-decoration: none; color: inherit; border-radius: 18px; @@ -56,7 +56,10 @@ transform 0.2s ease, box-shadow 0.2s ease; - --hero-poster-width: 160px; + // Tighter than v0.21 (was 160px). Matches the v0.22 mockup's 150px + // poster intent, freeing space for the progress + remaining label + // without growing total hero height. + --hero-poster-width: 140px; &:hover { transform: translateY(-2px); @@ -292,8 +295,58 @@ } } +// Watch-progress visualisation. Appears between the subtitle and the CTA +// only when a resume position is known — VOD/series only (M3U and live +// channels have no playback positions in the schema). +.hero__progress { + display: block; + position: relative; + margin-top: 12px; + height: 4px; + width: min(320px, 70%); + border-radius: 999px; + background: color-mix( + in srgb, + var(--app-on-surface, #e6e1e5) 14%, + transparent + ); + overflow: hidden; + + .hero--with-image & { + background: rgba(255, 255, 255, 0.18); + } +} + +.hero__progress-bar { + display: block; + height: 100%; + background: var(--app-selection-color, #78adff); + border-radius: 999px; + transition: width 0.4s ease; +} + +.hero__progress-meta { + margin-top: 6px; + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 0.74rem; + font-weight: 500; + color: var(--app-body-color, #8b93a8); + letter-spacing: 0.005em; + + .hero--with-image & { + color: rgba(255, 255, 255, 0.78); + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.45); + } +} + +.hero__progress-meta-sep { + opacity: 0.5; +} + .hero__cta { - margin-top: 16px; + margin-top: 14px; align-self: flex-start; display: inline-flex; align-items: center; diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts index 0f44ce234..a604f105a 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts @@ -3,6 +3,8 @@ import { buildDashboardSourceActions, calcEpgProgress, formatEpgTimeRange, + formatRemainingLabel, + playbackProgressPercent, resolveDashboardHeroArtwork, } from './workspace-dashboard-rails.component'; @@ -198,3 +200,107 @@ describe('EPG enrichment helpers', () => { }); }); }); + +describe('playback-position helpers', () => { + describe('playbackProgressPercent', () => { + it('returns null when there is no position or no duration', () => { + expect(playbackProgressPercent(null)).toBeNull(); + expect( + playbackProgressPercent({ positionSeconds: 300 }) + ).toBeNull(); + expect( + playbackProgressPercent({ + positionSeconds: 300, + durationSeconds: 0, + }) + ).toBeNull(); + }); + + it('returns the integer percent watched, clamped to [0, 100]', () => { + expect( + playbackProgressPercent({ + positionSeconds: 0, + durationSeconds: 6000, + }) + ).toBe(0); + expect( + playbackProgressPercent({ + positionSeconds: 3000, + durationSeconds: 6000, + }) + ).toBe(50); + expect( + playbackProgressPercent({ + positionSeconds: 6000, + durationSeconds: 6000, + }) + ).toBe(100); + // Past-end resume is clamped, not negative. + expect( + playbackProgressPercent({ + positionSeconds: 7000, + durationSeconds: 6000, + }) + ).toBe(100); + // Floor rounding — 99.97% stays at 99% to avoid the "watched + // completely" optical illusion when 1 second remains. + expect( + playbackProgressPercent({ + positionSeconds: 5998.5, + durationSeconds: 6000, + }) + ).toBe(99); + }); + }); + + describe('formatRemainingLabel', () => { + it('returns null without a usable position/duration', () => { + expect(formatRemainingLabel(null)).toBeNull(); + expect( + formatRemainingLabel({ positionSeconds: 100 }) + ).toBeNull(); + expect( + formatRemainingLabel({ + positionSeconds: 100, + durationSeconds: -1, + }) + ).toBeNull(); + }); + + it('formats sub-minute, minute, and hour-spanning remainders', () => { + expect( + formatRemainingLabel({ + positionSeconds: 5970, + durationSeconds: 6000, + }) + ).toBe('30s left'); + expect( + formatRemainingLabel({ + positionSeconds: 0, + durationSeconds: 1800, + }) + ).toBe('30m left'); + expect( + formatRemainingLabel({ + positionSeconds: 0, + durationSeconds: 3600, + }) + ).toBe('1h left'); + expect( + formatRemainingLabel({ + positionSeconds: 600, + durationSeconds: 6840, + }) + ).toBe('1h 44m left'); + }); + + it('clamps below zero — a completed program reads as "0s left" not negative', () => { + expect( + formatRemainingLabel({ + positionSeconds: 7000, + durationSeconds: 6000, + }) + ).toBe('0s left'); + }); + }); +}); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index 82e61cc86..67baa9f34 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -73,6 +73,10 @@ interface DashboardHeroModel { readonly state?: Record; readonly subtitle: string; readonly title: string; + /** 0–100 watched, when a resume position is known. */ + readonly watchProgress?: number | null; + /** "1h 04m left" — pre-formatted for direct rendering. */ + readonly remainingLabel?: string | null; } export type DashboardHeroBackdropSource = 'backdrop' | 'poster' | 'fallback'; @@ -254,6 +258,58 @@ export function calcEpgProgress( return Math.max(0, Math.min(100, ratio * 100)); } +// ── 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. + +export function playbackProgressPercent( + position: { positionSeconds: number; durationSeconds?: number } | null +): number | null { + if ( + !position || + position.durationSeconds == null || + position.durationSeconds <= 0 + ) { + return null; + } + const ratio = position.positionSeconds / position.durationSeconds; + if (!Number.isFinite(ratio)) { + return null; + } + // Integer percent — keeps "92% watched" out of "92.4% watched" territory, + // and matches the resolution of a 3px-tall progress bar on a 280px card. + return Math.max(0, Math.min(100, Math.floor(ratio * 100))); +} + +export function formatRemainingLabel( + position: { positionSeconds: number; durationSeconds?: number } | null +): string | null { + if ( + !position || + position.durationSeconds == null || + position.durationSeconds <= 0 + ) { + return null; + } + const remaining = Math.max( + 0, + Math.round(position.durationSeconds - position.positionSeconds) + ); + if (remaining < 60) { + return `${remaining}s left`; + } + const totalMinutes = Math.round(remaining / 60); + if (totalMinutes < 60) { + return `${totalMinutes}m left`; + } + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + if (minutes === 0) { + return `${hours}h left`; + } + return `${hours}h ${minutes.toString().padStart(2, '0')}m left`; +} + function isXtreamAccountPlaylist( playlist: PlaylistMeta ): playlist is PlaylistMeta & { @@ -321,6 +377,8 @@ export class WorkspaceDashboardRailsComponent { this.failedHeroImages() ); + const position = this.data.getPlaybackPositionForItem(item); + return { ...artwork, contentType: item.type, @@ -329,6 +387,8 @@ export class WorkspaceDashboardRailsComponent { state: this.data.getRecentItemNavigationState(item), subtitle: this.buildHeroSubtitle(item), title: item.title, + watchProgress: playbackProgressPercent(position), + remainingLabel: formatRemainingLabel(position), }; }); @@ -459,6 +519,21 @@ 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. + effect(() => { + const playlistKeys = new Set(); + 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('|'); + }); } onAddPlaylist(type?: WorkspacePlaylistType): void { @@ -506,6 +581,8 @@ export class WorkspaceDashboardRailsComponent { } private toRecentCard(item: GlobalRecentItem): DashboardRailCard { + const position = this.data.getPlaybackPositionForItem(item); + const watchProgress = playbackProgressPercent(position); return { id: `recent-${item.id}-${item.playlist_id}-${item.viewed_at}`, title: item.title, @@ -515,6 +592,7 @@ export class WorkspaceDashboardRailsComponent { contentType: item.type, link: this.data.getRecentItemLink(item), state: this.data.getRecentItemNavigationState(item), + watchProgress, }; }