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

@@ -17,9 +17,22 @@ class MockXtreamCollectionDetailComponent {
readonly closeRequested = output<void>();
}
@Component({
selector: 'app-stalker-collection-detail',
template: '',
})
class MockStalkerCollectionDetailComponent {
readonly item = input<UnifiedCollectionItem | null>(null);
readonly seriesResume = input<SeriesResumeTarget | null>(null);
readonly closeRequested = output<void>();
}
jest.unstable_mockModule('@iptvnator/portal/xtream/feature', () => ({
XtreamCollectionDetailComponent: MockXtreamCollectionDetailComponent,
}));
jest.unstable_mockModule('@iptvnator/portal/stalker/feature', () => ({
StalkerCollectionDetailComponent: MockStalkerCollectionDetailComponent,
}));
@Component({
imports: [GlobalCollectionDetailHostComponent],
@@ -123,6 +136,38 @@ describe('GlobalCollectionDetailHostComponent', () => {
expect(instance.seriesResume()).toEqual(seriesResume);
});
it('creates the Stalker detail with the series resume target attached', async () => {
// A Stalker embedded-VOD show routes as a movie; the resume target
// still has to reach the detail so the series view can consume it.
const seriesResume = {
seriesXtreamId: 17672,
contentXtreamId: 1879880442,
seasonNumber: 2,
episodeNumber: 6,
};
fixture.componentInstance.seriesResume.set(seriesResume);
fixture.componentInstance.item.set({
uid: 'stalker::stalker-1::17672',
name: 'Old School',
contentType: 'movie',
sourceType: 'stalker',
playlistId: 'stalker-1',
playlistName: 'Portal',
stalkerId: '17672',
categoryId: '7',
});
await stabilize();
const detail = fixture.debugElement.query(
By.directive(MockStalkerCollectionDetailComponent)
);
expect(detail).not.toBeNull();
const instance =
detail?.componentInstance as MockStalkerCollectionDetailComponent;
expect(instance.item()?.stalkerId).toBe('17672');
expect(instance.seriesResume()).toEqual(seriesResume);
});
it('re-emits close requests and clears the detail with the item', async () => {
fixture.componentInstance.item.set(xtreamSeriesItem);
await stabilize();
@@ -113,9 +113,9 @@ export class GlobalCollectionDetailHostComponent implements OnDestroy {
environmentInjector: this.environmentInjector,
});
componentRef.setInput('item', item);
if (item.sourceType === 'xtream') {
componentRef.setInput('seriesResume', seriesResume);
}
// Both portal details accept the one-shot resume handoff (Xtream
// series, Stalker embedded-VOD / lazy is_series shows).
componentRef.setInput('seriesResume', seriesResume);
this.subscribeToClose(componentRef.instance);
}
@@ -196,6 +196,7 @@ export class GlobalCollectionDetailHostComponent implements OnDestroy {
} @else if (item.sourceType === 'stalker') {
<app-global-collection-detail-host
[item]="item"
[seriesResume]="seriesResume"
(closeRequested)="close()"
/>
}