mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -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
@@ -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