mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(xtream): resume the latest series episode (#1187)
Dashboard Continue Watching now carries the exact saved season/episode into Xtream series details and starts it at the persisted offset. Successful external MPV/VLC launches persist the launched episode and retarget the series CTA to "Play episode N". Recent-history rows keyed by an episode id resolve their parent series before navigation. Includes maintainer follow-ups: no zero-offset resume on failed position loads, seriesXtreamId-gated resume targets for legacy rows, and patch coverage raised from 76.7% to 93.9%. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d61fd5db19
commit
643dee1be3
45 files changed
+1735
-88
No files matched your search
@@ -646,8 +646,10 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
|
||||
|
||||
- Xtream and Stalker detail pages use the shared `PortalDetailShellComponent` (`libs/ui/components/src/lib/portal-detail-shell/`) with two states: **Browse** (hero with poster/metadata/actions, episodes below) and **Watch** (hero collapses with a ~300ms morph, the inline player takes the full content width, metadata moves to an About block below the episodes)
|
||||
- Watch state derives from `inlinePlayback() !== null` only; external MPV/VLC playback keeps the browse layout. Esc and "Close player" exit to browse without navigation; the now-playing back arrow is route-level back (straight to the list via the host's `goBack()`)
|
||||
- A successful external MPV/VLC episode launch immediately persists the selected episode as the latest playback-position entry and retargets the series CTA to `Play episode N`; real player telemetry overwrites that marker when available, so episode identity is reliable while exact external timestamps remain best-effort.
|
||||
- Hosts pass hero chips/meta/actions as `*appDetailTags`/`*appDetailMeta`/`*appDetailActions` templates; the shell stamps them into both the hero and the About block
|
||||
- Seasons are tabs (`SeasonTabsComponent`, dropdown beyond 6 seasons) with auto-selection (playing episode's season → resume season → first) that fires the same `seasonSelected` lazy-load/enrichment hooks as manual clicks; grid/list episode view toggle persists to localStorage; season descriptions come from `get_series_info` (Xtream) or TMDB (Stalker)
|
||||
- Dashboard hero/Continue Watching clicks for an Xtream series carry a one-shot resume target through the global-recent inline-detail handoff; after series metadata and playback positions load, the exact saved episode starts at its stored position. A failed positions load leaves the target unconsumed and the handoff detail-only, so a transient storage error never starts the episode from the beginning. Ordinary global-recent grid clicks remain detail-only.
|
||||
- See `docs/architecture/embedded-inline-playback.md` ("Two-State Detail Layout")
|
||||
|
||||
**Radio Player**:
|
||||
|
||||
@@ -39,6 +39,7 @@ export default {
|
||||
tslib: 'tslib/tslib.es6.js',
|
||||
'^iptv-playlist-parser$':
|
||||
'<rootDir>/src/test-stubs/iptv-playlist-parser.mjs',
|
||||
'^video.js$': '<rootDir>/src/test-stubs/video-js.js',
|
||||
'^rxjs': '<rootDir>/../../node_modules/rxjs/dist/bundles/rxjs.umd.js',
|
||||
'^uuid$': '<rootDir>/../../node_modules/uuid/wrapper.mjs',
|
||||
},
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import {
|
||||
SeriesResumeTarget,
|
||||
UnifiedCollectionItem,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { GlobalCollectionDetailHostComponent } from './global-collection-route.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-xtream-collection-detail',
|
||||
template: '',
|
||||
})
|
||||
class MockXtreamCollectionDetailComponent {
|
||||
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,
|
||||
}));
|
||||
|
||||
@Component({
|
||||
imports: [GlobalCollectionDetailHostComponent],
|
||||
template: `
|
||||
<app-global-collection-detail-host
|
||||
[item]="item()"
|
||||
[seriesResume]="seriesResume()"
|
||||
(closeRequested)="closeCount = closeCount + 1"
|
||||
/>
|
||||
`,
|
||||
})
|
||||
class WrapperComponent {
|
||||
readonly item = signal<UnifiedCollectionItem | null>(null);
|
||||
readonly seriesResume = signal<SeriesResumeTarget | null>(null);
|
||||
closeCount = 0;
|
||||
}
|
||||
|
||||
const xtreamSeriesItem: UnifiedCollectionItem = {
|
||||
uid: 'xtream::xtream-1::series:103',
|
||||
name: 'Resume Series',
|
||||
contentType: 'series',
|
||||
sourceType: 'xtream',
|
||||
playlistId: 'xtream-1',
|
||||
playlistName: 'Xtream One',
|
||||
xtreamId: 103,
|
||||
categoryId: 3,
|
||||
};
|
||||
|
||||
describe('GlobalCollectionDetailHostComponent', () => {
|
||||
let fixture: ComponentFixture<WrapperComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [WrapperComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(WrapperComponent);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture?.destroy();
|
||||
});
|
||||
|
||||
function queryDetail() {
|
||||
return fixture.debugElement.query(
|
||||
By.directive(MockXtreamCollectionDetailComponent)
|
||||
);
|
||||
}
|
||||
|
||||
async function stabilize(): Promise<void> {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
it('mocks the xtream feature barrel', async () => {
|
||||
const mod = await import('@iptvnator/portal/xtream/feature');
|
||||
expect(mod.XtreamCollectionDetailComponent).toBe(
|
||||
MockXtreamCollectionDetailComponent as never
|
||||
);
|
||||
});
|
||||
|
||||
it('renders nothing until an item arrives', async () => {
|
||||
await stabilize();
|
||||
|
||||
expect(queryDetail()).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores sources without a collection detail component', async () => {
|
||||
fixture.componentInstance.item.set({
|
||||
...xtreamSeriesItem,
|
||||
uid: 'm3u::pl-1::vod:1',
|
||||
sourceType: 'm3u' as UnifiedCollectionItem['sourceType'],
|
||||
playlistId: 'pl-1',
|
||||
});
|
||||
await stabilize();
|
||||
|
||||
expect(queryDetail()).toBeNull();
|
||||
});
|
||||
|
||||
it('creates the Xtream detail with the series resume target attached', async () => {
|
||||
const seriesResume = {
|
||||
seriesXtreamId: 103,
|
||||
contentXtreamId: 2001,
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 1,
|
||||
};
|
||||
fixture.componentInstance.seriesResume.set(seriesResume);
|
||||
fixture.componentInstance.item.set(xtreamSeriesItem);
|
||||
await stabilize();
|
||||
|
||||
const detail = queryDetail();
|
||||
expect(detail).not.toBeNull();
|
||||
|
||||
const instance =
|
||||
detail?.componentInstance as MockXtreamCollectionDetailComponent;
|
||||
expect(instance.item()?.xtreamId).toBe(103);
|
||||
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();
|
||||
|
||||
const instance = queryDetail()
|
||||
?.componentInstance as MockXtreamCollectionDetailComponent;
|
||||
instance.closeRequested.emit();
|
||||
expect(fixture.componentInstance.closeCount).toBe(1);
|
||||
|
||||
fixture.componentInstance.item.set(null);
|
||||
await stabilize();
|
||||
|
||||
expect(queryDetail()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import {
|
||||
CollectionScope,
|
||||
PortalProvider,
|
||||
SeriesResumeTarget,
|
||||
UnifiedCollectionItem,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
|
||||
@@ -68,6 +69,7 @@ function providerToPortalType(provider: PortalProvider): UnifiedPortalType {
|
||||
})
|
||||
export class GlobalCollectionDetailHostComponent implements OnDestroy {
|
||||
readonly item = input<UnifiedCollectionItem | null>(null);
|
||||
readonly seriesResume = input<SeriesResumeTarget | null>(null);
|
||||
readonly closeRequested = output<void>();
|
||||
|
||||
private readonly viewContainer = inject(ViewContainerRef);
|
||||
@@ -78,9 +80,10 @@ export class GlobalCollectionDetailHostComponent implements OnDestroy {
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const item = this.item();
|
||||
const seriesResume = this.seriesResume();
|
||||
|
||||
untracked(() => {
|
||||
void this.renderDetail(item);
|
||||
void this.renderDetail(item, seriesResume);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -91,7 +94,8 @@ export class GlobalCollectionDetailHostComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
private async renderDetail(
|
||||
item: UnifiedCollectionItem | null
|
||||
item: UnifiedCollectionItem | null,
|
||||
seriesResume: SeriesResumeTarget | null
|
||||
): Promise<void> {
|
||||
const requestId = ++this.renderRequestId;
|
||||
this.clearDetail();
|
||||
@@ -109,6 +113,9 @@ export class GlobalCollectionDetailHostComponent implements OnDestroy {
|
||||
environmentInjector: this.environmentInjector,
|
||||
});
|
||||
componentRef.setInput('item', item);
|
||||
if (item.sourceType === 'xtream') {
|
||||
componentRef.setInput('seriesResume', seriesResume);
|
||||
}
|
||||
this.subscribeToClose(componentRef.instance);
|
||||
}
|
||||
|
||||
@@ -175,9 +182,17 @@ export class GlobalCollectionDetailHostComponent implements OnDestroy {
|
||||
[portalType]="activePortalType() ?? undefined"
|
||||
[defaultScope]="effectiveDefaultScope()"
|
||||
>
|
||||
<ng-template unifiedCollectionDetail let-item let-close="close">
|
||||
<ng-template
|
||||
unifiedCollectionDetail
|
||||
let-item
|
||||
let-seriesResume="seriesResume"
|
||||
let-close="close"
|
||||
>
|
||||
@if (item.sourceType === 'xtream') {
|
||||
<app-global-collection-detail-host [item]="item" />
|
||||
<app-global-collection-detail-host
|
||||
[item]="item"
|
||||
[seriesResume]="seriesResume"
|
||||
/>
|
||||
} @else if (item.sourceType === 'stalker') {
|
||||
<app-global-collection-detail-host
|
||||
[item]="item"
|
||||
|
||||
@@ -789,6 +789,7 @@
|
||||
"RESTART": "البدء من جديد",
|
||||
"CONTINUE_WATCHING": "متابعة المشاهدة",
|
||||
"PLAY_FIRST_EPISODE": "تشغيل الحلقة الأولى",
|
||||
"PLAY_EPISODE": "تشغيل الحلقة {{episode}}",
|
||||
"RESUME_EPISODE": "استئناف الحلقة",
|
||||
"PLAY_NEXT_EPISODE": "تشغيل الحلقة التالية",
|
||||
"SERIES_WATCHED": "تمت مشاهدة المسلسل",
|
||||
|
||||
@@ -789,6 +789,7 @@
|
||||
"RESTART": "بدا من الأول",
|
||||
"CONTINUE_WATCHING": "كمل المشاهدة",
|
||||
"PLAY_FIRST_EPISODE": "شغل الحلقة الأولى",
|
||||
"PLAY_EPISODE": "شغل الحلقة {{episode}}",
|
||||
"RESUME_EPISODE": "كمل الحلقة",
|
||||
"PLAY_NEXT_EPISODE": "شغل الحلقة الجاية",
|
||||
"SERIES_WATCHED": "السلسلة متشافة",
|
||||
|
||||
@@ -789,6 +789,7 @@
|
||||
"RESTART": "Спачатку",
|
||||
"CONTINUE_WATCHING": "Працягнуць прагляд",
|
||||
"PLAY_FIRST_EPISODE": "Прайграць першы эпізод",
|
||||
"PLAY_EPISODE": "Прайграць эпізод {{episode}}",
|
||||
"RESUME_EPISODE": "Працягнуць эпізод",
|
||||
"PLAY_NEXT_EPISODE": "Прайграць наступны эпізод",
|
||||
"SERIES_WATCHED": "Серыял прагледжаны",
|
||||
|
||||
@@ -789,6 +789,7 @@
|
||||
"RESTART": "Neu starten",
|
||||
"CONTINUE_WATCHING": "Weiter schauen",
|
||||
"PLAY_FIRST_EPISODE": "Erste Folge abspielen",
|
||||
"PLAY_EPISODE": "Folge {{episode}} abspielen",
|
||||
"RESUME_EPISODE": "Folge fortsetzen",
|
||||
"PLAY_NEXT_EPISODE": "Nächste Folge abspielen",
|
||||
"SERIES_WATCHED": "Serie angesehen",
|
||||
|
||||
@@ -789,6 +789,7 @@
|
||||
"RESTART": "Από την αρχή",
|
||||
"CONTINUE_WATCHING": "Συνέχιση παρακολούθησης",
|
||||
"PLAY_FIRST_EPISODE": "Αναπαραγωγή πρώτου επεισοδίου",
|
||||
"PLAY_EPISODE": "Αναπαραγωγή επεισοδίου {{episode}}",
|
||||
"RESUME_EPISODE": "Συνέχιση επεισοδίου",
|
||||
"PLAY_NEXT_EPISODE": "Αναπαραγωγή επόμενου επεισοδίου",
|
||||
"SERIES_WATCHED": "Η σειρά προβλήθηκε",
|
||||
|
||||
@@ -789,6 +789,7 @@
|
||||
"RESTART": "Start Over",
|
||||
"CONTINUE_WATCHING": "Continue Watching",
|
||||
"PLAY_FIRST_EPISODE": "Play first episode",
|
||||
"PLAY_EPISODE": "Play episode {{episode}}",
|
||||
"RESUME_EPISODE": "Resume episode",
|
||||
"PLAY_NEXT_EPISODE": "Play next episode",
|
||||
"SERIES_WATCHED": "Series watched",
|
||||
|
||||
@@ -789,6 +789,7 @@
|
||||
"RESTART": "Reiniciar",
|
||||
"CONTINUE_WATCHING": "Continuar viendo",
|
||||
"PLAY_FIRST_EPISODE": "Reproducir primer episodio",
|
||||
"PLAY_EPISODE": "Reproducir episodio {{episode}}",
|
||||
"RESUME_EPISODE": "Continuar episodio",
|
||||
"PLAY_NEXT_EPISODE": "Reproducir siguiente episodio",
|
||||
"SERIES_WATCHED": "Serie vista",
|
||||
|
||||
@@ -789,6 +789,7 @@
|
||||
"RESTART": "Recommencer",
|
||||
"CONTINUE_WATCHING": "Reprendre la lecture",
|
||||
"PLAY_FIRST_EPISODE": "Lire le premier épisode",
|
||||
"PLAY_EPISODE": "Lire l'épisode {{episode}}",
|
||||
"RESUME_EPISODE": "Reprendre l'épisode",
|
||||
"PLAY_NEXT_EPISODE": "Lire l'épisode suivant",
|
||||
"SERIES_WATCHED": "Série vue",
|
||||
|
||||
@@ -789,6 +789,7 @@
|
||||
"RESTART": "Ricomincia",
|
||||
"CONTINUE_WATCHING": "Continua a guardare",
|
||||
"PLAY_FIRST_EPISODE": "Riproduci primo episodio",
|
||||
"PLAY_EPISODE": "Riproduci episodio {{episode}}",
|
||||
"RESUME_EPISODE": "Riprendi episodio",
|
||||
"PLAY_NEXT_EPISODE": "Riproduci episodio successivo",
|
||||
"SERIES_WATCHED": "Serie vista",
|
||||
|
||||
@@ -789,6 +789,7 @@
|
||||
"RESTART": "最初から再生",
|
||||
"CONTINUE_WATCHING": "視聴を再開",
|
||||
"PLAY_FIRST_EPISODE": "第1話を再生",
|
||||
"PLAY_EPISODE": "第{{episode}}話を再生",
|
||||
"RESUME_EPISODE": "エピソードを再開",
|
||||
"PLAY_NEXT_EPISODE": "次のエピソードを再生",
|
||||
"SERIES_WATCHED": "シリーズ視聴済み",
|
||||
|
||||
@@ -789,6 +789,7 @@
|
||||
"RESTART": "처음부터 보기",
|
||||
"CONTINUE_WATCHING": "이어서 시청",
|
||||
"PLAY_FIRST_EPISODE": "첫 에피소드 재생",
|
||||
"PLAY_EPISODE": "에피소드 {{episode}} 재생",
|
||||
"RESUME_EPISODE": "에피소드 이어 보기",
|
||||
"PLAY_NEXT_EPISODE": "다음 에피소드 재생",
|
||||
"SERIES_WATCHED": "시리즈 시청 완료",
|
||||
|
||||
@@ -789,6 +789,7 @@
|
||||
"RESTART": "Opnieuw beginnen",
|
||||
"CONTINUE_WATCHING": "Verder kijken",
|
||||
"PLAY_FIRST_EPISODE": "Eerste aflevering afspelen",
|
||||
"PLAY_EPISODE": "Aflevering {{episode}} afspelen",
|
||||
"RESUME_EPISODE": "Aflevering hervatten",
|
||||
"PLAY_NEXT_EPISODE": "Volgende aflevering afspelen",
|
||||
"SERIES_WATCHED": "Serie bekeken",
|
||||
|
||||
@@ -789,6 +789,7 @@
|
||||
"RESTART": "Zacznij od nowa",
|
||||
"CONTINUE_WATCHING": "Kontynuuj oglądanie",
|
||||
"PLAY_FIRST_EPISODE": "Odtwórz pierwszy odcinek",
|
||||
"PLAY_EPISODE": "Odtwórz odcinek {{episode}}",
|
||||
"RESUME_EPISODE": "Wznów odcinek",
|
||||
"PLAY_NEXT_EPISODE": "Odtwórz następny odcinek",
|
||||
"SERIES_WATCHED": "Serial obejrzany",
|
||||
|
||||
@@ -789,6 +789,7 @@
|
||||
"RESTART": "Recomeçar",
|
||||
"CONTINUE_WATCHING": "Continuar assistindo",
|
||||
"PLAY_FIRST_EPISODE": "Reproduzir primeiro episódio",
|
||||
"PLAY_EPISODE": "Reproduzir episódio {{episode}}",
|
||||
"RESUME_EPISODE": "Continuar episódio",
|
||||
"PLAY_NEXT_EPISODE": "Reproduzir próximo episódio",
|
||||
"SERIES_WATCHED": "Série assistida",
|
||||
|
||||
@@ -789,6 +789,7 @@
|
||||
"RESTART": "С начала",
|
||||
"CONTINUE_WATCHING": "Продолжить просмотр",
|
||||
"PLAY_FIRST_EPISODE": "Воспроизвести первую серию",
|
||||
"PLAY_EPISODE": "Воспроизвести серию {{episode}}",
|
||||
"RESUME_EPISODE": "Продолжить серию",
|
||||
"PLAY_NEXT_EPISODE": "Следующая серия",
|
||||
"SERIES_WATCHED": "Сериал просмотрен",
|
||||
|
||||
@@ -789,6 +789,7 @@
|
||||
"RESTART": "Baştan Başla",
|
||||
"CONTINUE_WATCHING": "İzlemeye Devam Et",
|
||||
"PLAY_FIRST_EPISODE": "İlk bölümü oynat",
|
||||
"PLAY_EPISODE": "{{episode}}. bölümü oynat",
|
||||
"RESUME_EPISODE": "Bölüme devam et",
|
||||
"PLAY_NEXT_EPISODE": "Sonraki bölümü oynat",
|
||||
"SERIES_WATCHED": "Dizi izlendi",
|
||||
|
||||
@@ -789,6 +789,7 @@
|
||||
"RESTART": "从头开始",
|
||||
"CONTINUE_WATCHING": "继续观看",
|
||||
"PLAY_FIRST_EPISODE": "播放第一集",
|
||||
"PLAY_EPISODE": "播放第 {{episode}} 集",
|
||||
"RESUME_EPISODE": "继续播放本集",
|
||||
"PLAY_NEXT_EPISODE": "播放下一集",
|
||||
"SERIES_WATCHED": "剧集已看完",
|
||||
|
||||
@@ -789,6 +789,7 @@
|
||||
"RESTART": "從頭開始",
|
||||
"CONTINUE_WATCHING": "繼續觀看",
|
||||
"PLAY_FIRST_EPISODE": "播放第一集",
|
||||
"PLAY_EPISODE": "播放第 {{episode}} 集",
|
||||
"RESUME_EPISODE": "繼續播放本集",
|
||||
"PLAY_NEXT_EPISODE": "播放下一集",
|
||||
"SERIES_WATCHED": "劇集已看完",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// The video.js CJS bundle fails to evaluate under the web project's ESM jest
|
||||
// preset, so specs that transitively import the vjs player get this stub. It
|
||||
// stays CommonJS because videojs plugin packages require() it at module load.
|
||||
class StubComponent {
|
||||
createEl() {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
class StubPlugin {}
|
||||
|
||||
class StubEventTarget {
|
||||
on() {
|
||||
return undefined;
|
||||
}
|
||||
off() {
|
||||
return undefined;
|
||||
}
|
||||
one() {
|
||||
return undefined;
|
||||
}
|
||||
trigger() {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const videoJs = () => ({
|
||||
dispose: () => undefined,
|
||||
on: () => undefined,
|
||||
off: () => undefined,
|
||||
src: () => undefined,
|
||||
});
|
||||
|
||||
videoJs.getComponent = () => StubComponent;
|
||||
videoJs.registerComponent = () => undefined;
|
||||
videoJs.getPlugin = () => StubPlugin;
|
||||
videoJs.registerPlugin = () => undefined;
|
||||
videoJs.EventTarget = StubEventTarget;
|
||||
videoJs.dom = { createEl: () => ({}) };
|
||||
videoJs.browser = {};
|
||||
|
||||
module.exports = videoJs;
|
||||
module.exports.default = videoJs;
|
||||
@@ -176,6 +176,13 @@ When a detail view starts playback:
|
||||
The detail or collection/search host owns inline state. `PlayerService` is not
|
||||
an owner of embedded UI playback state.
|
||||
|
||||
After a successful external episode launch, the detail host immediately
|
||||
persists that episode as the latest playback-position entry, preserving an
|
||||
existing resume offset or using zero for a newly opened episode. MPV/VLC
|
||||
position telemetry overwrites this launch marker when available. This keeps the
|
||||
last-watched season and episode correct even when an external player's progress
|
||||
interface is unavailable; exact external timestamps remain best-effort.
|
||||
|
||||
## Series Quick Start CTA
|
||||
|
||||
Xtream and Stalker series detail views share the quick-start decision helper in
|
||||
@@ -189,6 +196,9 @@ Current contract:
|
||||
`S01E02 · Episode title`
|
||||
- if an episode is in progress, resume the latest updated in-progress episode
|
||||
with its saved offset
|
||||
- if the newest episode entry is a successful external-player launch marker
|
||||
with no meaningful progress yet, target it with `Play episode N` instead of
|
||||
falling back to the first episode
|
||||
- if no episode is in progress, play the first unwatched episode in season order
|
||||
- if watched episodes end at a season boundary, play the first episode of the
|
||||
next loaded season
|
||||
|
||||
@@ -23,6 +23,12 @@ Related:
|
||||
- Dashboard `Global Favorites` and `Recently Watched` widgets hand off Xtream and
|
||||
Stalker movies/series into the matching global collection route with detail
|
||||
pre-opened.
|
||||
- Dashboard Continue Watching handoffs for Xtream series may additionally carry
|
||||
a one-shot season/episode resume target. The collection-owned Xtream detail
|
||||
consumes it after its episode positions load and starts that exact episode;
|
||||
opening the series from the collection grid itself remains detail-only. If the
|
||||
positions load fails, the target stays unconsumed and the handoff degrades to
|
||||
detail-only rather than starting the episode at offset zero.
|
||||
- Do not force both portals into the same browse/detail behavior unless the full
|
||||
portal detail architecture is being changed.
|
||||
|
||||
@@ -73,6 +79,14 @@ Dashboard behavior to preserve:
|
||||
Xtream movie/series items into `/workspace/global-favorites` or
|
||||
`/workspace/global-recent` with collection detail pre-opened from navigation
|
||||
state.
|
||||
- When an Xtream series recent has a saved episode position, the dashboard hero
|
||||
and Continue Watching card should include that exact series/episode target in
|
||||
the navigation state. It is a one-shot playback request and must not leak into
|
||||
normal favorites, search, category, or collection-grid navigation. Only
|
||||
position rows that name their parent `seriesXtreamId` produce a target:
|
||||
episode-keyed recents make `item.xtream_id` an episode id, so legacy rows
|
||||
without the pointer stay detail-only instead of promoting the episode id to a
|
||||
series id.
|
||||
- Back from the collection detail should return to the dashboard handoff state,
|
||||
not switch the active playlist.
|
||||
|
||||
|
||||
@@ -97,6 +97,11 @@ Render rules:
|
||||
hero and cards can show progress, remaining time, and series season/
|
||||
episode badges. Series lookup uses keyed maps for both direct episode ids
|
||||
and series ids; card renders must not scan the full playback-position map.
|
||||
Dashboard-originated Xtream series clicks also carry that exact episode
|
||||
target through the global-recent inline-detail handoff. Once the series
|
||||
metadata and playback positions load, the detail player consumes the
|
||||
target once and resumes the saved episode. Opening the same item normally
|
||||
from the global recent grid remains a detail-only action.
|
||||
3. `liveOnFavoritesCardsEnriched` — maps favorited live channels first,
|
||||
falling back to recently watched live channels when no live favorites
|
||||
exist. M3U cards carry an `epg_lookup_key` using the app-wide XMLTV
|
||||
@@ -174,9 +179,12 @@ The welcome state is rendered via the existing
|
||||
rails have data.
|
||||
5. Navigation from a rail card must deep-link into the appropriate workspace
|
||||
route without switching the active playlist in the header switcher.
|
||||
6. `Recently Used Sources` reflects recent source usage across all provider
|
||||
6. Xtream series hero/Continue Watching clicks with a saved episode position
|
||||
must resume that exact episode while preserving the collection-owned detail
|
||||
and Back behavior. Do not apply autoplay to ordinary collection-grid clicks.
|
||||
7. `Recently Used Sources` reflects recent source usage across all provider
|
||||
types, not just recent imports.
|
||||
7. The live rail title key must match the rendered source: favorites use
|
||||
8. The live rail title key must match the rendered source: favorites use
|
||||
`WORKSPACE.DASHBOARD.LIVE_FAVORITES`; recently watched fallback uses
|
||||
`WORKSPACE.DASHBOARD.LIVE_RECENT`.
|
||||
|
||||
|
||||
+5
-1
@@ -1,9 +1,13 @@
|
||||
import { Directive, inject, TemplateRef } from '@angular/core';
|
||||
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
SeriesResumeTarget,
|
||||
UnifiedCollectionItem,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
|
||||
export interface UnifiedCollectionDetailContext {
|
||||
$implicit: UnifiedCollectionItem;
|
||||
item: UnifiedCollectionItem;
|
||||
seriesResume: SeriesResumeTarget | null;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
|
||||
+103
-2
@@ -76,8 +76,15 @@ class StubUnifiedGridTabComponent {
|
||||
[portalType]="portalType"
|
||||
[defaultScope]="defaultScope"
|
||||
>
|
||||
<ng-template unifiedCollectionDetail let-item>
|
||||
<ng-template
|
||||
unifiedCollectionDetail
|
||||
let-item
|
||||
let-seriesResume="seriesResume"
|
||||
>
|
||||
<div class="detail-probe">{{ item.name }}</div>
|
||||
<div class="resume-probe">
|
||||
{{ seriesResume?.contentXtreamId }}
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-unified-collection-page>
|
||||
`,
|
||||
@@ -295,10 +302,10 @@ describe('UnifiedCollectionPageComponent', () => {
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
window.history.replaceState({}, document.title);
|
||||
fixture = TestBed.createComponent(UnifiedCollectionPageComponent);
|
||||
fixture.componentRef.setInput('mode', 'favorites');
|
||||
fixture.componentRef.setInput('defaultScope', 'all');
|
||||
window.history.replaceState({}, document.title);
|
||||
});
|
||||
|
||||
it('reloads favorites after playlist hydration completes', async () => {
|
||||
@@ -712,9 +719,103 @@ describe('UnifiedCollectionPageComponent', () => {
|
||||
).toBe('movie');
|
||||
});
|
||||
|
||||
it('exposes a dashboard series resume target to the inline detail host', async () => {
|
||||
const item: UnifiedCollectionItem = {
|
||||
uid: 'xtream::xtream-1::series:103',
|
||||
name: 'Resume Series',
|
||||
contentType: 'series',
|
||||
sourceType: 'xtream',
|
||||
playlistId: 'xtream-1',
|
||||
playlistName: 'Xtream One',
|
||||
xtreamId: 103,
|
||||
categoryId: 3,
|
||||
};
|
||||
window.history.replaceState(
|
||||
{
|
||||
[OPEN_COLLECTION_DETAIL_STATE_KEY]: {
|
||||
item,
|
||||
seriesResume: {
|
||||
seriesXtreamId: 103,
|
||||
contentXtreamId: 2001,
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
document.title
|
||||
);
|
||||
|
||||
const hostFixture = TestBed.createComponent(
|
||||
HostUnifiedCollectionPageComponent
|
||||
);
|
||||
hostFixture.detectChanges();
|
||||
await hostFixture.whenStable();
|
||||
hostFixture.detectChanges();
|
||||
|
||||
expect(
|
||||
hostFixture.nativeElement.querySelector('.resume-probe')
|
||||
?.textContent
|
||||
).toContain('2001');
|
||||
expect(
|
||||
hostFixture.componentInstance.pageComponent?.selectedDetailSeriesResume()
|
||||
?.episodeNumber
|
||||
).toBe(1);
|
||||
hostFixture.destroy();
|
||||
});
|
||||
|
||||
it('redirects cross-provider detail selections with the resume target intact', async () => {
|
||||
const originalUrl = router.url;
|
||||
router.url = '/workspace/stalker/stalker-7/favorites';
|
||||
try {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
router.navigate.mockClear();
|
||||
|
||||
const seriesResume = {
|
||||
seriesXtreamId: 103,
|
||||
contentXtreamId: 2001,
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 1,
|
||||
};
|
||||
fixture.componentInstance.selectedDetailSeriesResume.set(
|
||||
seriesResume
|
||||
);
|
||||
fixture.componentInstance.selectedDetailItem.set({
|
||||
uid: 'xtream::xtream-1::series:103',
|
||||
name: 'Resume Series',
|
||||
contentType: 'series',
|
||||
sourceType: 'xtream',
|
||||
playlistId: 'xtream-1',
|
||||
playlistName: 'Xtream One',
|
||||
xtreamId: 103,
|
||||
categoryId: 3,
|
||||
} satisfies UnifiedCollectionItem);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(
|
||||
['/workspace', 'global-favorites'],
|
||||
{
|
||||
state: {
|
||||
[OPEN_COLLECTION_DETAIL_STATE_KEY]: {
|
||||
item: expect.objectContaining({ xtreamId: 103 }),
|
||||
seriesResume,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(
|
||||
fixture.componentInstance.selectedDetailItem()
|
||||
).toBeNull();
|
||||
} finally {
|
||||
router.url = originalUrl;
|
||||
}
|
||||
});
|
||||
|
||||
it('restores collection scope and selected content type from history state', async () => {
|
||||
setRouteParams({ id: 'playlist-1' });
|
||||
playlistsLoaded.set(true);
|
||||
fixture.destroy();
|
||||
window.history.replaceState(
|
||||
{
|
||||
[COLLECTION_VIEW_STATE_KEY]: {
|
||||
|
||||
+28
-7
@@ -47,6 +47,7 @@ import {
|
||||
routeParamSignal,
|
||||
ScopeToggleService,
|
||||
STALKER_RETURN_TO_STATE_KEY,
|
||||
SeriesResumeTarget,
|
||||
UnifiedCollectionItem,
|
||||
WorkspaceViewCommandService,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
@@ -144,6 +145,9 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
|
||||
this.historyCollectionViewState()?.selectedContentType ?? 'live'
|
||||
);
|
||||
readonly selectedDetailItem = signal<UnifiedCollectionItem | null>(null);
|
||||
readonly selectedDetailSeriesResume = signal<SeriesResumeTarget | null>(
|
||||
null
|
||||
);
|
||||
readonly pendingAutoOpenLiveItem = signal(
|
||||
getOpenLiveCollectionItemState(window.history.state)
|
||||
);
|
||||
@@ -157,6 +161,7 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
|
||||
return {
|
||||
$implicit: item,
|
||||
item,
|
||||
seriesResume: this.selectedDetailSeriesResume(),
|
||||
close: this.requestCloseDetail,
|
||||
};
|
||||
}
|
||||
@@ -360,7 +365,10 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
|
||||
return;
|
||||
}
|
||||
|
||||
const navigation = this.getGlobalCollectionDetailNavigation(item);
|
||||
const navigation = this.getGlobalCollectionDetailNavigation(
|
||||
item,
|
||||
this.selectedDetailSeriesResume()
|
||||
);
|
||||
if (!navigation) {
|
||||
return;
|
||||
}
|
||||
@@ -761,7 +769,10 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
|
||||
return !portalType || portalType === item.sourceType;
|
||||
}
|
||||
|
||||
private getGlobalCollectionDetailNavigation(item: UnifiedCollectionItem) {
|
||||
private getGlobalCollectionDetailNavigation(
|
||||
item: UnifiedCollectionItem,
|
||||
seriesResume?: SeriesResumeTarget | null
|
||||
) {
|
||||
if (
|
||||
item.contentType === 'live' ||
|
||||
(item.sourceType !== 'xtream' && item.sourceType !== 'stalker')
|
||||
@@ -769,16 +780,25 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildGlobalCollectionDetailNavigationTarget(this.mode(), item);
|
||||
return buildGlobalCollectionDetailNavigationTarget(
|
||||
this.mode(),
|
||||
item,
|
||||
seriesResume
|
||||
);
|
||||
}
|
||||
|
||||
private openInlineDetail(item: UnifiedCollectionItem): void {
|
||||
private openInlineDetail(
|
||||
item: UnifiedCollectionItem,
|
||||
seriesResume?: SeriesResumeTarget | null
|
||||
): void {
|
||||
this.selectedContentType.set(item.contentType);
|
||||
this.selectedDetailItem.set(item);
|
||||
this.selectedDetailSeriesResume.set(seriesResume ?? null);
|
||||
}
|
||||
|
||||
private clearInlineDetail(): void {
|
||||
this.selectedDetailItem.set(null);
|
||||
this.selectedDetailSeriesResume.set(null);
|
||||
this.autoSelectContentType();
|
||||
clearNavigationStateKeys([OPEN_COLLECTION_DETAIL_STATE_KEY]);
|
||||
}
|
||||
@@ -842,12 +862,13 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
|
||||
}
|
||||
|
||||
private syncDetailFromHistoryState(): void {
|
||||
const detailItem = getOpenCollectionDetailItemState(
|
||||
const detailState = getOpenCollectionDetailItemState(
|
||||
window.history.state
|
||||
)?.item;
|
||||
);
|
||||
const detailItem = detailState?.item;
|
||||
|
||||
if (detailItem && this.canOpenInlineDetail(detailItem)) {
|
||||
this.openInlineDetail(detailItem);
|
||||
this.openInlineDetail(detailItem, detailState.seriesResume);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { UnifiedCollectionItem } from '../collection/unified-collection-item.interface';
|
||||
import {
|
||||
buildCollectionViewState,
|
||||
buildOpenCollectionDetailItemState,
|
||||
buildStalkerStateItem,
|
||||
buildXtreamItemLink,
|
||||
COLLECTION_VIEW_STATE_KEY,
|
||||
@@ -153,6 +155,138 @@ describe('workspace-portal-navigation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('carries an exact Xtream series resume target into recent collection detail state', () => {
|
||||
const navigation = getRecentItemNavigation(
|
||||
{
|
||||
id: 81,
|
||||
title: 'Series Eighty One',
|
||||
type: 'series',
|
||||
playlist_id: 'xtream-1',
|
||||
category_id: 12,
|
||||
xtream_id: 8000,
|
||||
source: 'xtream',
|
||||
poster_url: 'https://example.com/series.png',
|
||||
viewed_at: '2026-03-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
seriesXtreamId: 8000,
|
||||
contentXtreamId: 8024,
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 4,
|
||||
}
|
||||
);
|
||||
|
||||
expect(navigation).toEqual({
|
||||
link: ['/workspace', 'global-recent'],
|
||||
state: {
|
||||
openCollectionDetailItem: {
|
||||
item: expect.objectContaining({
|
||||
contentType: 'series',
|
||||
sourceType: 'xtream',
|
||||
xtreamId: 8000,
|
||||
}),
|
||||
seriesResume: {
|
||||
seriesXtreamId: 8000,
|
||||
contentXtreamId: 8024,
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(getOpenCollectionDetailItemState(navigation.state)).toEqual(
|
||||
navigation.state?.[OPEN_COLLECTION_DETAIL_STATE_KEY]
|
||||
);
|
||||
});
|
||||
|
||||
it('drops malformed or misplaced series resume state on read', () => {
|
||||
const seriesItem = {
|
||||
uid: 'xtream::xtream-1::series:103',
|
||||
name: 'Series One',
|
||||
contentType: 'series',
|
||||
sourceType: 'xtream',
|
||||
playlistId: 'xtream-1',
|
||||
playlistName: 'Xtream One',
|
||||
xtreamId: 103,
|
||||
categoryId: 3,
|
||||
};
|
||||
const validResume = {
|
||||
seriesXtreamId: 103,
|
||||
contentXtreamId: 2001,
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 1,
|
||||
};
|
||||
const readResume = (seriesResume: unknown, item: object = seriesItem) =>
|
||||
getOpenCollectionDetailItemState({
|
||||
[OPEN_COLLECTION_DETAIL_STATE_KEY]: { item, seriesResume },
|
||||
})?.seriesResume;
|
||||
|
||||
expect(readResume(validResume)).toEqual(validResume);
|
||||
|
||||
expect(readResume('resume')).toBeUndefined();
|
||||
expect(
|
||||
readResume({ ...validResume, seriesXtreamId: 'x' })
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
readResume({ ...validResume, seriesXtreamId: 0 })
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
readResume({ ...validResume, contentXtreamId: 1.5 })
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
readResume({ ...validResume, contentXtreamId: -2 })
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
readResume({ ...validResume, seasonNumber: Number.NaN })
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
readResume({ ...validResume, seasonNumber: -1 })
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
readResume({ ...validResume, episodeNumber: 2.5 })
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
readResume({ ...validResume, episodeNumber: -1 })
|
||||
).toBeUndefined();
|
||||
|
||||
// A valid target attached to a non-series item is discarded too.
|
||||
expect(
|
||||
readResume(validResume, {
|
||||
...seriesItem,
|
||||
uid: 'xtream::xtream-1::movie:55',
|
||||
contentType: 'movie',
|
||||
xtreamId: 55,
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('omits the resume key when building detail state without a valid target', () => {
|
||||
const item: UnifiedCollectionItem = {
|
||||
uid: 'xtream::xtream-1::series:103',
|
||||
name: 'Series One',
|
||||
contentType: 'series',
|
||||
sourceType: 'xtream',
|
||||
playlistId: 'xtream-1',
|
||||
playlistName: 'Xtream One',
|
||||
xtreamId: 103,
|
||||
categoryId: 3,
|
||||
};
|
||||
|
||||
expect(buildOpenCollectionDetailItemState(item)).toEqual({
|
||||
item: expect.objectContaining({ xtreamId: 103 }),
|
||||
});
|
||||
expect(
|
||||
buildOpenCollectionDetailItemState(item, {
|
||||
seriesXtreamId: 0,
|
||||
contentXtreamId: 2001,
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 1,
|
||||
})
|
||||
).toEqual({
|
||||
item: expect.objectContaining({ xtreamId: 103 }),
|
||||
});
|
||||
});
|
||||
|
||||
it('matches collection live state against multiple live item identifiers', () => {
|
||||
expect(
|
||||
matchesOpenLiveCollectionItem(
|
||||
|
||||
@@ -36,6 +36,14 @@ export interface OpenLiveCollectionItemState {
|
||||
|
||||
export interface OpenCollectionDetailItemState {
|
||||
item: UnifiedCollectionItem;
|
||||
seriesResume?: SeriesResumeTarget;
|
||||
}
|
||||
|
||||
export interface SeriesResumeTarget {
|
||||
seriesXtreamId: number;
|
||||
contentXtreamId: number;
|
||||
seasonNumber: number;
|
||||
episodeNumber: number;
|
||||
}
|
||||
|
||||
export interface CollectionViewState {
|
||||
@@ -44,7 +52,8 @@ export interface CollectionViewState {
|
||||
}
|
||||
|
||||
export function getRecentItemNavigation(
|
||||
item: PortalRecentItem
|
||||
item: PortalRecentItem,
|
||||
seriesResume?: SeriesResumeTarget | null
|
||||
): WorkspaceNavigationTarget {
|
||||
if (item.type === 'live') {
|
||||
return buildLiveCollectionNavigationTarget({
|
||||
@@ -59,9 +68,24 @@ export function getRecentItemNavigation(
|
||||
|
||||
const collectionItem = buildDashboardCollectionDetailItem(item);
|
||||
if (collectionItem) {
|
||||
const detailItem =
|
||||
item.source === 'xtream' &&
|
||||
item.type === 'series' &&
|
||||
seriesResume
|
||||
? {
|
||||
...collectionItem,
|
||||
uid: buildXtreamCollectionUid(
|
||||
item.playlist_id,
|
||||
'series',
|
||||
seriesResume.seriesXtreamId
|
||||
),
|
||||
xtreamId: seriesResume.seriesXtreamId,
|
||||
}
|
||||
: collectionItem;
|
||||
return buildGlobalCollectionDetailNavigationTarget(
|
||||
'recent',
|
||||
collectionItem
|
||||
detailItem,
|
||||
item.type === 'series' ? seriesResume : null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -180,7 +204,8 @@ export function buildLiveCollectionNavigationTarget(params: {
|
||||
|
||||
export function buildGlobalCollectionDetailNavigationTarget(
|
||||
mode: 'favorites' | 'recent',
|
||||
item: UnifiedCollectionItem
|
||||
item: UnifiedCollectionItem,
|
||||
seriesResume?: SeriesResumeTarget | null
|
||||
): WorkspaceNavigationTarget {
|
||||
return {
|
||||
link: [
|
||||
@@ -189,7 +214,7 @@ export function buildGlobalCollectionDetailNavigationTarget(
|
||||
],
|
||||
state: {
|
||||
[OPEN_COLLECTION_DETAIL_STATE_KEY]:
|
||||
buildOpenCollectionDetailItemState(item),
|
||||
buildOpenCollectionDetailItemState(item, seriesResume),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -228,10 +253,13 @@ export function getCollectionViewState(
|
||||
}
|
||||
|
||||
export function buildOpenCollectionDetailItemState(
|
||||
item: UnifiedCollectionItem
|
||||
item: UnifiedCollectionItem,
|
||||
seriesResume?: SeriesResumeTarget | null
|
||||
): OpenCollectionDetailItemState {
|
||||
const normalizedResume = normalizeSeriesResumeTarget(seriesResume);
|
||||
return {
|
||||
item: normalizeCollectionDetailItem(item) ?? item,
|
||||
...(normalizedResume ? { seriesResume: normalizedResume } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -241,8 +269,18 @@ export function getOpenCollectionDetailItemState(
|
||||
const record = toStateRecord(state);
|
||||
const candidate = toStateRecord(record?.[OPEN_COLLECTION_DETAIL_STATE_KEY]);
|
||||
const item = normalizeCollectionDetailItem(candidate?.['item']);
|
||||
const seriesResume = normalizeSeriesResumeTarget(
|
||||
candidate?.['seriesResume']
|
||||
);
|
||||
|
||||
return item ? { item } : null;
|
||||
return item
|
||||
? {
|
||||
item,
|
||||
...(item.contentType === 'series' && seriesResume
|
||||
? { seriesResume }
|
||||
: {}),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
export function buildOpenLiveCollectionItemState(params: {
|
||||
@@ -668,6 +706,40 @@ function toOptionalNumber(value: unknown): number | undefined {
|
||||
return Number.isFinite(normalized) ? normalized : undefined;
|
||||
}
|
||||
|
||||
function normalizeSeriesResumeTarget(
|
||||
candidate: unknown
|
||||
): SeriesResumeTarget | null {
|
||||
const record = toStateRecord(candidate);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const seriesXtreamId = toOptionalNumber(record['seriesXtreamId']);
|
||||
const contentXtreamId = toOptionalNumber(record['contentXtreamId']);
|
||||
const seasonNumber = toOptionalNumber(record['seasonNumber']);
|
||||
const episodeNumber = toOptionalNumber(record['episodeNumber']);
|
||||
|
||||
if (
|
||||
!Number.isInteger(seriesXtreamId) ||
|
||||
(seriesXtreamId ?? 0) <= 0 ||
|
||||
!Number.isInteger(contentXtreamId) ||
|
||||
(contentXtreamId ?? 0) <= 0 ||
|
||||
!Number.isInteger(seasonNumber) ||
|
||||
(seasonNumber ?? -1) < 0 ||
|
||||
!Number.isInteger(episodeNumber) ||
|
||||
(episodeNumber ?? -1) < 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
seriesXtreamId: seriesXtreamId as number,
|
||||
contentXtreamId: contentXtreamId as number,
|
||||
seasonNumber: seasonNumber as number,
|
||||
episodeNumber: episodeNumber as number,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCollectionDetailItem(
|
||||
candidate: unknown
|
||||
): UnifiedCollectionItem | null {
|
||||
|
||||
@@ -93,6 +93,42 @@ describe('getSeriesQuickStartAction', () => {
|
||||
expect(action?.position?.positionSeconds).toBe(30);
|
||||
});
|
||||
|
||||
it('plays the most recently launched episode when it has no progress yet', () => {
|
||||
const latestEpisode = episode(108, 1, 8);
|
||||
|
||||
const action = getSeriesQuickStartAction({
|
||||
seasons: {
|
||||
'1': [episode(101, 1, 1), latestEpisode],
|
||||
},
|
||||
playbackPositions: new Map([
|
||||
[
|
||||
101,
|
||||
position(101, {
|
||||
positionSeconds: 20,
|
||||
updatedAt: '2026-05-10T10:00:00.000Z',
|
||||
}),
|
||||
],
|
||||
[
|
||||
108,
|
||||
position(108, {
|
||||
positionSeconds: 0,
|
||||
durationSeconds: undefined,
|
||||
updatedAt: '2026-05-10T11:00:00.000Z',
|
||||
}),
|
||||
],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(action?.kind).toBe(
|
||||
SERIES_QUICK_START_ACTION_KIND.PlayRecent
|
||||
);
|
||||
expect(action?.labelKey).toBe('XTREAM.PLAY_EPISODE');
|
||||
expect(action?.labelParams).toEqual({ episode: 8 });
|
||||
expect(action?.episodeLabel).toBe('S01E08 \u00b7 Episode 8');
|
||||
expect(action?.episode).toBe(latestEpisode);
|
||||
expect(action?.position?.positionSeconds).toBe(0);
|
||||
});
|
||||
|
||||
it('plays the next unwatched episode after watched episodes', () => {
|
||||
const nextEpisode = episode(102, 1, 2);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
|
||||
export const SERIES_QUICK_START_ACTION_KIND = {
|
||||
PlayFirst: 'play-first',
|
||||
PlayRecent: 'play-recent',
|
||||
Resume: 'resume',
|
||||
PlayNext: 'play-next',
|
||||
Completed: 'completed',
|
||||
@@ -17,6 +18,7 @@ export type SeriesQuickStartActionKind =
|
||||
export interface SeriesQuickStartAction {
|
||||
kind: SeriesQuickStartActionKind;
|
||||
labelKey: string;
|
||||
labelParams?: Record<string, number>;
|
||||
episodeLabel: string;
|
||||
icon: string;
|
||||
episode: XtreamSerieEpisode;
|
||||
@@ -49,18 +51,37 @@ export function getSeriesQuickStartAction(
|
||||
return null;
|
||||
}
|
||||
|
||||
const inProgress = orderedEpisodes
|
||||
.filter(({ position }) => isPortalPlaybackInProgress(position))
|
||||
.sort(compareInProgressPositions);
|
||||
const startedEpisodes = orderedEpisodes
|
||||
.filter(
|
||||
({ position }) =>
|
||||
position !== null && !isPortalPlaybackWatched(position)
|
||||
)
|
||||
.sort(comparePlaybackPositionRecency);
|
||||
|
||||
const resumeEpisode = inProgress[inProgress.length - 1];
|
||||
if (resumeEpisode?.position) {
|
||||
const latestStartedEpisode = startedEpisodes[startedEpisodes.length - 1];
|
||||
if (latestStartedEpisode?.position) {
|
||||
const shouldResume = isPortalPlaybackInProgress(
|
||||
latestStartedEpisode.position
|
||||
);
|
||||
return createQuickStartAction({
|
||||
kind: SERIES_QUICK_START_ACTION_KIND.Resume,
|
||||
labelKey: 'XTREAM.RESUME_EPISODE',
|
||||
kind: shouldResume
|
||||
? SERIES_QUICK_START_ACTION_KIND.Resume
|
||||
: SERIES_QUICK_START_ACTION_KIND.PlayRecent,
|
||||
labelKey: shouldResume
|
||||
? 'XTREAM.RESUME_EPISODE'
|
||||
: 'XTREAM.PLAY_EPISODE',
|
||||
...(!shouldResume
|
||||
? {
|
||||
labelParams: {
|
||||
episode: Number(
|
||||
latestStartedEpisode.episode.episode_num
|
||||
),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
icon: 'play_arrow',
|
||||
episode: resumeEpisode.episode,
|
||||
position: resumeEpisode.position,
|
||||
episode: latestStartedEpisode.episode,
|
||||
position: latestStartedEpisode.position,
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
@@ -150,7 +171,7 @@ function compareEpisodes(
|
||||
return naturalCollator.compare(episodeA.title ?? '', episodeB.title ?? '');
|
||||
}
|
||||
|
||||
function compareInProgressPositions(
|
||||
function comparePlaybackPositionRecency(
|
||||
episodeA: OrderedEpisode,
|
||||
episodeB: OrderedEpisode
|
||||
): number {
|
||||
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
import type {
|
||||
ExternalPlayerSession,
|
||||
PlaybackPositionData,
|
||||
ResolvedPortalPlayback,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { SerialDetailsPlaybackPositionState } from './serial-details-playback-position-state';
|
||||
import type { XtreamSerieDetailsView } from './serial-details-playback.service';
|
||||
|
||||
function episode(id: string, season: number, episodeNum: number) {
|
||||
return {
|
||||
id,
|
||||
season,
|
||||
episode_num: episodeNum,
|
||||
title: `Episode ${episodeNum}`,
|
||||
};
|
||||
}
|
||||
|
||||
function seriesView(): XtreamSerieDetailsView {
|
||||
return {
|
||||
series_id: 103,
|
||||
info: {},
|
||||
episodes: {
|
||||
'1': [episode('1001', 1, 1), episode('1002', 1, 2)],
|
||||
'2': [episode('2001', 2, 1)],
|
||||
},
|
||||
} as unknown as XtreamSerieDetailsView;
|
||||
}
|
||||
|
||||
function position(
|
||||
contentXtreamId: number,
|
||||
overrides: Partial<PlaybackPositionData> = {}
|
||||
): PlaybackPositionData {
|
||||
return {
|
||||
contentXtreamId,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 103,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
positionSeconds: 60,
|
||||
durationSeconds: 1200,
|
||||
playlistId: 'xtream-1',
|
||||
updatedAt: '2026-05-10T12:00:00.000Z',
|
||||
...overrides,
|
||||
} as PlaybackPositionData;
|
||||
}
|
||||
|
||||
function playback(
|
||||
contentXtreamId: number,
|
||||
overrides: Partial<ResolvedPortalPlayback> = {}
|
||||
): ResolvedPortalPlayback {
|
||||
return {
|
||||
streamUrl: `http://xtream.example/series/${contentXtreamId}.mp4`,
|
||||
title: 'Episode',
|
||||
contentInfo: {
|
||||
playlistId: 'xtream-1',
|
||||
contentXtreamId,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 103,
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 1,
|
||||
},
|
||||
...overrides,
|
||||
} as ResolvedPortalPlayback;
|
||||
}
|
||||
|
||||
const session = {
|
||||
id: 'vlc-1',
|
||||
player: 'vlc',
|
||||
status: 'opened',
|
||||
} as ExternalPlayerSession;
|
||||
|
||||
const target = {
|
||||
seriesXtreamId: 103,
|
||||
contentXtreamId: 2001,
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 1,
|
||||
};
|
||||
|
||||
describe('SerialDetailsPlaybackPositionState', () => {
|
||||
let state: SerialDetailsPlaybackPositionState;
|
||||
|
||||
beforeEach(() => {
|
||||
state = new SerialDetailsPlaybackPositionState();
|
||||
});
|
||||
|
||||
async function loadPositions(
|
||||
positions: PlaybackPositionData[] = [position(2001)]
|
||||
): Promise<void> {
|
||||
await state.load('xtream-1', 103, () => Promise.resolve(positions));
|
||||
}
|
||||
|
||||
describe('load', () => {
|
||||
it('keeps only the latest concurrent load result', async () => {
|
||||
let resolveFirst!: (value: PlaybackPositionData[]) => void;
|
||||
const first = state.load(
|
||||
'xtream-1',
|
||||
103,
|
||||
() =>
|
||||
new Promise<PlaybackPositionData[]>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
})
|
||||
);
|
||||
await loadPositions([position(2001, { positionSeconds: 84 })]);
|
||||
|
||||
resolveFirst([position(1001, { positionSeconds: 5 })]);
|
||||
await first;
|
||||
|
||||
expect(state.positions().get(2001)?.positionSeconds).toBe(84);
|
||||
expect(state.positions().has(1001)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('takeResumeEpisode', () => {
|
||||
it('rejects targets before positions finish loading', () => {
|
||||
expect(
|
||||
state.takeResumeEpisode({
|
||||
playlistId: 'xtream-1',
|
||||
selectedItem: seriesView(),
|
||||
target,
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects targets that belong to another series', async () => {
|
||||
await loadPositions();
|
||||
|
||||
expect(
|
||||
state.takeResumeEpisode({
|
||||
playlistId: 'xtream-1',
|
||||
selectedItem: seriesView(),
|
||||
target: { ...target, seriesXtreamId: 999 },
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('consumes a matching target only once', async () => {
|
||||
await loadPositions();
|
||||
const request = {
|
||||
playlistId: 'xtream-1',
|
||||
selectedItem: seriesView(),
|
||||
target,
|
||||
};
|
||||
|
||||
expect(state.takeResumeEpisode(request)?.id).toBe('2001');
|
||||
expect(state.takeResumeEpisode(request)).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to season/episode matching for unknown episode ids', async () => {
|
||||
await loadPositions();
|
||||
|
||||
expect(
|
||||
state.takeResumeEpisode({
|
||||
playlistId: 'xtream-1',
|
||||
selectedItem: seriesView(),
|
||||
target: { ...target, contentXtreamId: 5555 },
|
||||
})?.id
|
||||
).toBe('2001');
|
||||
});
|
||||
|
||||
it('leaves unmatched targets unconsumed until episodes arrive', async () => {
|
||||
await loadPositions();
|
||||
const request = {
|
||||
playlistId: 'xtream-1',
|
||||
selectedItem: {
|
||||
...seriesView(),
|
||||
episodes: {},
|
||||
} as unknown as XtreamSerieDetailsView,
|
||||
target,
|
||||
};
|
||||
|
||||
expect(state.takeResumeEpisode(request)).toBeNull();
|
||||
expect(
|
||||
state.takeResumeEpisode({
|
||||
...request,
|
||||
selectedItem: seriesView(),
|
||||
})?.id
|
||||
).toBe('2001');
|
||||
});
|
||||
|
||||
it('stops resolving targets after a reset', async () => {
|
||||
await loadPositions();
|
||||
state.reset();
|
||||
|
||||
expect(
|
||||
state.takeResumeEpisode({
|
||||
playlistId: 'xtream-1',
|
||||
selectedItem: seriesView(),
|
||||
target,
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update and remove', () => {
|
||||
it('updates and removes entries by content id', () => {
|
||||
state.update(position(1001));
|
||||
expect(state.positions().get(1001)?.positionSeconds).toBe(60);
|
||||
|
||||
state.remove(1001);
|
||||
expect(state.positions().has(1001)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordExternalLaunch', () => {
|
||||
let save: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
save = jest.fn().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('ignores launches that do not produce a session', async () => {
|
||||
await state.recordExternalLaunch(
|
||||
playback(2001),
|
||||
Promise.resolve(undefined),
|
||||
save
|
||||
);
|
||||
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores launches without episode content info', async () => {
|
||||
await state.recordExternalLaunch(
|
||||
playback(2001, { contentInfo: undefined }),
|
||||
Promise.resolve(session),
|
||||
save
|
||||
);
|
||||
await state.recordExternalLaunch(
|
||||
playback(2001, {
|
||||
contentInfo: {
|
||||
playlistId: 'xtream-1',
|
||||
contentXtreamId: 2001,
|
||||
contentType: 'vod',
|
||||
} as ResolvedPortalPlayback['contentInfo'],
|
||||
}),
|
||||
Promise.resolve(session),
|
||||
save
|
||||
);
|
||||
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('persists the floored requested start time', async () => {
|
||||
await state.recordExternalLaunch(
|
||||
playback(2001, { startTime: 42.9 }),
|
||||
Promise.resolve(session),
|
||||
save
|
||||
);
|
||||
|
||||
expect(save).toHaveBeenCalledWith(
|
||||
'xtream-1',
|
||||
expect.objectContaining({
|
||||
contentXtreamId: 2001,
|
||||
positionSeconds: 42,
|
||||
})
|
||||
);
|
||||
expect(state.positions().get(2001)?.positionSeconds).toBe(42);
|
||||
});
|
||||
|
||||
it('keeps the existing saved offset when the launch has none', async () => {
|
||||
state.update(
|
||||
position(2001, { positionSeconds: 84, durationSeconds: 1200 })
|
||||
);
|
||||
|
||||
await state.recordExternalLaunch(
|
||||
playback(2001),
|
||||
Promise.resolve(session),
|
||||
save
|
||||
);
|
||||
|
||||
expect(save).toHaveBeenCalledWith(
|
||||
'xtream-1',
|
||||
expect.objectContaining({
|
||||
positionSeconds: 84,
|
||||
durationSeconds: 1200,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('clamps invalid start times to zero', async () => {
|
||||
await state.recordExternalLaunch(
|
||||
playback(2001, { startTime: Number.NaN }),
|
||||
Promise.resolve(session),
|
||||
save
|
||||
);
|
||||
await state.recordExternalLaunch(
|
||||
playback(2001, { startTime: -30 }),
|
||||
Promise.resolve(session),
|
||||
save
|
||||
);
|
||||
|
||||
expect(save).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'xtream-1',
|
||||
expect.objectContaining({ positionSeconds: 0 })
|
||||
);
|
||||
expect(save).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'xtream-1',
|
||||
expect.objectContaining({ positionSeconds: 0 })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
import { signal } from '@angular/core';
|
||||
import type { SeriesResumeTarget } from '@iptvnator/portal/shared/util';
|
||||
import type {
|
||||
ExternalPlayerSession,
|
||||
PlaybackPositionData,
|
||||
ResolvedPortalPlayback,
|
||||
XtreamSerieEpisode,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { XtreamSerieDetailsView } from './serial-details-playback.service';
|
||||
|
||||
interface ResumeEpisodeRequest {
|
||||
playlistId: string;
|
||||
selectedItem: XtreamSerieDetailsView;
|
||||
target: SeriesResumeTarget;
|
||||
}
|
||||
|
||||
export class SerialDetailsPlaybackPositionState {
|
||||
readonly positions = signal<Map<number, PlaybackPositionData>>(new Map());
|
||||
|
||||
private loadRequestId = 0;
|
||||
private readonly loadedKey = signal<string | null>(null);
|
||||
private readonly consumedResumeKey = signal<string | null>(null);
|
||||
|
||||
reset(): void {
|
||||
this.loadRequestId++;
|
||||
this.positions.set(new Map());
|
||||
this.loadedKey.set(null);
|
||||
}
|
||||
|
||||
async load(
|
||||
playlistId: string,
|
||||
seriesXtreamId: number,
|
||||
loader: () => Promise<PlaybackPositionData[]>
|
||||
): Promise<void> {
|
||||
const requestId = ++this.loadRequestId;
|
||||
this.loadedKey.set(null);
|
||||
let positions: PlaybackPositionData[] = [];
|
||||
let loadSucceeded = true;
|
||||
|
||||
try {
|
||||
positions = await loader();
|
||||
} catch (error) {
|
||||
loadSucceeded = false;
|
||||
console.warn(
|
||||
'[SerialDetailsPlayback] Failed to load series playback positions',
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
if (requestId !== this.loadRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.positions.set(
|
||||
new Map(
|
||||
positions.map((position) => [
|
||||
position.contentXtreamId,
|
||||
position,
|
||||
])
|
||||
)
|
||||
);
|
||||
// A failed load must not mark the series resume-ready: without the
|
||||
// persisted offsets a dashboard handoff would start the target
|
||||
// episode from the beginning instead of its saved position.
|
||||
if (loadSucceeded) {
|
||||
this.loadedKey.set(this.createKey(playlistId, seriesXtreamId));
|
||||
}
|
||||
}
|
||||
|
||||
takeResumeEpisode(
|
||||
request: ResumeEpisodeRequest
|
||||
): XtreamSerieEpisode | null {
|
||||
const seriesXtreamId = Number(request.selectedItem.series_id);
|
||||
if (
|
||||
request.target.seriesXtreamId !== seriesXtreamId ||
|
||||
this.loadedKey() !==
|
||||
this.createKey(request.playlistId, seriesXtreamId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resumeKey = [
|
||||
request.playlistId,
|
||||
request.target.seriesXtreamId,
|
||||
request.target.contentXtreamId,
|
||||
request.target.seasonNumber,
|
||||
request.target.episodeNumber,
|
||||
].join(':');
|
||||
if (this.consumedResumeKey() === resumeKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const episodes: XtreamSerieEpisode[] = [];
|
||||
Object.values(request.selectedItem.episodes ?? {}).forEach(
|
||||
(seasonEpisodes) => episodes.push(...seasonEpisodes)
|
||||
);
|
||||
const episode =
|
||||
episodes.find(
|
||||
(item) => Number(item.id) === request.target.contentXtreamId
|
||||
) ??
|
||||
episodes.find(
|
||||
(item) =>
|
||||
Number(item.season) === request.target.seasonNumber &&
|
||||
Number(item.episode_num) === request.target.episodeNumber
|
||||
) ??
|
||||
null;
|
||||
|
||||
if (episode) {
|
||||
this.consumedResumeKey.set(resumeKey);
|
||||
}
|
||||
return episode;
|
||||
}
|
||||
|
||||
update(position: PlaybackPositionData): void {
|
||||
const updated = new Map(this.positions());
|
||||
updated.set(position.contentXtreamId, position);
|
||||
this.positions.set(updated);
|
||||
}
|
||||
|
||||
remove(contentXtreamId: number): void {
|
||||
const updated = new Map(this.positions());
|
||||
updated.delete(contentXtreamId);
|
||||
this.positions.set(updated);
|
||||
}
|
||||
|
||||
async recordExternalLaunch(
|
||||
playback: ResolvedPortalPlayback,
|
||||
launch: Promise<ExternalPlayerSession | void>,
|
||||
save: (
|
||||
playlistId: string,
|
||||
position: PlaybackPositionData
|
||||
) => Promise<void>
|
||||
): Promise<void> {
|
||||
const session = await launch;
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentInfo = playback.contentInfo;
|
||||
if (!contentInfo || contentInfo.contentType !== 'episode') {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = this.positions().get(contentInfo.contentXtreamId);
|
||||
const requestedPosition =
|
||||
playback.startTime ?? existing?.positionSeconds ?? 0;
|
||||
const positionSeconds = Number.isFinite(requestedPosition)
|
||||
? Math.max(0, Math.floor(requestedPosition))
|
||||
: 0;
|
||||
const position: PlaybackPositionData = {
|
||||
...contentInfo,
|
||||
playlistId: contentInfo.playlistId,
|
||||
positionSeconds,
|
||||
durationSeconds: existing?.durationSeconds,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await save(contentInfo.playlistId, position);
|
||||
this.update(position);
|
||||
}
|
||||
|
||||
private createKey(playlistId: string, seriesXtreamId: number): string {
|
||||
return `${playlistId}:${seriesXtreamId}`;
|
||||
}
|
||||
}
|
||||
+56
-33
@@ -6,6 +6,7 @@ import {
|
||||
Injectable,
|
||||
Signal,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import {
|
||||
@@ -31,6 +32,8 @@ import {
|
||||
resolveSeriesPlaybackEpisodeState,
|
||||
type SeriesPlaybackEpisodeState,
|
||||
} from '@iptvnator/ui/playback';
|
||||
import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.token';
|
||||
import { SerialDetailsPlaybackPositionState } from './serial-details-playback-position-state';
|
||||
|
||||
export type XtreamSerieDetailsView = XtreamSerieDetails & {
|
||||
readonly series_id: number;
|
||||
@@ -55,6 +58,7 @@ export class SerialDetailsPlaybackService {
|
||||
);
|
||||
private readonly portalPlayer = inject(PORTAL_PLAYER);
|
||||
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
|
||||
private readonly resumeTarget = inject(XTREAM_SERIES_RESUME_TARGET);
|
||||
|
||||
private readonly bindings = signal<SerialDetailsPlaybackBindings | null>(
|
||||
null
|
||||
@@ -62,12 +66,12 @@ export class SerialDetailsPlaybackService {
|
||||
private readonly currentPlaylistId = computed(
|
||||
() => this.xtreamStore.currentPlaylist()?.id ?? ''
|
||||
);
|
||||
private readonly playbackPositionState =
|
||||
new SerialDetailsPlaybackPositionState();
|
||||
private lastSaveTime = 0;
|
||||
|
||||
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
|
||||
readonly episodePlaybackPositions = signal<
|
||||
Map<number, PlaybackPositionData>
|
||||
>(new Map());
|
||||
readonly episodePlaybackPositions = this.playbackPositionState.positions;
|
||||
readonly openingEpisodeId = signal<number | null>(null);
|
||||
readonly activeEpisodeId = signal<number | null>(null);
|
||||
|
||||
@@ -129,6 +133,27 @@ export class SerialDetailsPlaybackService {
|
||||
this.activeEpisodeId.set(null);
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const target = this.resumeTarget();
|
||||
const selectedItem = this.selectedItem();
|
||||
const playlistId = this.currentPlaylistId();
|
||||
|
||||
if (!target || !selectedItem || !playlistId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const episode = this.playbackPositionState.takeResumeEpisode({
|
||||
playlistId,
|
||||
selectedItem,
|
||||
target,
|
||||
});
|
||||
if (!episode) {
|
||||
return;
|
||||
}
|
||||
|
||||
untracked(() => this.playEpisode(episode));
|
||||
});
|
||||
|
||||
const unsubscribePositionUpdates =
|
||||
this.playbackPositionBridge.onPlaybackPositionUpdate(
|
||||
(data: PlaybackPositionData) => {
|
||||
@@ -143,7 +168,7 @@ export class SerialDetailsPlaybackService {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateEpisodePlaybackPosition(data);
|
||||
this.playbackPositionState.update(data);
|
||||
}
|
||||
) ?? null;
|
||||
|
||||
@@ -160,7 +185,7 @@ export class SerialDetailsPlaybackService {
|
||||
/** Clears all playback state when switching to another series. */
|
||||
resetForNewSeries(): void {
|
||||
this.closeInlinePlayer();
|
||||
this.episodePlaybackPositions.set(new Map());
|
||||
this.playbackPositionState.reset();
|
||||
this.openingEpisodeId.set(null);
|
||||
this.activeEpisodeId.set(null);
|
||||
}
|
||||
@@ -257,13 +282,21 @@ export class SerialDetailsPlaybackService {
|
||||
playback.contentInfo.playlistId,
|
||||
position
|
||||
);
|
||||
this.updateEpisodePlaybackPosition(position);
|
||||
this.playbackPositionState.update(position);
|
||||
}
|
||||
|
||||
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
|
||||
void this.portalPlayer.openExternalPlayback(
|
||||
void this.playbackPositionState.recordExternalLaunch(
|
||||
request.playback,
|
||||
request.player
|
||||
this.portalPlayer.openExternalPlayback(
|
||||
request.playback,
|
||||
request.player
|
||||
),
|
||||
(playlistId, position) =>
|
||||
this.playbackPositions.savePlaybackPosition(
|
||||
playlistId,
|
||||
position
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -280,7 +313,7 @@ export class SerialDetailsPlaybackService {
|
||||
playlistId,
|
||||
request.nextPosition
|
||||
);
|
||||
this.updateEpisodePlaybackPosition(request.nextPosition);
|
||||
this.playbackPositionState.update(request.nextPosition);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -289,23 +322,19 @@ export class SerialDetailsPlaybackService {
|
||||
request.contentXtreamId,
|
||||
'episode'
|
||||
);
|
||||
this.removeEpisodePlaybackPosition(request.contentXtreamId);
|
||||
this.playbackPositionState.remove(request.contentXtreamId);
|
||||
}
|
||||
|
||||
async loadSeriesPlaybackPositions(
|
||||
playlistId: string,
|
||||
seriesXtreamId: number
|
||||
): Promise<void> {
|
||||
const positions =
|
||||
await this.playbackPositions.getSeriesPlaybackPositions(
|
||||
return this.playbackPositionState.load(playlistId, seriesXtreamId, () =>
|
||||
this.playbackPositions.getSeriesPlaybackPositions(
|
||||
playlistId,
|
||||
seriesXtreamId
|
||||
);
|
||||
const positionsMap = new Map<number, PlaybackPositionData>();
|
||||
positions.forEach((position) => {
|
||||
positionsMap.set(position.contentXtreamId, position);
|
||||
});
|
||||
this.episodePlaybackPositions.set(positionsMap);
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private selectedItem(): XtreamSerieDetailsView | null {
|
||||
@@ -329,7 +358,15 @@ export class SerialDetailsPlaybackService {
|
||||
}
|
||||
|
||||
this.closeInlinePlayer();
|
||||
void this.portalPlayer.openResolvedPlayback(playback, true);
|
||||
void this.playbackPositionState.recordExternalLaunch(
|
||||
playback,
|
||||
this.portalPlayer.openResolvedPlayback(playback, true),
|
||||
(playlistId, position) =>
|
||||
this.playbackPositions.savePlaybackPosition(
|
||||
playlistId,
|
||||
position
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private getInlineEpisodeState(): SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null {
|
||||
@@ -352,18 +389,4 @@ export class SerialDetailsPlaybackService {
|
||||
fallbackEpisodeNumber: playback.contentInfo.episodeNumber,
|
||||
});
|
||||
}
|
||||
|
||||
private updateEpisodePlaybackPosition(
|
||||
position: PlaybackPositionData
|
||||
): void {
|
||||
const updated = new Map(this.episodePlaybackPositions());
|
||||
updated.set(position.contentXtreamId, position);
|
||||
this.episodePlaybackPositions.set(updated);
|
||||
}
|
||||
|
||||
private removeEpisodePlaybackPosition(contentXtreamId: number): void {
|
||||
const updated = new Map(this.episodePlaybackPositions());
|
||||
updated.delete(contentXtreamId);
|
||||
this.episodePlaybackPositions.set(updated);
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.token';
|
||||
|
||||
describe('XTREAM_SERIES_RESUME_TARGET', () => {
|
||||
it('defaults to a null resume target outside collection detail hosts', () => {
|
||||
const target = TestBed.inject(XTREAM_SERIES_RESUME_TARGET);
|
||||
|
||||
expect(target()).toBeNull();
|
||||
});
|
||||
});
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { InjectionToken, Signal, signal } from '@angular/core';
|
||||
import type { SeriesResumeTarget } from '@iptvnator/portal/shared/util';
|
||||
|
||||
export const XTREAM_SERIES_RESUME_TARGET = new InjectionToken<
|
||||
Signal<SeriesResumeTarget | null>
|
||||
>('XTREAM_SERIES_RESUME_TARGET', {
|
||||
factory: () => signal(null),
|
||||
});
|
||||
@@ -102,7 +102,10 @@
|
||||
</span>
|
||||
<span class="play-btn__copy">
|
||||
<span class="play-btn__label">
|
||||
{{ action.labelKey | translate }}
|
||||
{{
|
||||
action.labelKey
|
||||
| translate: action.labelParams
|
||||
}}
|
||||
</span>
|
||||
<span class="play-btn__meta">
|
||||
{{ action.episodeLabel }}
|
||||
|
||||
+330
-3
@@ -15,11 +15,16 @@ import {
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
PORTAL_PLAYBACK_POSITIONS,
|
||||
PORTAL_PLAYER,
|
||||
SeriesResumeTarget,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
|
||||
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
|
||||
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
|
||||
import { of } from 'rxjs';
|
||||
import { SerialDetailsComponent } from './serial-details.component';
|
||||
import { SerialDetailsPlaybackService } from './serial-details-playback.service';
|
||||
import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.token';
|
||||
|
||||
@Component({
|
||||
selector: 'app-season-container',
|
||||
@@ -84,8 +89,16 @@ describe('SerialDetailsComponent', () => {
|
||||
const constructEpisodeStreamUrl = jest.fn();
|
||||
const addRecentItem = jest.fn();
|
||||
const openResolvedPlayback = jest.fn();
|
||||
const openExternalPlayback = jest.fn();
|
||||
const savePlaybackPosition = jest.fn();
|
||||
const clearPlaybackPosition = jest.fn();
|
||||
const isEmbeddedPlayer = jest.fn();
|
||||
const getSeriesPlaybackPositions = jest.fn().mockResolvedValue([]);
|
||||
let positionUpdateCallback: ((data: PlaybackPositionData) => void) | null =
|
||||
null;
|
||||
let seriesResumeTarget: ReturnType<
|
||||
typeof signal<SeriesResumeTarget | null>
|
||||
>;
|
||||
|
||||
beforeEach(async () => {
|
||||
selectedItem.set({
|
||||
@@ -133,11 +146,20 @@ describe('SerialDetailsComponent', () => {
|
||||
`http://xtream.example/series/${episode.id}.mp4`
|
||||
);
|
||||
addRecentItem.mockClear();
|
||||
openResolvedPlayback.mockClear();
|
||||
openResolvedPlayback.mockReset();
|
||||
openResolvedPlayback.mockResolvedValue(undefined);
|
||||
openExternalPlayback.mockReset();
|
||||
openExternalPlayback.mockResolvedValue(undefined);
|
||||
savePlaybackPosition.mockReset();
|
||||
savePlaybackPosition.mockResolvedValue(undefined);
|
||||
clearPlaybackPosition.mockReset();
|
||||
clearPlaybackPosition.mockResolvedValue(undefined);
|
||||
positionUpdateCallback = null;
|
||||
isEmbeddedPlayer.mockReset();
|
||||
isEmbeddedPlayer.mockReturnValue(false);
|
||||
getSeriesPlaybackPositions.mockClear();
|
||||
getSeriesPlaybackPositions.mockResolvedValue([]);
|
||||
seriesResumeTarget = signal<SeriesResumeTarget | null>(null);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SerialDetailsComponent],
|
||||
@@ -187,8 +209,8 @@ describe('SerialDetailsComponent', () => {
|
||||
provide: PORTAL_PLAYBACK_POSITIONS,
|
||||
useValue: {
|
||||
getSeriesPlaybackPositions,
|
||||
savePlaybackPosition: jest.fn(),
|
||||
clearPlaybackPosition: jest.fn(),
|
||||
savePlaybackPosition,
|
||||
clearPlaybackPosition,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -196,8 +218,26 @@ describe('SerialDetailsComponent', () => {
|
||||
useValue: {
|
||||
isEmbeddedPlayer,
|
||||
openResolvedPlayback,
|
||||
openExternalPlayback,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PlaybackPositionRuntimeBridgeService,
|
||||
useValue: {
|
||||
onPlaybackPositionUpdate: (
|
||||
callback: (data: PlaybackPositionData) => void
|
||||
) => {
|
||||
positionUpdateCallback = callback;
|
||||
return () => {
|
||||
positionUpdateCallback = null;
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: XTREAM_SERIES_RESUME_TARGET,
|
||||
useValue: seriesResumeTarget,
|
||||
},
|
||||
{
|
||||
provide: MatSnackBar,
|
||||
useValue: {
|
||||
@@ -408,6 +448,293 @@ describe('SerialDetailsComponent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('records the selected episode after a successful external-player launch', async () => {
|
||||
openResolvedPlayback.mockResolvedValue({
|
||||
id: 'vlc-session-1',
|
||||
player: 'vlc',
|
||||
status: 'opened',
|
||||
title: 'Season 2 Episode 1',
|
||||
streamUrl: 'http://xtream.example/series/2001.mp4',
|
||||
startedAt: '2026-07-14T10:00:00.000Z',
|
||||
updatedAt: '2026-07-14T10:00:00.000Z',
|
||||
canClose: true,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
fixture.componentInstance.playEpisode({
|
||||
id: '2001',
|
||||
episode_num: 1,
|
||||
title: 'Season 2 Episode 1',
|
||||
season: 2,
|
||||
} as never);
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(savePlaybackPosition).toHaveBeenCalledWith(
|
||||
'xtream-1',
|
||||
expect.objectContaining({
|
||||
playlistId: 'xtream-1',
|
||||
contentXtreamId: 2001,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 103,
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 1,
|
||||
positionSeconds: 0,
|
||||
updatedAt: expect.any(String),
|
||||
})
|
||||
);
|
||||
fixture.detectChanges();
|
||||
const quickStartButton: HTMLButtonElement | null =
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="series-quick-start"]'
|
||||
);
|
||||
expect(quickStartButton?.textContent).toContain(
|
||||
'XTREAM.PLAY_EPISODE'
|
||||
);
|
||||
expect(quickStartButton?.textContent).toContain(
|
||||
'S02E01 \u00b7 Season 2 Episode 1'
|
||||
);
|
||||
});
|
||||
|
||||
it('automatically resumes the exact dashboard episode after positions load', async () => {
|
||||
getSeriesPlaybackPositions.mockResolvedValue([
|
||||
{
|
||||
contentXtreamId: 2001,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 103,
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 1,
|
||||
positionSeconds: 84,
|
||||
durationSeconds: 1200,
|
||||
playlistId: 'xtream-1',
|
||||
updatedAt: '2026-05-10T12:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
seriesResumeTarget.set({
|
||||
seriesXtreamId: 103,
|
||||
contentXtreamId: 2001,
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 1,
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(constructEpisodeStreamUrl).toHaveBeenCalledTimes(1);
|
||||
expect(constructEpisodeStreamUrl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: '2001',
|
||||
season: 2,
|
||||
episode_num: 1,
|
||||
})
|
||||
);
|
||||
expect(openResolvedPlayback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
streamUrl: 'http://xtream.example/series/2001.mp4',
|
||||
startTime: 84,
|
||||
contentInfo: expect.objectContaining({
|
||||
contentXtreamId: 2001,
|
||||
seriesXtreamId: 103,
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 1,
|
||||
}),
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('does not auto-resume the dashboard episode when positions fail to load', async () => {
|
||||
const warnSpy = jest
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
getSeriesPlaybackPositions.mockRejectedValue(
|
||||
new Error('storage unavailable')
|
||||
);
|
||||
seriesResumeTarget.set({
|
||||
seriesXtreamId: 103,
|
||||
contentXtreamId: 2001,
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 1,
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(constructEpisodeStreamUrl).not.toHaveBeenCalled();
|
||||
expect(openResolvedPlayback).not.toHaveBeenCalled();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('applies streamed playback-position updates for the selected series only', async () => {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
if (!positionUpdateCallback) {
|
||||
throw new Error('expected a playback-position subscription');
|
||||
}
|
||||
|
||||
positionUpdateCallback({
|
||||
playlistId: 'other-playlist',
|
||||
contentXtreamId: 1001,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 103,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
positionSeconds: 300,
|
||||
durationSeconds: 1200,
|
||||
} as PlaybackPositionData);
|
||||
fixture.detectChanges();
|
||||
|
||||
const quickStartButton = (): HTMLButtonElement | null =>
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="series-quick-start"]'
|
||||
);
|
||||
expect(quickStartButton()?.textContent).not.toContain(
|
||||
'XTREAM.RESUME_EPISODE'
|
||||
);
|
||||
|
||||
positionUpdateCallback({
|
||||
playlistId: 'xtream-1',
|
||||
contentXtreamId: 1001,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 103,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
positionSeconds: 300,
|
||||
durationSeconds: 1200,
|
||||
} as PlaybackPositionData);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(quickStartButton()?.textContent).toContain(
|
||||
'XTREAM.RESUME_EPISODE'
|
||||
);
|
||||
expect(quickStartButton()?.textContent).toContain(
|
||||
'S01E01 · Episode 1'
|
||||
);
|
||||
});
|
||||
|
||||
it('persists the launched episode after an external fallback succeeds', async () => {
|
||||
openExternalPlayback.mockResolvedValue({
|
||||
id: 'mpv-session-1',
|
||||
player: 'mpv',
|
||||
status: 'opened',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const playbackService = fixture.debugElement.injector.get(
|
||||
SerialDetailsPlaybackService
|
||||
);
|
||||
playbackService.handleExternalFallbackRequest({
|
||||
player: 'mpv',
|
||||
playback: {
|
||||
streamUrl: 'http://xtream.example/series/2001.mp4',
|
||||
title: 'Season 2 Episode 1',
|
||||
contentInfo: {
|
||||
playlistId: 'xtream-1',
|
||||
contentXtreamId: 2001,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 103,
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 1,
|
||||
},
|
||||
},
|
||||
diagnostic: {},
|
||||
} as never);
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(openExternalPlayback).toHaveBeenCalledTimes(1);
|
||||
expect(savePlaybackPosition).toHaveBeenCalledWith(
|
||||
'xtream-1',
|
||||
expect.objectContaining({
|
||||
contentXtreamId: 2001,
|
||||
contentType: 'episode',
|
||||
positionSeconds: 0,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('persists throttled inline time updates for the playing episode', async () => {
|
||||
isEmbeddedPlayer.mockReturnValue(true);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const playbackService = fixture.debugElement.injector.get(
|
||||
SerialDetailsPlaybackService
|
||||
);
|
||||
|
||||
// Without an inline playback there is nothing to persist.
|
||||
playbackService.handleInlineTimeUpdate({
|
||||
currentTime: 10,
|
||||
duration: 100,
|
||||
});
|
||||
expect(savePlaybackPosition).not.toHaveBeenCalled();
|
||||
|
||||
fixture.componentInstance.playEpisode({
|
||||
id: '1001',
|
||||
episode_num: 1,
|
||||
title: 'Episode 1',
|
||||
season: 1,
|
||||
} as never);
|
||||
playbackService.handleInlineTimeUpdate({
|
||||
currentTime: 123.9,
|
||||
duration: 1200.4,
|
||||
});
|
||||
expect(savePlaybackPosition).toHaveBeenCalledWith(
|
||||
'xtream-1',
|
||||
expect.objectContaining({
|
||||
contentXtreamId: 1001,
|
||||
positionSeconds: 123,
|
||||
durationSeconds: 1200,
|
||||
})
|
||||
);
|
||||
|
||||
// A second update inside the 15s throttle window is skipped.
|
||||
savePlaybackPosition.mockClear();
|
||||
playbackService.handleInlineTimeUpdate({
|
||||
currentTime: 130,
|
||||
duration: 1200,
|
||||
});
|
||||
expect(savePlaybackPosition).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('saves and clears positions for season-container toggle requests', async () => {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const playbackService = fixture.debugElement.injector.get(
|
||||
SerialDetailsPlaybackService
|
||||
);
|
||||
await playbackService.handlePlaybackToggleRequested({
|
||||
contentXtreamId: 1001,
|
||||
nextPosition: {
|
||||
playlistId: 'xtream-1',
|
||||
contentXtreamId: 1001,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 103,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
positionSeconds: 950,
|
||||
durationSeconds: 1000,
|
||||
},
|
||||
} as never);
|
||||
expect(savePlaybackPosition).toHaveBeenCalledWith(
|
||||
'xtream-1',
|
||||
expect.objectContaining({ contentXtreamId: 1001 })
|
||||
);
|
||||
|
||||
await playbackService.handlePlaybackToggleRequested({
|
||||
contentXtreamId: 1001,
|
||||
nextPosition: null,
|
||||
} as never);
|
||||
expect(clearPlaybackPosition).toHaveBeenCalledWith(
|
||||
'xtream-1',
|
||||
1001,
|
||||
'episode'
|
||||
);
|
||||
});
|
||||
|
||||
it('passes inline episode metadata and autoplays only inside the current season', async () => {
|
||||
isEmbeddedPlayer.mockReturnValue(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -10,6 +10,7 @@ import { PlaylistsService } from '@iptvnator/services';
|
||||
import { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import { firstValueFrom, of } from 'rxjs';
|
||||
import { SerialDetailsComponent } from './serial-details/serial-details.component';
|
||||
import { XTREAM_SERIES_RESUME_TARGET } from './serial-details/serial-details-resume-target.token';
|
||||
import { XtreamCollectionDetailComponent } from './xtream-collection-detail.component';
|
||||
|
||||
describe('XtreamCollectionDetailComponent', () => {
|
||||
@@ -105,19 +106,23 @@ describe('XtreamCollectionDetailComponent', () => {
|
||||
});
|
||||
|
||||
it('opens Xtream series favorites with the serial detail route context', async () => {
|
||||
fixture.componentRef.setInput(
|
||||
'item',
|
||||
{
|
||||
uid: 'xtream::xtream-1::series:103',
|
||||
name: 'Series One',
|
||||
contentType: 'series',
|
||||
sourceType: 'xtream',
|
||||
playlistId: 'xtream-1',
|
||||
playlistName: 'Xtream Portal',
|
||||
xtreamId: 103,
|
||||
categoryId: 3,
|
||||
} satisfies UnifiedCollectionItem
|
||||
);
|
||||
const seriesResume = {
|
||||
seriesXtreamId: 103,
|
||||
contentXtreamId: 2001,
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 1,
|
||||
};
|
||||
fixture.componentRef.setInput('item', {
|
||||
uid: 'xtream::xtream-1::series:103',
|
||||
name: 'Series One',
|
||||
contentType: 'series',
|
||||
sourceType: 'xtream',
|
||||
playlistId: 'xtream-1',
|
||||
playlistName: 'Xtream Portal',
|
||||
xtreamId: 103,
|
||||
categoryId: 3,
|
||||
} satisfies UnifiedCollectionItem);
|
||||
fixture.componentRef.setInput('seriesResume', seriesResume);
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
@@ -140,9 +145,17 @@ describe('XtreamCollectionDetailComponent', () => {
|
||||
// toSignal(), so the fake route must expose the observable too —
|
||||
// otherwise the inline detail crashes on construction.
|
||||
expect(route?.params).toBeDefined();
|
||||
await expect(firstValueFrom(route!.params)).resolves.toEqual({
|
||||
if (!route) {
|
||||
throw new Error('expected an inline ActivatedRoute');
|
||||
}
|
||||
await expect(firstValueFrom(route.params)).resolves.toEqual({
|
||||
categoryId: '3',
|
||||
serialId: '103',
|
||||
});
|
||||
expect(
|
||||
fixture.componentInstance
|
||||
.detailInjector()
|
||||
?.get(XTREAM_SERIES_RESUME_TARGET)()
|
||||
).toEqual(seriesResume);
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { PortalDetailShellComponent } from '@iptvnator/ui/components';
|
||||
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
SeriesResumeTarget,
|
||||
UnifiedCollectionItem,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
XtreamPlaylistData,
|
||||
XtreamStore,
|
||||
@@ -21,6 +24,7 @@ import { PlaylistsService } from '@iptvnator/services';
|
||||
import { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import { firstValueFrom, of } from 'rxjs';
|
||||
import { SerialDetailsComponent } from './serial-details/serial-details.component';
|
||||
import { XTREAM_SERIES_RESUME_TARGET } from './serial-details/serial-details-resume-target.token';
|
||||
import { VodDetailsRouteComponent } from './vod-details/vod-details-route.component';
|
||||
|
||||
interface XtreamCollectionStateSnapshot {
|
||||
@@ -62,6 +66,7 @@ interface XtreamCollectionStateSnapshot {
|
||||
})
|
||||
export class XtreamCollectionDetailComponent {
|
||||
readonly item = input<UnifiedCollectionItem | null>(null);
|
||||
readonly seriesResume = input<SeriesResumeTarget | null>(null);
|
||||
|
||||
private readonly parentInjector = inject(Injector);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
@@ -154,6 +159,10 @@ export class XtreamCollectionDetailComponent {
|
||||
params: of(routeParams),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: XTREAM_SERIES_RESUME_TARGET,
|
||||
useValue: this.seriesResume,
|
||||
},
|
||||
],
|
||||
parent: this.parentInjector,
|
||||
})
|
||||
|
||||
@@ -969,6 +969,9 @@ describe('DashboardDataService', () => {
|
||||
const series = vodItems.find(
|
||||
(item) => item.title === 'Color Orchard S02E04'
|
||||
);
|
||||
if (!series) {
|
||||
throw new Error('expected the series recent item');
|
||||
}
|
||||
|
||||
// Movie: vod position with 60% watched.
|
||||
const moviePos = service.getPlaybackPositionForItem(movie!);
|
||||
@@ -977,13 +980,91 @@ describe('DashboardDataService', () => {
|
||||
|
||||
// Series: episode position keyed by the recent item's xtream_id (the
|
||||
// episode id, not the series id).
|
||||
const seriesPos = service.getPlaybackPositionForItem(series!);
|
||||
const seriesPos = service.getPlaybackPositionForItem(series);
|
||||
expect(seriesPos?.contentType).toBe('episode');
|
||||
expect(seriesPos?.positionSeconds).toBe(720);
|
||||
// Season/episode metadata travels with the position so cards can
|
||||
// render an "S2 · E5" badge without an extra round-trip.
|
||||
expect(seriesPos?.seasonNumber).toBe(2);
|
||||
expect(seriesPos?.episodeNumber).toBe(4);
|
||||
expect(service.getRecentItemNavigationState(series)).toEqual({
|
||||
openCollectionDetailItem: {
|
||||
item: expect.objectContaining({
|
||||
contentType: 'series',
|
||||
xtreamId: 900,
|
||||
}),
|
||||
seriesResume: {
|
||||
seriesXtreamId: 900,
|
||||
contentXtreamId: 909,
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 4,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps legacy episode-keyed recents detail-only when the position row lacks the parent series id', async () => {
|
||||
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
|
||||
{
|
||||
id: 103,
|
||||
category_id: 19,
|
||||
title: 'Legacy Orchard S02E04',
|
||||
rating: '8.1',
|
||||
viewed_at: '2026-04-22T10:00:00.000Z',
|
||||
poster_url: 'https://example.com/legacy-orchard.png',
|
||||
xtream_id: 909,
|
||||
type: 'series',
|
||||
playlist_id: 'xtream-L',
|
||||
playlist_name: 'Xtream Legacy',
|
||||
},
|
||||
]);
|
||||
|
||||
playlistsSignal.set([
|
||||
...playlistsSignal(),
|
||||
{
|
||||
_id: 'xtream-L',
|
||||
title: 'Xtream Legacy',
|
||||
count: 1,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
serverUrl: 'https://legacy.example.com',
|
||||
},
|
||||
]);
|
||||
|
||||
// Rows saved before seriesXtreamId existed: keyed by the episode id
|
||||
// with no pointer back to the parent series.
|
||||
playbackPositionsMock.getAllPlaybackPositions.mockResolvedValue([
|
||||
{
|
||||
contentXtreamId: 909,
|
||||
contentType: 'episode',
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 4,
|
||||
positionSeconds: 720,
|
||||
durationSeconds: 1800,
|
||||
playlistId: 'xtream-L',
|
||||
} as PlaybackPositionData,
|
||||
]);
|
||||
|
||||
await service.reloadGlobalRecentItems();
|
||||
await service.reloadPlaybackPositions();
|
||||
|
||||
const series = service
|
||||
.globalRecentVodItems()
|
||||
.find((item) => item.title === 'Legacy Orchard S02E04');
|
||||
if (!series) {
|
||||
throw new Error('expected the series recent item');
|
||||
}
|
||||
|
||||
// The episode id must not be promoted to a series id: no resume
|
||||
// target, and the detail item keeps its original identifier.
|
||||
expect(service.getRecentItemNavigationState(series)).toEqual({
|
||||
openCollectionDetailItem: {
|
||||
item: expect.objectContaining({
|
||||
contentType: 'series',
|
||||
xtreamId: 909,
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves the latest episode position for series whose recent_items row carries the series id', async () => {
|
||||
@@ -1077,6 +1158,21 @@ describe('DashboardDataService', () => {
|
||||
expect(position?.contentType).toBe('episode');
|
||||
expect(position?.seasonNumber).toBe(3);
|
||||
expect(position?.episodeNumber).toBe(7);
|
||||
expect(service.getRecentItemNavigationState(series)).toEqual({
|
||||
openCollectionDetailItem: {
|
||||
item: expect.objectContaining({
|
||||
contentType: 'series',
|
||||
sourceType: 'xtream',
|
||||
xtreamId: 4000,
|
||||
}),
|
||||
seriesResume: {
|
||||
seriesXtreamId: 4000,
|
||||
contentXtreamId: 4007,
|
||||
seasonNumber: 3,
|
||||
episodeNumber: 7,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(failOnLinearScan).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -893,7 +893,10 @@ export class DashboardDataService {
|
||||
getRecentItemNavigationState(
|
||||
item: GlobalRecentItem
|
||||
): WorkspaceNavigationTarget['state'] {
|
||||
return getRecentItemNavigationStateUtil(item);
|
||||
return getRecentItemNavigationStateUtil(
|
||||
item,
|
||||
this.getPlaybackPositionForItem(item)
|
||||
);
|
||||
}
|
||||
|
||||
async removeGlobalRecentItem(item: GlobalRecentItem): Promise<void> {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
PlaylistMeta,
|
||||
PlaybackPositionData,
|
||||
PortalActivityType,
|
||||
PortalAddedItem,
|
||||
PortalFavoriteItem,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
getGlobalFavoriteNavigation,
|
||||
getRecentItemNavigation,
|
||||
WorkspaceNavigationTarget,
|
||||
type SeriesResumeTarget,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
|
||||
/**
|
||||
@@ -58,9 +60,54 @@ export function getRecentItemLink(item: PortalRecentItem): string[] {
|
||||
}
|
||||
|
||||
export function getRecentItemNavigationState(
|
||||
item: PortalRecentItem
|
||||
item: PortalRecentItem,
|
||||
playbackPosition?: PlaybackPositionData | null
|
||||
): WorkspaceNavigationTarget['state'] {
|
||||
return getRecentItemNavigation(item).state;
|
||||
return getRecentItemNavigation(
|
||||
item,
|
||||
buildRecentSeriesResumeTarget(item, playbackPosition)
|
||||
).state;
|
||||
}
|
||||
|
||||
function buildRecentSeriesResumeTarget(
|
||||
item: PortalRecentItem,
|
||||
playbackPosition?: PlaybackPositionData | null
|
||||
): SeriesResumeTarget | null {
|
||||
if (
|
||||
item.type !== 'series' ||
|
||||
item.source !== 'xtream' ||
|
||||
playbackPosition?.contentType !== 'episode'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Episode-keyed recent rows make item.xtream_id an episode id, so only a
|
||||
// position row that names its parent series can produce a resume target;
|
||||
// legacy rows without seriesXtreamId stay detail-only.
|
||||
const seriesXtreamId = Number(playbackPosition.seriesXtreamId);
|
||||
const contentXtreamId = Number(playbackPosition.contentXtreamId);
|
||||
const seasonNumber = Number(playbackPosition.seasonNumber);
|
||||
const episodeNumber = Number(playbackPosition.episodeNumber);
|
||||
|
||||
if (
|
||||
!Number.isInteger(seriesXtreamId) ||
|
||||
seriesXtreamId <= 0 ||
|
||||
!Number.isInteger(contentXtreamId) ||
|
||||
contentXtreamId <= 0 ||
|
||||
!Number.isInteger(seasonNumber) ||
|
||||
seasonNumber < 0 ||
|
||||
!Number.isInteger(episodeNumber) ||
|
||||
episodeNumber < 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
seriesXtreamId,
|
||||
contentXtreamId,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
};
|
||||
}
|
||||
|
||||
export function getGlobalFavoriteLink(item: PortalFavoriteItem): string[] {
|
||||
|
||||
Reference in new issue
Block a user