mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -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(
|
||||
|
||||
@@ -172,6 +172,17 @@
|
||||
<mat-icon>{{ card.icon }}</mat-icon>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (card.watchProgress != null) {
|
||||
<span
|
||||
class="rail__art-progress"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<i
|
||||
[style.width.%]="card.watchProgress"
|
||||
></i>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="rail__meta">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+18
@@ -76,6 +76,24 @@
|
||||
<h1 class="hero__title">{{ item.title }}</h1>
|
||||
<p class="hero__subtitle">{{ item.subtitle }}</p>
|
||||
|
||||
@if (item.watchProgress != null) {
|
||||
<span class="hero__progress" aria-hidden="true">
|
||||
<span
|
||||
class="hero__progress-bar"
|
||||
[style.width.%]="item.watchProgress"
|
||||
></span>
|
||||
</span>
|
||||
<span class="hero__progress-meta">
|
||||
@if (item.remainingLabel) {
|
||||
<span>{{ item.remainingLabel }}</span>
|
||||
<span class="hero__progress-meta-sep"
|
||||
>·</span
|
||||
>
|
||||
}
|
||||
<span>{{ item.watchProgress }}% watched</span>
|
||||
</span>
|
||||
}
|
||||
|
||||
<span class="hero__cta">
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
<span>{{
|
||||
|
||||
+57
-4
@@ -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;
|
||||
|
||||
+106
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -73,6 +73,10 @@ interface DashboardHeroModel {
|
||||
readonly state?: Record<string, unknown>;
|
||||
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<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('|');
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user