diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index d094fbe6a..b002e452e 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -411,6 +411,18 @@ Every entry point is gated on a bridge `typeof` check (`isAvailable`), matching `CatalogTitleMatchService`. In the PWA the chip renders nothing and the auto-failover setting is hidden. +## Resuming a pinned copy + +Playback positions are keyed by (playlist, stream), so a pinned alternative +carries its own. `playPinned` looks that position up and applies it — and +applies **zero** when the lookup comes back empty, because the controller is +still holding the ROUTE copy's position at that moment. Carrying it across +would drop the user into the middle of a film they never started here, and the +first save would write that timecode under the pinned source's key. + +When no lookup function is supplied at all, nothing is applied: "never watched" +was never established, so there is nothing to correct. + ## Claims about the present `isActive` means "the source a switch or Play would use". Discovery sets it the diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts index 369b423f9..25849947d 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts @@ -186,7 +186,32 @@ describe('VodMultiSourceHostService — pinning', () => { ); }); - it('keeps the page position when the pinned source has none', async () => { + it('starts a pinned source that was never watched from the beginning', async () => { + pins.get.mockResolvedValue({ + matchKey: 'title:the matrix:1999', + playlistId: ALT_TWO.playlistId, + contentId: ALT_TWO.contentId, + portalType: 'xtream', + }); + await loadMovie([ALT_TWO]); + // The position the PAGE loaded belongs to the route's copy. + service.seedResumePosition(2538); + + await expect( + service.playPinnedSource(jest.fn().mockResolvedValue(null)) + ).resolves.toBe(true); + + // Positions are keyed by (playlist, stream). Carrying the route copy's + // timecode into a copy the user has never opened drops them 42 minutes + // into an unstarted film — and the first save writes it back under + // this source's key, making the mistake permanent. + expect(resolver.resolve).toHaveBeenCalledWith( + expect.objectContaining({ id: ALT_TWO.id }), + { startTime: 0 } + ); + }); + + it('leaves the position alone when the host cannot look one up', async () => { pins.get.mockResolvedValue({ matchKey: 'title:the matrix:1999', playlistId: ALT_TWO.playlistId, @@ -196,9 +221,9 @@ describe('VodMultiSourceHostService — pinning', () => { await loadMovie([ALT_TWO]); service.seedResumePosition(2538); - await expect( - service.playPinnedSource(jest.fn().mockResolvedValue(null)) - ).resolves.toBe(true); + // No lookup was performed, so "never watched" was never established — + // zeroing here would throw away a position nobody contradicted. + await expect(service.playPinnedSource()).resolves.toBe(true); expect(resolver.resolve).toHaveBeenCalledWith( expect.objectContaining({ id: ALT_TWO.id }), diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts index 252f6b6bd..2c7208633 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts @@ -210,6 +210,7 @@ export async function playPinned(deps: PinnedPlayDeps): Promise { return false; } + const looked = !!deps.resumeFor; const stored = await deps.resumeFor?.(pinned); // That lookup is a database round-trip, and the user can navigate across // it. Playing then would hand THIS film's source id to whatever movie now @@ -218,8 +219,12 @@ export async function playPinned(deps: PinnedPlayDeps): Promise { return false; } - if (stored !== null && stored !== undefined) { - deps.controller.setResumeSeconds(stored); + if (looked) { + // `null` means this copy has never been watched, and the controller is + // still holding the ROUTE copy's position. Carrying that across would + // drop the user an hour into a film they have not started here — and + // the first save would write that timecode under this source's key. + deps.controller.setResumeSeconds(stored ?? 0); } return deps.play(pinned.id);