feat(dashboard): say where you left off instead of which provider it came from (#1646)

Every dashboard title carried a "Xtream · Series" / "Stalker · Movie" subtitle. Provider kind and content kind are the app's own taxonomy, not a property of the title, and they are identical on every card in a rail — so the one line that could tell two cards apart said nothing.

The hero now shows the source name alone, through `playlistDisplayLabel` (a stored playlist name is routinely the pasted URL with credentials, or a MAC). Continue Watching cards show what actually varies: the "S1·E5" chip plus "12 min left". Favorites keep the title alone, Recently Added keeps the source name, and a meta row with nothing in it is no longer rendered.

Stalker shows filed under Movies had no badge, no progress and no resume. An embedded-VOD row announces its episodes through a `series[]` array and carries no `is_series` flag, so `extractStalkerItemType` reports `movie` on purpose — the item must keep routing to the VOD catalog — while its progress is a set of episode positions keyed by the parent id, which the dashboard was looking up as a single `vod` row and never finding.

Split the two questions: `PortalActivityItem.watch_kind` records the progress model when it differs from the routing type, and every reader that has to choose goes through `resolvePortalActivityWatchKind` instead of `type`. That makes the resume handoff reachable for Stalker, so wire it through `STALKER_SERIES_RESUME_TARGET`: consumed once after the series positions are read, hydrating a lazy Ministra season first with a bounded two-attempt retry, and playing nothing at all when the position read failed rather than restarting the episode from zero.

Also fixes the global-recent route template, which bound `[seriesResume]` only on its Xtream branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 authored and GitHub committed 2026-09-20 22:19:47 +02:00
1 parent 954c8ad65e
commit 4e575a810e
26 files changed
+1514 -160

No files matched your search

@@ -193,32 +193,45 @@
<span class="rail__card-title">{{
cardTitle(card)
}}</span>
<span class="rail__card-meta-row">
@if (card.episodeBadge) {
<span class="rail__card-episode">{{
card.episodeBadge
}}</span>
}
@if (card.subtitle) {
<span class="rail__card-subtitle">{{
card.subtitle
}}</span>
}
@if (card.expiryBadge; as expiry) {
<span
class="rail__card-expiry"
[class.rail__card-expiry--expired]="
expiry.kind === 'expired'
"
[attr.data-test-id]="
testId()
? testId() + '-card-expiry'
: null
"
>{{ expiry.label }}</span
>
}
</span>
@if (
card.episodeBadge ||
card.remainingLabel ||
card.subtitle ||
card.expiryBadge
) {
<span class="rail__card-meta-row">
@if (card.episodeBadge) {
<span class="rail__card-episode">{{
card.episodeBadge
}}</span>
}
@if (card.remainingLabel; as remaining) {
<span class="rail__card-remaining">{{
remaining.key
| translate: remaining.params
}}</span>
}
@if (card.subtitle) {
<span class="rail__card-subtitle">{{
card.subtitle
}}</span>
}
@if (card.expiryBadge; as expiry) {
<span
class="rail__card-expiry"
[class.rail__card-expiry--expired]="
expiry.kind === 'expired'
"
[attr.data-test-id]="
testId()
? testId() + '-card-expiry'
: null
"
>{{ expiry.label }}</span
>
}
</span>
}
</div>
</a>
@@ -404,6 +404,21 @@
color: var(--app-expiry-expired-text, #b3261e);
}
// "12 min left" beside the episode chip: same tone as the subtitle, but it
// keeps its digits whole instead of truncating when the row runs short.
.rail__card-remaining {
flex: 0 1 auto;
min-width: 0;
font-size: 0.72rem;
font-weight: 500;
line-height: 1.4;
color: var(--app-muted-color, #8891a4);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-variant-numeric: tabular-nums;
}
.rail__card-subtitle {
flex: 1 1 auto;
min-width: 0;
@@ -133,5 +133,34 @@ describe('DashboardRailComponent', () => {
chips[1].classList.contains('rail__card-expiry--expired')
).toBe(true);
});
it('renders the episode chip and remaining time, and drops an empty meta row', async () => {
const element = await renderCards([
card({
id: 'show',
title: 'Fake',
contentType: 'movie',
episodeBadge: 'S1·E5',
remainingLabel: {
key: 'WORKSPACE.DASHBOARD.REMAINING_MINUTES',
params: { minutes: 12 },
},
}),
card({ id: 'bare', title: 'Bare', contentType: 'movie' }),
]);
const cards = element.querySelectorAll('.rail__card');
expect(cards).toHaveLength(2);
expect(
cards[0].querySelector('.rail__card-episode')?.textContent
).toBe('S1·E5');
expect(
cards[0]
.querySelector('.rail__card-remaining')
?.textContent?.trim()
).toBe('WORKSPACE.DASHBOARD.REMAINING_MINUTES');
expect(cards[0].querySelector('.rail__card-subtitle')).toBeNull();
expect(cards[1].querySelector('.rail__card-meta-row')).toBeNull();
});
});
});
@@ -18,6 +18,7 @@ import { RouterLink } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { SettingsStore } from '@iptvnator/services';
import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils';
import type { DashboardRemainingLabel } from './dashboard-playback.utils';
export interface DashboardRailAction {
id: string;
@@ -73,6 +74,13 @@ export interface DashboardRailCard {
*/
episodeBadge?: string | null;
/**
* Localised "12 min left" for Continue Watching cards with a known
* duration. Sits beside the episode chip in the meta row; absent when
* the position carries no duration.
*/
remainingLabel?: DashboardRemainingLabel | null;
/**
* Subscription-expiry warning for portal source cards: a quiet amber
* chip when the account expires soon, an error-toned one once it has.
@@ -13,6 +13,9 @@ import {
isStalkerAccountPlaylist,
isXtreamAccountPlaylist,
normalizeDashboardRailsSettings,
type PlaybackPositionData,
playlistDisplayLabel,
resolvePortalActivityWatchKind,
} from '@iptvnator/shared/interfaces';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
@@ -185,18 +188,7 @@ export class WorkspaceDashboardRailsComponent {
item.type === 'live'
? this.liveEpg.detailsFor(this.heroLiveCard())
: null;
const episodeBadge =
item.type === 'series' &&
position?.seasonNumber != null &&
position?.episodeNumber != null
? this.translate.instant(
'WORKSPACE.DASHBOARD.SEASON_EPISODE_BADGE',
{
season: position.seasonNumber,
episode: position.episodeNumber,
}
)
: null;
const episodeBadge = this.buildEpisodeBadge(item, position);
return {
...artwork,
@@ -593,34 +585,49 @@ export class WorkspaceDashboardRailsComponent {
);
}
/**
* Only the source name under the hero title. Provider kind (Xtream /
* Stalker / M3U) and content kind (movie / series) are the app's own
* taxonomy, not a property of the title, and the badges row already
* says "S1·E5"; a stored playlist name can be a pasted URL with
* credentials or a MAC, so it goes through `playlistDisplayLabel`.
*/
private buildHeroSubtitle(item: GlobalRecentItem): string {
const parts = [
return playlistDisplayLabel(
item.playlist_name,
this.data.getRecentItemProviderLabel(item),
this.data.getRecentItemTypeLabel(item),
].filter((value): value is string => Boolean(value));
return parts.join(' · ');
this.data.getRecentItemProviderLabel(item)
);
}
/**
* "S1·E5" for an item whose progress is tracked per episode. Keyed on
* the WATCH kind: a Stalker embedded-VOD / lazy `is_series` show routes
* as a movie but still names the episode it is on.
*/
private buildEpisodeBadge(
item: GlobalRecentItem,
position: PlaybackPositionData | null
): string | null {
return resolvePortalActivityWatchKind(item) === 'series' &&
position?.seasonNumber != null &&
position?.episodeNumber != null
? this.translate.instant(
'WORKSPACE.DASHBOARD.SEASON_EPISODE_BADGE',
{
season: position.seasonNumber,
episode: position.episodeNumber,
}
)
: null;
}
private toRecentCard(item: GlobalRecentItem): DashboardRailCard {
const position = this.data.getPlaybackPositionForItem(item);
const watchProgress = playbackProgressPercent(position);
const episodeBadge =
item.type === 'series' &&
position?.seasonNumber != null &&
position?.episodeNumber != null
? this.translate.instant(
'WORKSPACE.DASHBOARD.SEASON_EPISODE_BADGE',
{
season: position.seasonNumber,
episode: position.episodeNumber,
}
)
: null;
const episodeBadge = this.buildEpisodeBadge(item, position);
return {
id: this.recentCardId(item),
title: item.title,
subtitle: `${this.data.getRecentItemProviderLabel(item)} · ${this.data.getRecentItemTypeLabel(item)}`,
imageUrl: item.poster_url,
icon: this.typeIcon(item.type),
contentType: item.type,
@@ -633,6 +640,9 @@ export class WorkspaceDashboardRailsComponent {
state: this.data.getRecentItemDetailNavigationState(item),
watchProgress,
episodeBadge,
// What still separates one card from the next: where in the
// show, and how much is left — not which provider it came from.
remainingLabel: formatRemainingLabel(position),
...(item.type === 'movie' || item.type === 'series'
? {
actions: buildDashboardContinueWatchingActions({
@@ -657,7 +667,6 @@ export class WorkspaceDashboardRailsComponent {
return {
id: `fav-${item.id}-${item.playlist_id}-${item.added_at}`,
title: item.title,
subtitle: `${this.data.getFavoriteItemProviderLabel(item)} · ${this.data.getFavoriteItemTypeLabel(item)}`,
imageUrl: item.poster_url,
icon: this.typeIcon(item.type),
contentType: item.type,
@@ -671,14 +680,11 @@ export class WorkspaceDashboardRailsComponent {
private toRecentlyAddedCard(
item: DashboardRecentlyAddedItem
): DashboardRailCard {
const typeLabel = this.data.getRecentlyAddedItemTypeLabel(item);
const subtitleParts = [item.playlist_name, typeLabel].filter(
(value): value is string => Boolean(value)
);
return {
id: `added-${item.id}-${item.playlist_id}-${item.added_at}`,
title: item.title,
subtitle: subtitleParts.join(' · '),
// "Where was it added" is the one fact that varies per card here.
subtitle: playlistDisplayLabel(item.playlist_name),
imageUrl: item.poster_url,
icon: this.typeIcon(item.type),
contentType: item.type,