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)
|
- 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()`)
|
- 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
|
- 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)
|
- 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")
|
- See `docs/architecture/embedded-inline-playback.md` ("Two-State Detail Layout")
|
||||||
|
|
||||||
**Radio Player**:
|
**Radio Player**:
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export default {
|
|||||||
tslib: 'tslib/tslib.es6.js',
|
tslib: 'tslib/tslib.es6.js',
|
||||||
'^iptv-playlist-parser$':
|
'^iptv-playlist-parser$':
|
||||||
'<rootDir>/src/test-stubs/iptv-playlist-parser.mjs',
|
'<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',
|
'^rxjs': '<rootDir>/../../node_modules/rxjs/dist/bundles/rxjs.umd.js',
|
||||||
'^uuid$': '<rootDir>/../../node_modules/uuid/wrapper.mjs',
|
'^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 {
|
import {
|
||||||
CollectionScope,
|
CollectionScope,
|
||||||
PortalProvider,
|
PortalProvider,
|
||||||
|
SeriesResumeTarget,
|
||||||
UnifiedCollectionItem,
|
UnifiedCollectionItem,
|
||||||
} from '@iptvnator/portal/shared/util';
|
} from '@iptvnator/portal/shared/util';
|
||||||
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
|
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
|
||||||
@@ -68,6 +69,7 @@ function providerToPortalType(provider: PortalProvider): UnifiedPortalType {
|
|||||||
})
|
})
|
||||||
export class GlobalCollectionDetailHostComponent implements OnDestroy {
|
export class GlobalCollectionDetailHostComponent implements OnDestroy {
|
||||||
readonly item = input<UnifiedCollectionItem | null>(null);
|
readonly item = input<UnifiedCollectionItem | null>(null);
|
||||||
|
readonly seriesResume = input<SeriesResumeTarget | null>(null);
|
||||||
readonly closeRequested = output<void>();
|
readonly closeRequested = output<void>();
|
||||||
|
|
||||||
private readonly viewContainer = inject(ViewContainerRef);
|
private readonly viewContainer = inject(ViewContainerRef);
|
||||||
@@ -78,9 +80,10 @@ export class GlobalCollectionDetailHostComponent implements OnDestroy {
|
|||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const item = this.item();
|
const item = this.item();
|
||||||
|
const seriesResume = this.seriesResume();
|
||||||
|
|
||||||
untracked(() => {
|
untracked(() => {
|
||||||
void this.renderDetail(item);
|
void this.renderDetail(item, seriesResume);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -91,7 +94,8 @@ export class GlobalCollectionDetailHostComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async renderDetail(
|
private async renderDetail(
|
||||||
item: UnifiedCollectionItem | null
|
item: UnifiedCollectionItem | null,
|
||||||
|
seriesResume: SeriesResumeTarget | null
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const requestId = ++this.renderRequestId;
|
const requestId = ++this.renderRequestId;
|
||||||
this.clearDetail();
|
this.clearDetail();
|
||||||
@@ -109,6 +113,9 @@ export class GlobalCollectionDetailHostComponent implements OnDestroy {
|
|||||||
environmentInjector: this.environmentInjector,
|
environmentInjector: this.environmentInjector,
|
||||||
});
|
});
|
||||||
componentRef.setInput('item', item);
|
componentRef.setInput('item', item);
|
||||||
|
if (item.sourceType === 'xtream') {
|
||||||
|
componentRef.setInput('seriesResume', seriesResume);
|
||||||
|
}
|
||||||
this.subscribeToClose(componentRef.instance);
|
this.subscribeToClose(componentRef.instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,9 +182,17 @@ export class GlobalCollectionDetailHostComponent implements OnDestroy {
|
|||||||
[portalType]="activePortalType() ?? undefined"
|
[portalType]="activePortalType() ?? undefined"
|
||||||
[defaultScope]="effectiveDefaultScope()"
|
[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') {
|
@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') {
|
} @else if (item.sourceType === 'stalker') {
|
||||||
<app-global-collection-detail-host
|
<app-global-collection-detail-host
|
||||||
[item]="item"
|
[item]="item"
|
||||||
|
|||||||
@@ -789,6 +789,7 @@
|
|||||||
"RESTART": "البدء من جديد",
|
"RESTART": "البدء من جديد",
|
||||||
"CONTINUE_WATCHING": "متابعة المشاهدة",
|
"CONTINUE_WATCHING": "متابعة المشاهدة",
|
||||||
"PLAY_FIRST_EPISODE": "تشغيل الحلقة الأولى",
|
"PLAY_FIRST_EPISODE": "تشغيل الحلقة الأولى",
|
||||||
|
"PLAY_EPISODE": "تشغيل الحلقة {{episode}}",
|
||||||
"RESUME_EPISODE": "استئناف الحلقة",
|
"RESUME_EPISODE": "استئناف الحلقة",
|
||||||
"PLAY_NEXT_EPISODE": "تشغيل الحلقة التالية",
|
"PLAY_NEXT_EPISODE": "تشغيل الحلقة التالية",
|
||||||
"SERIES_WATCHED": "تمت مشاهدة المسلسل",
|
"SERIES_WATCHED": "تمت مشاهدة المسلسل",
|
||||||
|
|||||||
@@ -789,6 +789,7 @@
|
|||||||
"RESTART": "بدا من الأول",
|
"RESTART": "بدا من الأول",
|
||||||
"CONTINUE_WATCHING": "كمل المشاهدة",
|
"CONTINUE_WATCHING": "كمل المشاهدة",
|
||||||
"PLAY_FIRST_EPISODE": "شغل الحلقة الأولى",
|
"PLAY_FIRST_EPISODE": "شغل الحلقة الأولى",
|
||||||
|
"PLAY_EPISODE": "شغل الحلقة {{episode}}",
|
||||||
"RESUME_EPISODE": "كمل الحلقة",
|
"RESUME_EPISODE": "كمل الحلقة",
|
||||||
"PLAY_NEXT_EPISODE": "شغل الحلقة الجاية",
|
"PLAY_NEXT_EPISODE": "شغل الحلقة الجاية",
|
||||||
"SERIES_WATCHED": "السلسلة متشافة",
|
"SERIES_WATCHED": "السلسلة متشافة",
|
||||||
|
|||||||
@@ -789,6 +789,7 @@
|
|||||||
"RESTART": "Спачатку",
|
"RESTART": "Спачатку",
|
||||||
"CONTINUE_WATCHING": "Працягнуць прагляд",
|
"CONTINUE_WATCHING": "Працягнуць прагляд",
|
||||||
"PLAY_FIRST_EPISODE": "Прайграць першы эпізод",
|
"PLAY_FIRST_EPISODE": "Прайграць першы эпізод",
|
||||||
|
"PLAY_EPISODE": "Прайграць эпізод {{episode}}",
|
||||||
"RESUME_EPISODE": "Працягнуць эпізод",
|
"RESUME_EPISODE": "Працягнуць эпізод",
|
||||||
"PLAY_NEXT_EPISODE": "Прайграць наступны эпізод",
|
"PLAY_NEXT_EPISODE": "Прайграць наступны эпізод",
|
||||||
"SERIES_WATCHED": "Серыял прагледжаны",
|
"SERIES_WATCHED": "Серыял прагледжаны",
|
||||||
|
|||||||
@@ -789,6 +789,7 @@
|
|||||||
"RESTART": "Neu starten",
|
"RESTART": "Neu starten",
|
||||||
"CONTINUE_WATCHING": "Weiter schauen",
|
"CONTINUE_WATCHING": "Weiter schauen",
|
||||||
"PLAY_FIRST_EPISODE": "Erste Folge abspielen",
|
"PLAY_FIRST_EPISODE": "Erste Folge abspielen",
|
||||||
|
"PLAY_EPISODE": "Folge {{episode}} abspielen",
|
||||||
"RESUME_EPISODE": "Folge fortsetzen",
|
"RESUME_EPISODE": "Folge fortsetzen",
|
||||||
"PLAY_NEXT_EPISODE": "Nächste Folge abspielen",
|
"PLAY_NEXT_EPISODE": "Nächste Folge abspielen",
|
||||||
"SERIES_WATCHED": "Serie angesehen",
|
"SERIES_WATCHED": "Serie angesehen",
|
||||||
|
|||||||
@@ -789,6 +789,7 @@
|
|||||||
"RESTART": "Από την αρχή",
|
"RESTART": "Από την αρχή",
|
||||||
"CONTINUE_WATCHING": "Συνέχιση παρακολούθησης",
|
"CONTINUE_WATCHING": "Συνέχιση παρακολούθησης",
|
||||||
"PLAY_FIRST_EPISODE": "Αναπαραγωγή πρώτου επεισοδίου",
|
"PLAY_FIRST_EPISODE": "Αναπαραγωγή πρώτου επεισοδίου",
|
||||||
|
"PLAY_EPISODE": "Αναπαραγωγή επεισοδίου {{episode}}",
|
||||||
"RESUME_EPISODE": "Συνέχιση επεισοδίου",
|
"RESUME_EPISODE": "Συνέχιση επεισοδίου",
|
||||||
"PLAY_NEXT_EPISODE": "Αναπαραγωγή επόμενου επεισοδίου",
|
"PLAY_NEXT_EPISODE": "Αναπαραγωγή επόμενου επεισοδίου",
|
||||||
"SERIES_WATCHED": "Η σειρά προβλήθηκε",
|
"SERIES_WATCHED": "Η σειρά προβλήθηκε",
|
||||||
|
|||||||
@@ -789,6 +789,7 @@
|
|||||||
"RESTART": "Start Over",
|
"RESTART": "Start Over",
|
||||||
"CONTINUE_WATCHING": "Continue Watching",
|
"CONTINUE_WATCHING": "Continue Watching",
|
||||||
"PLAY_FIRST_EPISODE": "Play first episode",
|
"PLAY_FIRST_EPISODE": "Play first episode",
|
||||||
|
"PLAY_EPISODE": "Play episode {{episode}}",
|
||||||
"RESUME_EPISODE": "Resume episode",
|
"RESUME_EPISODE": "Resume episode",
|
||||||
"PLAY_NEXT_EPISODE": "Play next episode",
|
"PLAY_NEXT_EPISODE": "Play next episode",
|
||||||
"SERIES_WATCHED": "Series watched",
|
"SERIES_WATCHED": "Series watched",
|
||||||
|
|||||||
@@ -789,6 +789,7 @@
|
|||||||
"RESTART": "Reiniciar",
|
"RESTART": "Reiniciar",
|
||||||
"CONTINUE_WATCHING": "Continuar viendo",
|
"CONTINUE_WATCHING": "Continuar viendo",
|
||||||
"PLAY_FIRST_EPISODE": "Reproducir primer episodio",
|
"PLAY_FIRST_EPISODE": "Reproducir primer episodio",
|
||||||
|
"PLAY_EPISODE": "Reproducir episodio {{episode}}",
|
||||||
"RESUME_EPISODE": "Continuar episodio",
|
"RESUME_EPISODE": "Continuar episodio",
|
||||||
"PLAY_NEXT_EPISODE": "Reproducir siguiente episodio",
|
"PLAY_NEXT_EPISODE": "Reproducir siguiente episodio",
|
||||||
"SERIES_WATCHED": "Serie vista",
|
"SERIES_WATCHED": "Serie vista",
|
||||||
|
|||||||
@@ -789,6 +789,7 @@
|
|||||||
"RESTART": "Recommencer",
|
"RESTART": "Recommencer",
|
||||||
"CONTINUE_WATCHING": "Reprendre la lecture",
|
"CONTINUE_WATCHING": "Reprendre la lecture",
|
||||||
"PLAY_FIRST_EPISODE": "Lire le premier épisode",
|
"PLAY_FIRST_EPISODE": "Lire le premier épisode",
|
||||||
|
"PLAY_EPISODE": "Lire l'épisode {{episode}}",
|
||||||
"RESUME_EPISODE": "Reprendre l'épisode",
|
"RESUME_EPISODE": "Reprendre l'épisode",
|
||||||
"PLAY_NEXT_EPISODE": "Lire l'épisode suivant",
|
"PLAY_NEXT_EPISODE": "Lire l'épisode suivant",
|
||||||
"SERIES_WATCHED": "Série vue",
|
"SERIES_WATCHED": "Série vue",
|
||||||
|
|||||||
@@ -789,6 +789,7 @@
|
|||||||
"RESTART": "Ricomincia",
|
"RESTART": "Ricomincia",
|
||||||
"CONTINUE_WATCHING": "Continua a guardare",
|
"CONTINUE_WATCHING": "Continua a guardare",
|
||||||
"PLAY_FIRST_EPISODE": "Riproduci primo episodio",
|
"PLAY_FIRST_EPISODE": "Riproduci primo episodio",
|
||||||
|
"PLAY_EPISODE": "Riproduci episodio {{episode}}",
|
||||||
"RESUME_EPISODE": "Riprendi episodio",
|
"RESUME_EPISODE": "Riprendi episodio",
|
||||||
"PLAY_NEXT_EPISODE": "Riproduci episodio successivo",
|
"PLAY_NEXT_EPISODE": "Riproduci episodio successivo",
|
||||||
"SERIES_WATCHED": "Serie vista",
|
"SERIES_WATCHED": "Serie vista",
|
||||||
|
|||||||
@@ -789,6 +789,7 @@
|
|||||||
"RESTART": "最初から再生",
|
"RESTART": "最初から再生",
|
||||||
"CONTINUE_WATCHING": "視聴を再開",
|
"CONTINUE_WATCHING": "視聴を再開",
|
||||||
"PLAY_FIRST_EPISODE": "第1話を再生",
|
"PLAY_FIRST_EPISODE": "第1話を再生",
|
||||||
|
"PLAY_EPISODE": "第{{episode}}話を再生",
|
||||||
"RESUME_EPISODE": "エピソードを再開",
|
"RESUME_EPISODE": "エピソードを再開",
|
||||||
"PLAY_NEXT_EPISODE": "次のエピソードを再生",
|
"PLAY_NEXT_EPISODE": "次のエピソードを再生",
|
||||||
"SERIES_WATCHED": "シリーズ視聴済み",
|
"SERIES_WATCHED": "シリーズ視聴済み",
|
||||||
|
|||||||
@@ -789,6 +789,7 @@
|
|||||||
"RESTART": "처음부터 보기",
|
"RESTART": "처음부터 보기",
|
||||||
"CONTINUE_WATCHING": "이어서 시청",
|
"CONTINUE_WATCHING": "이어서 시청",
|
||||||
"PLAY_FIRST_EPISODE": "첫 에피소드 재생",
|
"PLAY_FIRST_EPISODE": "첫 에피소드 재생",
|
||||||
|
"PLAY_EPISODE": "에피소드 {{episode}} 재생",
|
||||||
"RESUME_EPISODE": "에피소드 이어 보기",
|
"RESUME_EPISODE": "에피소드 이어 보기",
|
||||||
"PLAY_NEXT_EPISODE": "다음 에피소드 재생",
|
"PLAY_NEXT_EPISODE": "다음 에피소드 재생",
|
||||||
"SERIES_WATCHED": "시리즈 시청 완료",
|
"SERIES_WATCHED": "시리즈 시청 완료",
|
||||||
|
|||||||
@@ -789,6 +789,7 @@
|
|||||||
"RESTART": "Opnieuw beginnen",
|
"RESTART": "Opnieuw beginnen",
|
||||||
"CONTINUE_WATCHING": "Verder kijken",
|
"CONTINUE_WATCHING": "Verder kijken",
|
||||||
"PLAY_FIRST_EPISODE": "Eerste aflevering afspelen",
|
"PLAY_FIRST_EPISODE": "Eerste aflevering afspelen",
|
||||||
|
"PLAY_EPISODE": "Aflevering {{episode}} afspelen",
|
||||||
"RESUME_EPISODE": "Aflevering hervatten",
|
"RESUME_EPISODE": "Aflevering hervatten",
|
||||||
"PLAY_NEXT_EPISODE": "Volgende aflevering afspelen",
|
"PLAY_NEXT_EPISODE": "Volgende aflevering afspelen",
|
||||||
"SERIES_WATCHED": "Serie bekeken",
|
"SERIES_WATCHED": "Serie bekeken",
|
||||||
|
|||||||
@@ -789,6 +789,7 @@
|
|||||||
"RESTART": "Zacznij od nowa",
|
"RESTART": "Zacznij od nowa",
|
||||||
"CONTINUE_WATCHING": "Kontynuuj oglądanie",
|
"CONTINUE_WATCHING": "Kontynuuj oglądanie",
|
||||||
"PLAY_FIRST_EPISODE": "Odtwórz pierwszy odcinek",
|
"PLAY_FIRST_EPISODE": "Odtwórz pierwszy odcinek",
|
||||||
|
"PLAY_EPISODE": "Odtwórz odcinek {{episode}}",
|
||||||
"RESUME_EPISODE": "Wznów odcinek",
|
"RESUME_EPISODE": "Wznów odcinek",
|
||||||
"PLAY_NEXT_EPISODE": "Odtwórz następny odcinek",
|
"PLAY_NEXT_EPISODE": "Odtwórz następny odcinek",
|
||||||
"SERIES_WATCHED": "Serial obejrzany",
|
"SERIES_WATCHED": "Serial obejrzany",
|
||||||
|
|||||||
@@ -789,6 +789,7 @@
|
|||||||
"RESTART": "Recomeçar",
|
"RESTART": "Recomeçar",
|
||||||
"CONTINUE_WATCHING": "Continuar assistindo",
|
"CONTINUE_WATCHING": "Continuar assistindo",
|
||||||
"PLAY_FIRST_EPISODE": "Reproduzir primeiro episódio",
|
"PLAY_FIRST_EPISODE": "Reproduzir primeiro episódio",
|
||||||
|
"PLAY_EPISODE": "Reproduzir episódio {{episode}}",
|
||||||
"RESUME_EPISODE": "Continuar episódio",
|
"RESUME_EPISODE": "Continuar episódio",
|
||||||
"PLAY_NEXT_EPISODE": "Reproduzir próximo episódio",
|
"PLAY_NEXT_EPISODE": "Reproduzir próximo episódio",
|
||||||
"SERIES_WATCHED": "Série assistida",
|
"SERIES_WATCHED": "Série assistida",
|
||||||
|
|||||||
@@ -789,6 +789,7 @@
|
|||||||
"RESTART": "С начала",
|
"RESTART": "С начала",
|
||||||
"CONTINUE_WATCHING": "Продолжить просмотр",
|
"CONTINUE_WATCHING": "Продолжить просмотр",
|
||||||
"PLAY_FIRST_EPISODE": "Воспроизвести первую серию",
|
"PLAY_FIRST_EPISODE": "Воспроизвести первую серию",
|
||||||
|
"PLAY_EPISODE": "Воспроизвести серию {{episode}}",
|
||||||
"RESUME_EPISODE": "Продолжить серию",
|
"RESUME_EPISODE": "Продолжить серию",
|
||||||
"PLAY_NEXT_EPISODE": "Следующая серия",
|
"PLAY_NEXT_EPISODE": "Следующая серия",
|
||||||
"SERIES_WATCHED": "Сериал просмотрен",
|
"SERIES_WATCHED": "Сериал просмотрен",
|
||||||
|
|||||||
@@ -789,6 +789,7 @@
|
|||||||
"RESTART": "Baştan Başla",
|
"RESTART": "Baştan Başla",
|
||||||
"CONTINUE_WATCHING": "İzlemeye Devam Et",
|
"CONTINUE_WATCHING": "İzlemeye Devam Et",
|
||||||
"PLAY_FIRST_EPISODE": "İlk bölümü oynat",
|
"PLAY_FIRST_EPISODE": "İlk bölümü oynat",
|
||||||
|
"PLAY_EPISODE": "{{episode}}. bölümü oynat",
|
||||||
"RESUME_EPISODE": "Bölüme devam et",
|
"RESUME_EPISODE": "Bölüme devam et",
|
||||||
"PLAY_NEXT_EPISODE": "Sonraki bölümü oynat",
|
"PLAY_NEXT_EPISODE": "Sonraki bölümü oynat",
|
||||||
"SERIES_WATCHED": "Dizi izlendi",
|
"SERIES_WATCHED": "Dizi izlendi",
|
||||||
|
|||||||
@@ -789,6 +789,7 @@
|
|||||||
"RESTART": "从头开始",
|
"RESTART": "从头开始",
|
||||||
"CONTINUE_WATCHING": "继续观看",
|
"CONTINUE_WATCHING": "继续观看",
|
||||||
"PLAY_FIRST_EPISODE": "播放第一集",
|
"PLAY_FIRST_EPISODE": "播放第一集",
|
||||||
|
"PLAY_EPISODE": "播放第 {{episode}} 集",
|
||||||
"RESUME_EPISODE": "继续播放本集",
|
"RESUME_EPISODE": "继续播放本集",
|
||||||
"PLAY_NEXT_EPISODE": "播放下一集",
|
"PLAY_NEXT_EPISODE": "播放下一集",
|
||||||
"SERIES_WATCHED": "剧集已看完",
|
"SERIES_WATCHED": "剧集已看完",
|
||||||
|
|||||||
@@ -789,6 +789,7 @@
|
|||||||
"RESTART": "從頭開始",
|
"RESTART": "從頭開始",
|
||||||
"CONTINUE_WATCHING": "繼續觀看",
|
"CONTINUE_WATCHING": "繼續觀看",
|
||||||
"PLAY_FIRST_EPISODE": "播放第一集",
|
"PLAY_FIRST_EPISODE": "播放第一集",
|
||||||
|
"PLAY_EPISODE": "播放第 {{episode}} 集",
|
||||||
"RESUME_EPISODE": "繼續播放本集",
|
"RESUME_EPISODE": "繼續播放本集",
|
||||||
"PLAY_NEXT_EPISODE": "播放下一集",
|
"PLAY_NEXT_EPISODE": "播放下一集",
|
||||||
"SERIES_WATCHED": "劇集已看完",
|
"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
|
The detail or collection/search host owns inline state. `PlayerService` is not
|
||||||
an owner of embedded UI playback state.
|
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
|
## Series Quick Start CTA
|
||||||
|
|
||||||
Xtream and Stalker series detail views share the quick-start decision helper in
|
Xtream and Stalker series detail views share the quick-start decision helper in
|
||||||
@@ -189,6 +196,9 @@ Current contract:
|
|||||||
`S01E02 · Episode title`
|
`S01E02 · Episode title`
|
||||||
- if an episode is in progress, resume the latest updated in-progress episode
|
- if an episode is in progress, resume the latest updated in-progress episode
|
||||||
with its saved offset
|
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 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
|
- if watched episodes end at a season boundary, play the first episode of the
|
||||||
next loaded season
|
next loaded season
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ Related:
|
|||||||
- Dashboard `Global Favorites` and `Recently Watched` widgets hand off Xtream and
|
- Dashboard `Global Favorites` and `Recently Watched` widgets hand off Xtream and
|
||||||
Stalker movies/series into the matching global collection route with detail
|
Stalker movies/series into the matching global collection route with detail
|
||||||
pre-opened.
|
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
|
- Do not force both portals into the same browse/detail behavior unless the full
|
||||||
portal detail architecture is being changed.
|
portal detail architecture is being changed.
|
||||||
|
|
||||||
@@ -73,6 +79,14 @@ Dashboard behavior to preserve:
|
|||||||
Xtream movie/series items into `/workspace/global-favorites` or
|
Xtream movie/series items into `/workspace/global-favorites` or
|
||||||
`/workspace/global-recent` with collection detail pre-opened from navigation
|
`/workspace/global-recent` with collection detail pre-opened from navigation
|
||||||
state.
|
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,
|
- Back from the collection detail should return to the dashboard handoff state,
|
||||||
not switch the active playlist.
|
not switch the active playlist.
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,11 @@ Render rules:
|
|||||||
hero and cards can show progress, remaining time, and series season/
|
hero and cards can show progress, remaining time, and series season/
|
||||||
episode badges. Series lookup uses keyed maps for both direct episode ids
|
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.
|
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,
|
3. `liveOnFavoritesCardsEnriched` — maps favorited live channels first,
|
||||||
falling back to recently watched live channels when no live favorites
|
falling back to recently watched live channels when no live favorites
|
||||||
exist. M3U cards carry an `epg_lookup_key` using the app-wide XMLTV
|
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.
|
rails have data.
|
||||||
5. Navigation from a rail card must deep-link into the appropriate workspace
|
5. Navigation from a rail card must deep-link into the appropriate workspace
|
||||||
route without switching the active playlist in the header switcher.
|
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.
|
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_FAVORITES`; recently watched fallback uses
|
||||||
`WORKSPACE.DASHBOARD.LIVE_RECENT`.
|
`WORKSPACE.DASHBOARD.LIVE_RECENT`.
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -1,9 +1,13 @@
|
|||||||
import { Directive, inject, TemplateRef } from '@angular/core';
|
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 {
|
export interface UnifiedCollectionDetailContext {
|
||||||
$implicit: UnifiedCollectionItem;
|
$implicit: UnifiedCollectionItem;
|
||||||
item: UnifiedCollectionItem;
|
item: UnifiedCollectionItem;
|
||||||
|
seriesResume: SeriesResumeTarget | null;
|
||||||
close: () => void;
|
close: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+103
-2
@@ -76,8 +76,15 @@ class StubUnifiedGridTabComponent {
|
|||||||
[portalType]="portalType"
|
[portalType]="portalType"
|
||||||
[defaultScope]="defaultScope"
|
[defaultScope]="defaultScope"
|
||||||
>
|
>
|
||||||
<ng-template unifiedCollectionDetail let-item>
|
<ng-template
|
||||||
|
unifiedCollectionDetail
|
||||||
|
let-item
|
||||||
|
let-seriesResume="seriesResume"
|
||||||
|
>
|
||||||
<div class="detail-probe">{{ item.name }}</div>
|
<div class="detail-probe">{{ item.name }}</div>
|
||||||
|
<div class="resume-probe">
|
||||||
|
{{ seriesResume?.contentXtreamId }}
|
||||||
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-unified-collection-page>
|
</app-unified-collection-page>
|
||||||
`,
|
`,
|
||||||
@@ -295,10 +302,10 @@ describe('UnifiedCollectionPageComponent', () => {
|
|||||||
})
|
})
|
||||||
.compileComponents();
|
.compileComponents();
|
||||||
|
|
||||||
|
window.history.replaceState({}, document.title);
|
||||||
fixture = TestBed.createComponent(UnifiedCollectionPageComponent);
|
fixture = TestBed.createComponent(UnifiedCollectionPageComponent);
|
||||||
fixture.componentRef.setInput('mode', 'favorites');
|
fixture.componentRef.setInput('mode', 'favorites');
|
||||||
fixture.componentRef.setInput('defaultScope', 'all');
|
fixture.componentRef.setInput('defaultScope', 'all');
|
||||||
window.history.replaceState({}, document.title);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reloads favorites after playlist hydration completes', async () => {
|
it('reloads favorites after playlist hydration completes', async () => {
|
||||||
@@ -712,9 +719,103 @@ describe('UnifiedCollectionPageComponent', () => {
|
|||||||
).toBe('movie');
|
).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 () => {
|
it('restores collection scope and selected content type from history state', async () => {
|
||||||
setRouteParams({ id: 'playlist-1' });
|
setRouteParams({ id: 'playlist-1' });
|
||||||
playlistsLoaded.set(true);
|
playlistsLoaded.set(true);
|
||||||
|
fixture.destroy();
|
||||||
window.history.replaceState(
|
window.history.replaceState(
|
||||||
{
|
{
|
||||||
[COLLECTION_VIEW_STATE_KEY]: {
|
[COLLECTION_VIEW_STATE_KEY]: {
|
||||||
|
|||||||
+28
-7
@@ -47,6 +47,7 @@ import {
|
|||||||
routeParamSignal,
|
routeParamSignal,
|
||||||
ScopeToggleService,
|
ScopeToggleService,
|
||||||
STALKER_RETURN_TO_STATE_KEY,
|
STALKER_RETURN_TO_STATE_KEY,
|
||||||
|
SeriesResumeTarget,
|
||||||
UnifiedCollectionItem,
|
UnifiedCollectionItem,
|
||||||
WorkspaceViewCommandService,
|
WorkspaceViewCommandService,
|
||||||
} from '@iptvnator/portal/shared/util';
|
} from '@iptvnator/portal/shared/util';
|
||||||
@@ -144,6 +145,9 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
|
|||||||
this.historyCollectionViewState()?.selectedContentType ?? 'live'
|
this.historyCollectionViewState()?.selectedContentType ?? 'live'
|
||||||
);
|
);
|
||||||
readonly selectedDetailItem = signal<UnifiedCollectionItem | null>(null);
|
readonly selectedDetailItem = signal<UnifiedCollectionItem | null>(null);
|
||||||
|
readonly selectedDetailSeriesResume = signal<SeriesResumeTarget | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
readonly pendingAutoOpenLiveItem = signal(
|
readonly pendingAutoOpenLiveItem = signal(
|
||||||
getOpenLiveCollectionItemState(window.history.state)
|
getOpenLiveCollectionItemState(window.history.state)
|
||||||
);
|
);
|
||||||
@@ -157,6 +161,7 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
|
|||||||
return {
|
return {
|
||||||
$implicit: item,
|
$implicit: item,
|
||||||
item,
|
item,
|
||||||
|
seriesResume: this.selectedDetailSeriesResume(),
|
||||||
close: this.requestCloseDetail,
|
close: this.requestCloseDetail,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -360,7 +365,10 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigation = this.getGlobalCollectionDetailNavigation(item);
|
const navigation = this.getGlobalCollectionDetailNavigation(
|
||||||
|
item,
|
||||||
|
this.selectedDetailSeriesResume()
|
||||||
|
);
|
||||||
if (!navigation) {
|
if (!navigation) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -761,7 +769,10 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
|
|||||||
return !portalType || portalType === item.sourceType;
|
return !portalType || portalType === item.sourceType;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getGlobalCollectionDetailNavigation(item: UnifiedCollectionItem) {
|
private getGlobalCollectionDetailNavigation(
|
||||||
|
item: UnifiedCollectionItem,
|
||||||
|
seriesResume?: SeriesResumeTarget | null
|
||||||
|
) {
|
||||||
if (
|
if (
|
||||||
item.contentType === 'live' ||
|
item.contentType === 'live' ||
|
||||||
(item.sourceType !== 'xtream' && item.sourceType !== 'stalker')
|
(item.sourceType !== 'xtream' && item.sourceType !== 'stalker')
|
||||||
@@ -769,16 +780,25 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
|
|||||||
return null;
|
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.selectedContentType.set(item.contentType);
|
||||||
this.selectedDetailItem.set(item);
|
this.selectedDetailItem.set(item);
|
||||||
|
this.selectedDetailSeriesResume.set(seriesResume ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearInlineDetail(): void {
|
private clearInlineDetail(): void {
|
||||||
this.selectedDetailItem.set(null);
|
this.selectedDetailItem.set(null);
|
||||||
|
this.selectedDetailSeriesResume.set(null);
|
||||||
this.autoSelectContentType();
|
this.autoSelectContentType();
|
||||||
clearNavigationStateKeys([OPEN_COLLECTION_DETAIL_STATE_KEY]);
|
clearNavigationStateKeys([OPEN_COLLECTION_DETAIL_STATE_KEY]);
|
||||||
}
|
}
|
||||||
@@ -842,12 +862,13 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private syncDetailFromHistoryState(): void {
|
private syncDetailFromHistoryState(): void {
|
||||||
const detailItem = getOpenCollectionDetailItemState(
|
const detailState = getOpenCollectionDetailItemState(
|
||||||
window.history.state
|
window.history.state
|
||||||
)?.item;
|
);
|
||||||
|
const detailItem = detailState?.item;
|
||||||
|
|
||||||
if (detailItem && this.canOpenInlineDetail(detailItem)) {
|
if (detailItem && this.canOpenInlineDetail(detailItem)) {
|
||||||
this.openInlineDetail(detailItem);
|
this.openInlineDetail(detailItem, detailState.seriesResume);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { UnifiedCollectionItem } from '../collection/unified-collection-item.interface';
|
||||||
import {
|
import {
|
||||||
buildCollectionViewState,
|
buildCollectionViewState,
|
||||||
|
buildOpenCollectionDetailItemState,
|
||||||
buildStalkerStateItem,
|
buildStalkerStateItem,
|
||||||
buildXtreamItemLink,
|
buildXtreamItemLink,
|
||||||
COLLECTION_VIEW_STATE_KEY,
|
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', () => {
|
it('matches collection live state against multiple live item identifiers', () => {
|
||||||
expect(
|
expect(
|
||||||
matchesOpenLiveCollectionItem(
|
matchesOpenLiveCollectionItem(
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ export interface OpenLiveCollectionItemState {
|
|||||||
|
|
||||||
export interface OpenCollectionDetailItemState {
|
export interface OpenCollectionDetailItemState {
|
||||||
item: UnifiedCollectionItem;
|
item: UnifiedCollectionItem;
|
||||||
|
seriesResume?: SeriesResumeTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeriesResumeTarget {
|
||||||
|
seriesXtreamId: number;
|
||||||
|
contentXtreamId: number;
|
||||||
|
seasonNumber: number;
|
||||||
|
episodeNumber: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CollectionViewState {
|
export interface CollectionViewState {
|
||||||
@@ -44,7 +52,8 @@ export interface CollectionViewState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getRecentItemNavigation(
|
export function getRecentItemNavigation(
|
||||||
item: PortalRecentItem
|
item: PortalRecentItem,
|
||||||
|
seriesResume?: SeriesResumeTarget | null
|
||||||
): WorkspaceNavigationTarget {
|
): WorkspaceNavigationTarget {
|
||||||
if (item.type === 'live') {
|
if (item.type === 'live') {
|
||||||
return buildLiveCollectionNavigationTarget({
|
return buildLiveCollectionNavigationTarget({
|
||||||
@@ -59,9 +68,24 @@ export function getRecentItemNavigation(
|
|||||||
|
|
||||||
const collectionItem = buildDashboardCollectionDetailItem(item);
|
const collectionItem = buildDashboardCollectionDetailItem(item);
|
||||||
if (collectionItem) {
|
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(
|
return buildGlobalCollectionDetailNavigationTarget(
|
||||||
'recent',
|
'recent',
|
||||||
collectionItem
|
detailItem,
|
||||||
|
item.type === 'series' ? seriesResume : null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +204,8 @@ export function buildLiveCollectionNavigationTarget(params: {
|
|||||||
|
|
||||||
export function buildGlobalCollectionDetailNavigationTarget(
|
export function buildGlobalCollectionDetailNavigationTarget(
|
||||||
mode: 'favorites' | 'recent',
|
mode: 'favorites' | 'recent',
|
||||||
item: UnifiedCollectionItem
|
item: UnifiedCollectionItem,
|
||||||
|
seriesResume?: SeriesResumeTarget | null
|
||||||
): WorkspaceNavigationTarget {
|
): WorkspaceNavigationTarget {
|
||||||
return {
|
return {
|
||||||
link: [
|
link: [
|
||||||
@@ -189,7 +214,7 @@ export function buildGlobalCollectionDetailNavigationTarget(
|
|||||||
],
|
],
|
||||||
state: {
|
state: {
|
||||||
[OPEN_COLLECTION_DETAIL_STATE_KEY]:
|
[OPEN_COLLECTION_DETAIL_STATE_KEY]:
|
||||||
buildOpenCollectionDetailItemState(item),
|
buildOpenCollectionDetailItemState(item, seriesResume),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -228,10 +253,13 @@ export function getCollectionViewState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildOpenCollectionDetailItemState(
|
export function buildOpenCollectionDetailItemState(
|
||||||
item: UnifiedCollectionItem
|
item: UnifiedCollectionItem,
|
||||||
|
seriesResume?: SeriesResumeTarget | null
|
||||||
): OpenCollectionDetailItemState {
|
): OpenCollectionDetailItemState {
|
||||||
|
const normalizedResume = normalizeSeriesResumeTarget(seriesResume);
|
||||||
return {
|
return {
|
||||||
item: normalizeCollectionDetailItem(item) ?? item,
|
item: normalizeCollectionDetailItem(item) ?? item,
|
||||||
|
...(normalizedResume ? { seriesResume: normalizedResume } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,8 +269,18 @@ export function getOpenCollectionDetailItemState(
|
|||||||
const record = toStateRecord(state);
|
const record = toStateRecord(state);
|
||||||
const candidate = toStateRecord(record?.[OPEN_COLLECTION_DETAIL_STATE_KEY]);
|
const candidate = toStateRecord(record?.[OPEN_COLLECTION_DETAIL_STATE_KEY]);
|
||||||
const item = normalizeCollectionDetailItem(candidate?.['item']);
|
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: {
|
export function buildOpenLiveCollectionItemState(params: {
|
||||||
@@ -668,6 +706,40 @@ function toOptionalNumber(value: unknown): number | undefined {
|
|||||||
return Number.isFinite(normalized) ? normalized : 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(
|
function normalizeCollectionDetailItem(
|
||||||
candidate: unknown
|
candidate: unknown
|
||||||
): UnifiedCollectionItem | null {
|
): UnifiedCollectionItem | null {
|
||||||
|
|||||||
@@ -93,6 +93,42 @@ describe('getSeriesQuickStartAction', () => {
|
|||||||
expect(action?.position?.positionSeconds).toBe(30);
|
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', () => {
|
it('plays the next unwatched episode after watched episodes', () => {
|
||||||
const nextEpisode = episode(102, 1, 2);
|
const nextEpisode = episode(102, 1, 2);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
|
|
||||||
export const SERIES_QUICK_START_ACTION_KIND = {
|
export const SERIES_QUICK_START_ACTION_KIND = {
|
||||||
PlayFirst: 'play-first',
|
PlayFirst: 'play-first',
|
||||||
|
PlayRecent: 'play-recent',
|
||||||
Resume: 'resume',
|
Resume: 'resume',
|
||||||
PlayNext: 'play-next',
|
PlayNext: 'play-next',
|
||||||
Completed: 'completed',
|
Completed: 'completed',
|
||||||
@@ -17,6 +18,7 @@ export type SeriesQuickStartActionKind =
|
|||||||
export interface SeriesQuickStartAction {
|
export interface SeriesQuickStartAction {
|
||||||
kind: SeriesQuickStartActionKind;
|
kind: SeriesQuickStartActionKind;
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
|
labelParams?: Record<string, number>;
|
||||||
episodeLabel: string;
|
episodeLabel: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
episode: XtreamSerieEpisode;
|
episode: XtreamSerieEpisode;
|
||||||
@@ -49,18 +51,37 @@ export function getSeriesQuickStartAction(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const inProgress = orderedEpisodes
|
const startedEpisodes = orderedEpisodes
|
||||||
.filter(({ position }) => isPortalPlaybackInProgress(position))
|
.filter(
|
||||||
.sort(compareInProgressPositions);
|
({ position }) =>
|
||||||
|
position !== null && !isPortalPlaybackWatched(position)
|
||||||
|
)
|
||||||
|
.sort(comparePlaybackPositionRecency);
|
||||||
|
|
||||||
const resumeEpisode = inProgress[inProgress.length - 1];
|
const latestStartedEpisode = startedEpisodes[startedEpisodes.length - 1];
|
||||||
if (resumeEpisode?.position) {
|
if (latestStartedEpisode?.position) {
|
||||||
|
const shouldResume = isPortalPlaybackInProgress(
|
||||||
|
latestStartedEpisode.position
|
||||||
|
);
|
||||||
return createQuickStartAction({
|
return createQuickStartAction({
|
||||||
kind: SERIES_QUICK_START_ACTION_KIND.Resume,
|
kind: shouldResume
|
||||||
labelKey: 'XTREAM.RESUME_EPISODE',
|
? 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',
|
icon: 'play_arrow',
|
||||||
episode: resumeEpisode.episode,
|
episode: latestStartedEpisode.episode,
|
||||||
position: resumeEpisode.position,
|
position: latestStartedEpisode.position,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -150,7 +171,7 @@ function compareEpisodes(
|
|||||||
return naturalCollator.compare(episodeA.title ?? '', episodeB.title ?? '');
|
return naturalCollator.compare(episodeA.title ?? '', episodeB.title ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function compareInProgressPositions(
|
function comparePlaybackPositionRecency(
|
||||||
episodeA: OrderedEpisode,
|
episodeA: OrderedEpisode,
|
||||||
episodeB: OrderedEpisode
|
episodeB: OrderedEpisode
|
||||||
): number {
|
): 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,
|
Injectable,
|
||||||
Signal,
|
Signal,
|
||||||
signal,
|
signal,
|
||||||
|
untracked,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import {
|
import {
|
||||||
@@ -31,6 +32,8 @@ import {
|
|||||||
resolveSeriesPlaybackEpisodeState,
|
resolveSeriesPlaybackEpisodeState,
|
||||||
type SeriesPlaybackEpisodeState,
|
type SeriesPlaybackEpisodeState,
|
||||||
} from '@iptvnator/ui/playback';
|
} 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 & {
|
export type XtreamSerieDetailsView = XtreamSerieDetails & {
|
||||||
readonly series_id: number;
|
readonly series_id: number;
|
||||||
@@ -55,6 +58,7 @@ export class SerialDetailsPlaybackService {
|
|||||||
);
|
);
|
||||||
private readonly portalPlayer = inject(PORTAL_PLAYER);
|
private readonly portalPlayer = inject(PORTAL_PLAYER);
|
||||||
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
|
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
|
||||||
|
private readonly resumeTarget = inject(XTREAM_SERIES_RESUME_TARGET);
|
||||||
|
|
||||||
private readonly bindings = signal<SerialDetailsPlaybackBindings | null>(
|
private readonly bindings = signal<SerialDetailsPlaybackBindings | null>(
|
||||||
null
|
null
|
||||||
@@ -62,12 +66,12 @@ export class SerialDetailsPlaybackService {
|
|||||||
private readonly currentPlaylistId = computed(
|
private readonly currentPlaylistId = computed(
|
||||||
() => this.xtreamStore.currentPlaylist()?.id ?? ''
|
() => this.xtreamStore.currentPlaylist()?.id ?? ''
|
||||||
);
|
);
|
||||||
|
private readonly playbackPositionState =
|
||||||
|
new SerialDetailsPlaybackPositionState();
|
||||||
private lastSaveTime = 0;
|
private lastSaveTime = 0;
|
||||||
|
|
||||||
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
|
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
|
||||||
readonly episodePlaybackPositions = signal<
|
readonly episodePlaybackPositions = this.playbackPositionState.positions;
|
||||||
Map<number, PlaybackPositionData>
|
|
||||||
>(new Map());
|
|
||||||
readonly openingEpisodeId = signal<number | null>(null);
|
readonly openingEpisodeId = signal<number | null>(null);
|
||||||
readonly activeEpisodeId = signal<number | null>(null);
|
readonly activeEpisodeId = signal<number | null>(null);
|
||||||
|
|
||||||
@@ -129,6 +133,27 @@ export class SerialDetailsPlaybackService {
|
|||||||
this.activeEpisodeId.set(null);
|
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 =
|
const unsubscribePositionUpdates =
|
||||||
this.playbackPositionBridge.onPlaybackPositionUpdate(
|
this.playbackPositionBridge.onPlaybackPositionUpdate(
|
||||||
(data: PlaybackPositionData) => {
|
(data: PlaybackPositionData) => {
|
||||||
@@ -143,7 +168,7 @@ export class SerialDetailsPlaybackService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateEpisodePlaybackPosition(data);
|
this.playbackPositionState.update(data);
|
||||||
}
|
}
|
||||||
) ?? null;
|
) ?? null;
|
||||||
|
|
||||||
@@ -160,7 +185,7 @@ export class SerialDetailsPlaybackService {
|
|||||||
/** Clears all playback state when switching to another series. */
|
/** Clears all playback state when switching to another series. */
|
||||||
resetForNewSeries(): void {
|
resetForNewSeries(): void {
|
||||||
this.closeInlinePlayer();
|
this.closeInlinePlayer();
|
||||||
this.episodePlaybackPositions.set(new Map());
|
this.playbackPositionState.reset();
|
||||||
this.openingEpisodeId.set(null);
|
this.openingEpisodeId.set(null);
|
||||||
this.activeEpisodeId.set(null);
|
this.activeEpisodeId.set(null);
|
||||||
}
|
}
|
||||||
@@ -257,13 +282,21 @@ export class SerialDetailsPlaybackService {
|
|||||||
playback.contentInfo.playlistId,
|
playback.contentInfo.playlistId,
|
||||||
position
|
position
|
||||||
);
|
);
|
||||||
this.updateEpisodePlaybackPosition(position);
|
this.playbackPositionState.update(position);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
|
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
|
||||||
void this.portalPlayer.openExternalPlayback(
|
void this.playbackPositionState.recordExternalLaunch(
|
||||||
request.playback,
|
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,
|
playlistId,
|
||||||
request.nextPosition
|
request.nextPosition
|
||||||
);
|
);
|
||||||
this.updateEpisodePlaybackPosition(request.nextPosition);
|
this.playbackPositionState.update(request.nextPosition);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,23 +322,19 @@ export class SerialDetailsPlaybackService {
|
|||||||
request.contentXtreamId,
|
request.contentXtreamId,
|
||||||
'episode'
|
'episode'
|
||||||
);
|
);
|
||||||
this.removeEpisodePlaybackPosition(request.contentXtreamId);
|
this.playbackPositionState.remove(request.contentXtreamId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadSeriesPlaybackPositions(
|
async loadSeriesPlaybackPositions(
|
||||||
playlistId: string,
|
playlistId: string,
|
||||||
seriesXtreamId: number
|
seriesXtreamId: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const positions =
|
return this.playbackPositionState.load(playlistId, seriesXtreamId, () =>
|
||||||
await this.playbackPositions.getSeriesPlaybackPositions(
|
this.playbackPositions.getSeriesPlaybackPositions(
|
||||||
playlistId,
|
playlistId,
|
||||||
seriesXtreamId
|
seriesXtreamId
|
||||||
);
|
)
|
||||||
const positionsMap = new Map<number, PlaybackPositionData>();
|
);
|
||||||
positions.forEach((position) => {
|
|
||||||
positionsMap.set(position.contentXtreamId, position);
|
|
||||||
});
|
|
||||||
this.episodePlaybackPositions.set(positionsMap);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private selectedItem(): XtreamSerieDetailsView | null {
|
private selectedItem(): XtreamSerieDetailsView | null {
|
||||||
@@ -329,7 +358,15 @@ export class SerialDetailsPlaybackService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.closeInlinePlayer();
|
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 {
|
private getInlineEpisodeState(): SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null {
|
||||||
@@ -352,18 +389,4 @@ export class SerialDetailsPlaybackService {
|
|||||||
fallbackEpisodeNumber: playback.contentInfo.episodeNumber,
|
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>
|
||||||
<span class="play-btn__copy">
|
<span class="play-btn__copy">
|
||||||
<span class="play-btn__label">
|
<span class="play-btn__label">
|
||||||
{{ action.labelKey | translate }}
|
{{
|
||||||
|
action.labelKey
|
||||||
|
| translate: action.labelParams
|
||||||
|
}}
|
||||||
</span>
|
</span>
|
||||||
<span class="play-btn__meta">
|
<span class="play-btn__meta">
|
||||||
{{ action.episodeLabel }}
|
{{ action.episodeLabel }}
|
||||||
|
|||||||
+330
-3
@@ -15,11 +15,16 @@ import {
|
|||||||
PORTAL_EXTERNAL_PLAYBACK,
|
PORTAL_EXTERNAL_PLAYBACK,
|
||||||
PORTAL_PLAYBACK_POSITIONS,
|
PORTAL_PLAYBACK_POSITIONS,
|
||||||
PORTAL_PLAYER,
|
PORTAL_PLAYER,
|
||||||
|
SeriesResumeTarget,
|
||||||
} from '@iptvnator/portal/shared/util';
|
} from '@iptvnator/portal/shared/util';
|
||||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
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 { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
|
||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
import { SerialDetailsComponent } from './serial-details.component';
|
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({
|
@Component({
|
||||||
selector: 'app-season-container',
|
selector: 'app-season-container',
|
||||||
@@ -84,8 +89,16 @@ describe('SerialDetailsComponent', () => {
|
|||||||
const constructEpisodeStreamUrl = jest.fn();
|
const constructEpisodeStreamUrl = jest.fn();
|
||||||
const addRecentItem = jest.fn();
|
const addRecentItem = jest.fn();
|
||||||
const openResolvedPlayback = jest.fn();
|
const openResolvedPlayback = jest.fn();
|
||||||
|
const openExternalPlayback = jest.fn();
|
||||||
|
const savePlaybackPosition = jest.fn();
|
||||||
|
const clearPlaybackPosition = jest.fn();
|
||||||
const isEmbeddedPlayer = jest.fn();
|
const isEmbeddedPlayer = jest.fn();
|
||||||
const getSeriesPlaybackPositions = jest.fn().mockResolvedValue([]);
|
const getSeriesPlaybackPositions = jest.fn().mockResolvedValue([]);
|
||||||
|
let positionUpdateCallback: ((data: PlaybackPositionData) => void) | null =
|
||||||
|
null;
|
||||||
|
let seriesResumeTarget: ReturnType<
|
||||||
|
typeof signal<SeriesResumeTarget | null>
|
||||||
|
>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
selectedItem.set({
|
selectedItem.set({
|
||||||
@@ -133,11 +146,20 @@ describe('SerialDetailsComponent', () => {
|
|||||||
`http://xtream.example/series/${episode.id}.mp4`
|
`http://xtream.example/series/${episode.id}.mp4`
|
||||||
);
|
);
|
||||||
addRecentItem.mockClear();
|
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.mockReset();
|
||||||
isEmbeddedPlayer.mockReturnValue(false);
|
isEmbeddedPlayer.mockReturnValue(false);
|
||||||
getSeriesPlaybackPositions.mockClear();
|
getSeriesPlaybackPositions.mockClear();
|
||||||
getSeriesPlaybackPositions.mockResolvedValue([]);
|
getSeriesPlaybackPositions.mockResolvedValue([]);
|
||||||
|
seriesResumeTarget = signal<SeriesResumeTarget | null>(null);
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [SerialDetailsComponent],
|
imports: [SerialDetailsComponent],
|
||||||
@@ -187,8 +209,8 @@ describe('SerialDetailsComponent', () => {
|
|||||||
provide: PORTAL_PLAYBACK_POSITIONS,
|
provide: PORTAL_PLAYBACK_POSITIONS,
|
||||||
useValue: {
|
useValue: {
|
||||||
getSeriesPlaybackPositions,
|
getSeriesPlaybackPositions,
|
||||||
savePlaybackPosition: jest.fn(),
|
savePlaybackPosition,
|
||||||
clearPlaybackPosition: jest.fn(),
|
clearPlaybackPosition,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -196,8 +218,26 @@ describe('SerialDetailsComponent', () => {
|
|||||||
useValue: {
|
useValue: {
|
||||||
isEmbeddedPlayer,
|
isEmbeddedPlayer,
|
||||||
openResolvedPlayback,
|
openResolvedPlayback,
|
||||||
|
openExternalPlayback,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: PlaybackPositionRuntimeBridgeService,
|
||||||
|
useValue: {
|
||||||
|
onPlaybackPositionUpdate: (
|
||||||
|
callback: (data: PlaybackPositionData) => void
|
||||||
|
) => {
|
||||||
|
positionUpdateCallback = callback;
|
||||||
|
return () => {
|
||||||
|
positionUpdateCallback = null;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: XTREAM_SERIES_RESUME_TARGET,
|
||||||
|
useValue: seriesResumeTarget,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: MatSnackBar,
|
provide: MatSnackBar,
|
||||||
useValue: {
|
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 () => {
|
it('passes inline episode metadata and autoplays only inside the current season', async () => {
|
||||||
isEmbeddedPlayer.mockReturnValue(true);
|
isEmbeddedPlayer.mockReturnValue(true);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { PlaylistsService } from '@iptvnator/services';
|
|||||||
import { Playlist } from '@iptvnator/shared/interfaces';
|
import { Playlist } from '@iptvnator/shared/interfaces';
|
||||||
import { firstValueFrom, of } from 'rxjs';
|
import { firstValueFrom, of } from 'rxjs';
|
||||||
import { SerialDetailsComponent } from './serial-details/serial-details.component';
|
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';
|
import { XtreamCollectionDetailComponent } from './xtream-collection-detail.component';
|
||||||
|
|
||||||
describe('XtreamCollectionDetailComponent', () => {
|
describe('XtreamCollectionDetailComponent', () => {
|
||||||
@@ -105,19 +106,23 @@ describe('XtreamCollectionDetailComponent', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('opens Xtream series favorites with the serial detail route context', async () => {
|
it('opens Xtream series favorites with the serial detail route context', async () => {
|
||||||
fixture.componentRef.setInput(
|
const seriesResume = {
|
||||||
'item',
|
seriesXtreamId: 103,
|
||||||
{
|
contentXtreamId: 2001,
|
||||||
uid: 'xtream::xtream-1::series:103',
|
seasonNumber: 2,
|
||||||
name: 'Series One',
|
episodeNumber: 1,
|
||||||
contentType: 'series',
|
};
|
||||||
sourceType: 'xtream',
|
fixture.componentRef.setInput('item', {
|
||||||
playlistId: 'xtream-1',
|
uid: 'xtream::xtream-1::series:103',
|
||||||
playlistName: 'Xtream Portal',
|
name: 'Series One',
|
||||||
xtreamId: 103,
|
contentType: 'series',
|
||||||
categoryId: 3,
|
sourceType: 'xtream',
|
||||||
} satisfies UnifiedCollectionItem
|
playlistId: 'xtream-1',
|
||||||
);
|
playlistName: 'Xtream Portal',
|
||||||
|
xtreamId: 103,
|
||||||
|
categoryId: 3,
|
||||||
|
} satisfies UnifiedCollectionItem);
|
||||||
|
fixture.componentRef.setInput('seriesResume', seriesResume);
|
||||||
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
@@ -140,9 +145,17 @@ describe('XtreamCollectionDetailComponent', () => {
|
|||||||
// toSignal(), so the fake route must expose the observable too —
|
// toSignal(), so the fake route must expose the observable too —
|
||||||
// otherwise the inline detail crashes on construction.
|
// otherwise the inline detail crashes on construction.
|
||||||
expect(route?.params).toBeDefined();
|
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',
|
categoryId: '3',
|
||||||
serialId: '103',
|
serialId: '103',
|
||||||
});
|
});
|
||||||
|
expect(
|
||||||
|
fixture.componentInstance
|
||||||
|
.detailInjector()
|
||||||
|
?.get(XTREAM_SERIES_RESUME_TARGET)()
|
||||||
|
).toEqual(seriesResume);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -12,7 +12,10 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import { PortalDetailShellComponent } from '@iptvnator/ui/components';
|
import { PortalDetailShellComponent } from '@iptvnator/ui/components';
|
||||||
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
|
import {
|
||||||
|
SeriesResumeTarget,
|
||||||
|
UnifiedCollectionItem,
|
||||||
|
} from '@iptvnator/portal/shared/util';
|
||||||
import {
|
import {
|
||||||
XtreamPlaylistData,
|
XtreamPlaylistData,
|
||||||
XtreamStore,
|
XtreamStore,
|
||||||
@@ -21,6 +24,7 @@ import { PlaylistsService } from '@iptvnator/services';
|
|||||||
import { Playlist } from '@iptvnator/shared/interfaces';
|
import { Playlist } from '@iptvnator/shared/interfaces';
|
||||||
import { firstValueFrom, of } from 'rxjs';
|
import { firstValueFrom, of } from 'rxjs';
|
||||||
import { SerialDetailsComponent } from './serial-details/serial-details.component';
|
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';
|
import { VodDetailsRouteComponent } from './vod-details/vod-details-route.component';
|
||||||
|
|
||||||
interface XtreamCollectionStateSnapshot {
|
interface XtreamCollectionStateSnapshot {
|
||||||
@@ -62,6 +66,7 @@ interface XtreamCollectionStateSnapshot {
|
|||||||
})
|
})
|
||||||
export class XtreamCollectionDetailComponent {
|
export class XtreamCollectionDetailComponent {
|
||||||
readonly item = input<UnifiedCollectionItem | null>(null);
|
readonly item = input<UnifiedCollectionItem | null>(null);
|
||||||
|
readonly seriesResume = input<SeriesResumeTarget | null>(null);
|
||||||
|
|
||||||
private readonly parentInjector = inject(Injector);
|
private readonly parentInjector = inject(Injector);
|
||||||
private readonly playlistsService = inject(PlaylistsService);
|
private readonly playlistsService = inject(PlaylistsService);
|
||||||
@@ -154,6 +159,10 @@ export class XtreamCollectionDetailComponent {
|
|||||||
params: of(routeParams),
|
params: of(routeParams),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: XTREAM_SERIES_RESUME_TARGET,
|
||||||
|
useValue: this.seriesResume,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
parent: this.parentInjector,
|
parent: this.parentInjector,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -969,6 +969,9 @@ describe('DashboardDataService', () => {
|
|||||||
const series = vodItems.find(
|
const series = vodItems.find(
|
||||||
(item) => item.title === 'Color Orchard S02E04'
|
(item) => item.title === 'Color Orchard S02E04'
|
||||||
);
|
);
|
||||||
|
if (!series) {
|
||||||
|
throw new Error('expected the series recent item');
|
||||||
|
}
|
||||||
|
|
||||||
// Movie: vod position with 60% watched.
|
// Movie: vod position with 60% watched.
|
||||||
const moviePos = service.getPlaybackPositionForItem(movie!);
|
const moviePos = service.getPlaybackPositionForItem(movie!);
|
||||||
@@ -977,13 +980,91 @@ describe('DashboardDataService', () => {
|
|||||||
|
|
||||||
// Series: episode position keyed by the recent item's xtream_id (the
|
// Series: episode position keyed by the recent item's xtream_id (the
|
||||||
// episode id, not the series id).
|
// episode id, not the series id).
|
||||||
const seriesPos = service.getPlaybackPositionForItem(series!);
|
const seriesPos = service.getPlaybackPositionForItem(series);
|
||||||
expect(seriesPos?.contentType).toBe('episode');
|
expect(seriesPos?.contentType).toBe('episode');
|
||||||
expect(seriesPos?.positionSeconds).toBe(720);
|
expect(seriesPos?.positionSeconds).toBe(720);
|
||||||
// Season/episode metadata travels with the position so cards can
|
// Season/episode metadata travels with the position so cards can
|
||||||
// render an "S2 · E5" badge without an extra round-trip.
|
// render an "S2 · E5" badge without an extra round-trip.
|
||||||
expect(seriesPos?.seasonNumber).toBe(2);
|
expect(seriesPos?.seasonNumber).toBe(2);
|
||||||
expect(seriesPos?.episodeNumber).toBe(4);
|
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 () => {
|
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?.contentType).toBe('episode');
|
||||||
expect(position?.seasonNumber).toBe(3);
|
expect(position?.seasonNumber).toBe(3);
|
||||||
expect(position?.episodeNumber).toBe(7);
|
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();
|
expect(failOnLinearScan).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -893,7 +893,10 @@ export class DashboardDataService {
|
|||||||
getRecentItemNavigationState(
|
getRecentItemNavigationState(
|
||||||
item: GlobalRecentItem
|
item: GlobalRecentItem
|
||||||
): WorkspaceNavigationTarget['state'] {
|
): WorkspaceNavigationTarget['state'] {
|
||||||
return getRecentItemNavigationStateUtil(item);
|
return getRecentItemNavigationStateUtil(
|
||||||
|
item,
|
||||||
|
this.getPlaybackPositionForItem(item)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeGlobalRecentItem(item: GlobalRecentItem): Promise<void> {
|
async removeGlobalRecentItem(item: GlobalRecentItem): Promise<void> {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
PlaylistMeta,
|
PlaylistMeta,
|
||||||
|
PlaybackPositionData,
|
||||||
PortalActivityType,
|
PortalActivityType,
|
||||||
PortalAddedItem,
|
PortalAddedItem,
|
||||||
PortalFavoriteItem,
|
PortalFavoriteItem,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
getGlobalFavoriteNavigation,
|
getGlobalFavoriteNavigation,
|
||||||
getRecentItemNavigation,
|
getRecentItemNavigation,
|
||||||
WorkspaceNavigationTarget,
|
WorkspaceNavigationTarget,
|
||||||
|
type SeriesResumeTarget,
|
||||||
} from '@iptvnator/portal/shared/util';
|
} from '@iptvnator/portal/shared/util';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,9 +60,54 @@ export function getRecentItemLink(item: PortalRecentItem): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getRecentItemNavigationState(
|
export function getRecentItemNavigationState(
|
||||||
item: PortalRecentItem
|
item: PortalRecentItem,
|
||||||
|
playbackPosition?: PlaybackPositionData | null
|
||||||
): WorkspaceNavigationTarget['state'] {
|
): 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[] {
|
export function getGlobalFavoriteLink(item: PortalFavoriteItem): string[] {
|
||||||
|
|||||||
Reference in new issue
Block a user