diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index dd06d4b8d..ecc88d12a 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -453,7 +453,12 @@ which replace whatever timecode an alternative left in the controller. While the pinned copy is the one playing, its live position wins over the row that was stored before this session started. -A pin means the button plays a copy the page did not load a position for. +A pin means the button plays a copy the page did not load a position for. A +watched-through copy resolves to zero rather than its stored seconds, through +the same `isResumablePosition` rule the label uses — otherwise the button reads +Play and then seeks back to where the film ended. Restart follows the pin for +the same reason: with Resume honouring a pinned copy, a Restart that started +the route copy would silently switch the user's playlist. `createPrimaryActionPosition` therefore looks that copy's row up and lets it govern the label, the timecode and the Restart affordance — including when the lookup comes back empty, because "never watched" is an answer: the button must @@ -468,6 +473,11 @@ object carrying `codec_name`/`width`/`height`. `readStreamInfo` accepts both — reading only the object silently lost the codec on every array response, and with it the "dub may differ" warning, which compares stated audio tracks. +Switching sources through `startResolvedPlayback` closes a matched external +session first. It REPLACES what is playing — with MPV or VLC and instance +reuse off, the backend would otherwise spawn a second detached player, leaving +both sources running and Stop owning only the newer one. + ## Claims about the present `isActive` means "the source a switch or Play would use" — selection, not diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-multi-source-ui.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-multi-source-ui.service.ts index 6c56cc98e..63f07236a 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-multi-source-ui.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-multi-source-ui.service.ts @@ -17,6 +17,7 @@ import { VodMultiSourceHostService } from './vod-multi-source-host.service'; import { createPrimaryActionPosition, formatPlaybackPosition, + isResumablePosition, } from './vod-primary-action-position'; /** @@ -145,10 +146,24 @@ export class VodDetailsMultiSourceUiService { 'vod' ); + /** + * The seconds a pinned play should resume from — `null` once the copy has + * been watched through, so a row the button offers as Play does not seek + * back to where it ended. + */ readonly resumeSecondsFor = async ( source: VodSourceCandidate - ): Promise => - (await this.positionFor(source))?.positionSeconds ?? null; + ): Promise => { + const position = await this.positionFor(source); + return isResumablePosition(position) + ? (position?.positionSeconds ?? null) + : null; + }; + + /** True when the primary button acts on a pinned copy, not the route's. */ + readonly primaryIsPinnedCopy = computed( + () => this.primaryAction.foreignPin() !== null + ); /** Title shown in the sources popover header. */ readonly multiSourceTitle = computed(() => this.movieTitle()); 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 c3c73ff43..cc0fb39fa 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 @@ -29,6 +29,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => { let positionListener: ((data: PlaybackPositionData) => void) | undefined; const addRecentItem = jest.fn(); const activeSession = signal(null); + const closeSession = jest.fn().mockResolvedValue(undefined); const activeSource = signal(null); function sessionFor(playlistId: string, contentXtreamId: number) { @@ -61,7 +62,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => { }, { provide: PORTAL_EXTERNAL_PLAYBACK, - useValue: { activeSession, closeSession: jest.fn() }, + useValue: { activeSession, closeSession }, }, { provide: PORTAL_PLAYBACK_POSITIONS, @@ -141,6 +142,25 @@ describe('VodDetailsPlaybackService — external session ownership', () => { expect(service.matchedExternalPlayback()).toBeNull(); }); + it('stops the running external player before switching sources', async () => { + activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID)); + + await service.startResolvedPlayback({ + streamUrl: 'https://example.com/alt.mkv', + title: 'Example Movie', + contentInfo: { + playlistId: 'playlist-2', + contentXtreamId: 991, + contentType: 'vod', + }, + }); + + // A switch REPLACES what is playing. With MPV/VLC and instance reuse + // off the backend spawns a second detached player otherwise: both + // sources keep running, and Stop owns only the newer one. + expect(closeSession).toHaveBeenCalled(); + }); + it('records a source started through multi-source as recently viewed', () => { // Playing an alternative from the picker, or letting a pin decide the // primary Play, is still watching the movie — it belongs in Recently 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 45878bf27..ddc49057e 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 @@ -342,7 +342,17 @@ export class VodDetailsPlaybackService { * template already renders through `@if`, so the player component and its * engine survive and simply re-seek to `playback.startTime`. */ - startResolvedPlayback(playback: ResolvedPortalPlayback): void { + async startResolvedPlayback( + playback: ResolvedPortalPlayback + ): Promise { + // A switch REPLACES what is playing. With MPV or VLC and instance + // reuse off, the backend spawns a second detached player otherwise — + // both sources keep running and Stop owns only the newer one. + const running = this.matchedExternalPlayback(); + if (running) { + await this.externalPlayback.closeSession(running); + } + // Same movie, different source: still a view. this.addToRecentlyViewed(); this.startPlayback(playback); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-auto-failover.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-auto-failover.spec.ts new file mode 100644 index 000000000..d93c26209 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-auto-failover.spec.ts @@ -0,0 +1,92 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { VideoPlayer } from '@iptvnator/shared/interfaces'; +import { VodDetailsRouteComponent } from './vod-details-route.component'; +import { + configureVodDetailsRouteTestBed, + createVodDetailsRouteStubs, + resetVodDetailsRouteStubs, + silenceRouteLogging, +} from './vod-details-route.harness'; + +/** + * Who gets offered auto-failover, and what happens when the preference cannot + * be stored. Split from the playback-actions spec to keep both inside the + * repository's file-size rule. + */ +describe('VodDetailsRouteComponent — auto-failover', () => { + let fixture: ComponentFixture; + let restoreLogging: (() => void) | undefined; + const stubs = createVodDetailsRouteStubs(); + const { selectedPlayer, snackBarOpen, updateSettings } = stubs; + + beforeEach(async () => { + restoreLogging = silenceRouteLogging(); + resetVodDetailsRouteStubs(stubs); + await configureVodDetailsRouteTestBed(stubs); + + fixture = TestBed.createComponent(VodDetailsRouteComponent); + }); + + afterEach(() => { + restoreLogging?.(); + }); + + beforeEach(() => { + selectedPlayer.set(VideoPlayer.Html5Player); + updateSettings.mockReset().mockResolvedValue(undefined); + snackBarOpen.mockClear(); + }); + + it.each([VideoPlayer.MPV, VideoPlayer.VLC, VideoPlayer.EmbeddedMpv])( + 'is not offered on %s', + (player) => { + // Those players never raise the playback diagnostic that + // calls onPlaybackFailed(), so the switch could never happen. + selectedPlayer.set(player); + + expect(fixture.componentInstance.autoFailoverSupported()).toBe( + false + ); + } + ); + + it.each([ + VideoPlayer.Html5Player, + VideoPlayer.VideoJs, + VideoPlayer.ArtPlayer, + ])('is offered on %s', (player) => { + selectedPlayer.set(player); + + expect(fixture.componentInstance.autoFailoverSupported()).toBe( + true + ); + }); + + it('tells the user when the preference could not be stored', async () => { + // updateSettings patches memory and REJECTS on a failed write, so + // without this the toggle looks saved and silently reverts on the + // next start — and the rejection is unhandled. + updateSettings.mockRejectedValue(new Error('disk full')); + + fixture.componentInstance.setAutoFailover(true); + await Promise.resolve(); + await Promise.resolve(); + + expect(snackBarOpen).toHaveBeenCalledWith( + 'SETTINGS.SETTINGS_SAVE_FAILED', + 'CLOSE', + expect.anything() + ); + }); + + it('stays quiet when the write succeeds', async () => { + fixture.componentInstance.setAutoFailover(true); + await Promise.resolve(); + await Promise.resolve(); + + expect(updateSettings).toHaveBeenCalledWith({ + vodAutoFailover: true, + }); + expect(snackBarOpen).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts index 4e5e0cd09..30568be54 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts @@ -291,64 +291,68 @@ describe('VodDetailsRouteComponent — playback actions', () => { }); }); - describe('auto-failover toggle', () => { - beforeEach(() => { - selectedPlayer.set(VideoPlayer.Html5Player); - updateSettings.mockReset().mockResolvedValue(undefined); - snackBarOpen.mockClear(); + it('offers no resume point for a pinned copy watched through', async () => { + const component = fixture.componentInstance; + const resumeSecondsFor = component['msUi'].resumeSecondsFor; + + getPlaybackPosition.mockResolvedValue({ + playlistId: 'playlist-2', + contentXtreamId: 991, + contentType: 'vod', + positionSeconds: 6900, + durationSeconds: 7200, }); - it.each([VideoPlayer.MPV, VideoPlayer.VLC, VideoPlayer.EmbeddedMpv])( - 'is not offered on %s', - (player) => { - // Those players never raise the playback diagnostic that - // calls onPlaybackFailed(), so the switch could never happen. - selectedPlayer.set(player); + // The button reads Play at 95%, so the start must mean zero — not a + // seek back to where the film ended. + await expect( + resumeSecondsFor({ + playlistId: 'playlist-2', + contentId: 991, + } as never) + ).resolves.toBeNull(); + }); - expect(fixture.componentInstance.autoFailoverSupported()).toBe( - false - ); - } - ); + it('still resumes a pinned copy left mid-film', async () => { + const component = fixture.componentInstance; - it.each([ - VideoPlayer.Html5Player, - VideoPlayer.VideoJs, - VideoPlayer.ArtPlayer, - ])('is offered on %s', (player) => { - selectedPlayer.set(player); - - expect(fixture.componentInstance.autoFailoverSupported()).toBe( - true - ); + getPlaybackPosition.mockResolvedValue({ + playlistId: 'playlist-2', + contentXtreamId: 991, + contentType: 'vod', + positionSeconds: 2538, + durationSeconds: 7200, }); - it('tells the user when the preference could not be stored', async () => { - // updateSettings patches memory and REJECTS on a failed write, so - // without this the toggle looks saved and silently reverts on the - // next start — and the rejection is unhandled. - updateSettings.mockRejectedValue(new Error('disk full')); + await expect( + component['msUi'].resumeSecondsFor({ + playlistId: 'playlist-2', + contentId: 991, + } as never) + ).resolves.toBe(2538); + }); - fixture.componentInstance.setAutoFailover(true); - await Promise.resolve(); - await Promise.resolve(); - - expect(snackBarOpen).toHaveBeenCalledWith( - 'SETTINGS.SETTINGS_SAVE_FAILED', - 'CLOSE', - expect.anything() - ); + it('restarts the pinned copy, not the route copy', async () => { + currentPlaylist.set({ id: 'playlist-1' }); + const component = fixture.componentInstance; + withActiveSource('playlist-1', 650020); + // Resume honours the pin, so the Restart beside it must too — + // otherwise the button quietly switches the user's playlist. + Object.defineProperty(component['msUi'], 'primaryIsPinnedCopy', { + configurable: true, + value: () => true, }); + const pinnedPlay = jest + .spyOn(component.multiSource, 'playPinnedSource') + .mockResolvedValue('played'); - it('stays quiet when the write succeeds', async () => { - fixture.componentInstance.setAutoFailover(true); - await Promise.resolve(); - await Promise.resolve(); + await component.restartVod({ + movie_data: { stream_id: 650020, name: 'Example' }, + } as never); - expect(updateSettings).toHaveBeenCalledWith({ - vodAutoFailover: true, - }); - expect(snackBarOpen).not.toHaveBeenCalled(); - }); + expect(pinnedPlay).toHaveBeenCalled(); + // Restart means zero, whichever copy it starts. + const resumeFor = pinnedPlay.mock.calls[0][0]; + await expect(resumeFor?.({} as never)).resolves.toBe(0); }); }); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html index 584fcd777..538852e24 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html @@ -158,7 +158,7 @@ @if (!isExternalStopAction()) {