diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index 46bbde403..9dae1fa02 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -304,7 +304,10 @@ the save throttle and applying it would visibly rewind. A resuming engine can emit a `timeupdate` at ~0 before it finishes seeking. `VodDetailsPlaybackService` guards this with a one-shot `resumeSettled` latch — -a filter would have broken deliberate seek-backwards. `handleInlineTimeUpdate` +a filter would have broken deliberate seek-backwards. The latch also releases +when the target is out of reach: switching a two-hour position into a +90-minute cut means the engine can never report it, and waiting would suppress +every save for the rest of the session. `handleInlineTimeUpdate` returns that verdict and the route feeds multi-source the `startTime` it asked for until the engine gets there, so a switch or a failure during the initial seek does not resolve the next source at zero and restart the film. One latch diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts index 41a1411a6..097a07515 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts @@ -158,6 +158,43 @@ describe('VodDetailsPlaybackService — external session ownership', () => { expect(addRecentItem).toHaveBeenCalled(); }); + it('gives up on a resume point the source cannot reach', () => { + // Carrying a two-hour position into a 90-minute cut: the engine can + // never report that time, so latching on it would suppress every save + // for the whole session and keep re-reporting the impossible start. + service.inlinePlayback.set({ + streamUrl: 'https://example.com/short-cut.mkv', + title: 'Example Movie', + startTime: 7200, + contentInfo: { + playlistId: ROUTE_PLAYLIST, + contentXtreamId: ROUTE_VOD_ID, + contentType: 'vod', + }, + }); + + expect( + service.handleInlineTimeUpdate({ currentTime: 12, duration: 5400 }) + ).toBe(true); + }); + + it('still waits for a resume point the source can reach', () => { + service.inlinePlayback.set({ + streamUrl: 'https://example.com/full.mkv', + title: 'Example Movie', + startTime: 2538, + contentInfo: { + playlistId: ROUTE_PLAYLIST, + contentXtreamId: ROUTE_VOD_ID, + contentType: 'vod', + }, + }); + + expect( + service.handleInlineTimeUpdate({ currentTime: 12, duration: 7744 }) + ).toBe(false); + }); + describe('position updates from the bridge', () => { function emit(playlistId: string, contentXtreamId: number, at: number) { positionListener?.({ diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts index 461c2cc6d..b5c39afa6 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts @@ -31,10 +31,9 @@ export interface VodDetailsPlaybackBindings { /** Usable metadata of the selected VOD, if any */ vodInfo: Signal; /** - * The source actually playing when it is NOT the route's own — supplied by - * multi-source. An external player launched for an alternative carries - * that playlist's ids, so without this the session belongs to no page and - * its Stop button never appears. + * The source playing when it is NOT the route's own. An external player + * launched for an alternative carries that playlist's ids, so without + * this the session belongs to no page and never offers Stop. */ activeSource?: Signal; } @@ -56,9 +55,7 @@ export class VodDetailsPlaybackService { private readonly logger = createLogger('VodDetailsPlayback'); /** Signals bound from the host component via `bind()` */ - private readonly bindings = signal( - null - ); + private readonly bindings = signal(null); private lastSaveTime = 0; /** Cleared on every start; latches once playback reaches startTime. */ private resumeSettled = false; @@ -323,7 +320,11 @@ export class VodDetailsPlaybackService { // is still saved normally. if (!this.resumeSettled) { const startTime = playback.startTime ?? 0; - if (startTime > 0 && event.currentTime < startTime - 5) + // A shorter cut can never reach a position carried into it, and + // latching on that would suppress every save for the session. + const reachable = + !(event.duration > 0) || startTime < event.duration; + if (reachable && startTime > 0 && event.currentTime < startTime - 5) return false; this.resumeSettled = true; } @@ -380,8 +381,7 @@ export class VodDetailsPlaybackService { * engine survive and simply re-seek to `playback.startTime`. */ startResolvedPlayback(playback: ResolvedPortalPlayback): void { - // Same movie, different source: still a view, and the picker and - // pin paths would otherwise leave it out of Recently Viewed. + // Same movie, different source: still a view. this.addToRecentlyViewed(); this.startPlayback(playback); }