From 26b20658e39b553e04dcbae371ffbeee7e53d000 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 30 Jul 2026 09:55:33 +0200 Subject: [PATCH] fix(downloads): keep offline Xtream playback available --- .../vod-details-route-playback.spec.ts | 24 +++ .../vod-details-route.actions.spec.ts | 37 ++++ .../vod-details-route.component.html | 173 ++++++++++-------- .../vod-details-route.component.ts | 2 +- 4 files changed, 154 insertions(+), 82 deletions(-) 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 208938ad7..786c15957 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 @@ -143,6 +143,30 @@ describe('VodDetailsRouteComponent — playback actions', () => { expect(playDownload).not.toHaveBeenCalled(); }); + it('stops external playback without a usable provider item', async () => { + currentPlaylist.set({ id: 'playlist-1' }); + downloadsAvailable.set(true); + isDownloaded.mockReturnValue(true); + getDownloadedFilePath.mockReturnValue('/downloads/example.mp4'); + activeSession.set({ + player: 'mpv', + status: 'playing', + contentInfo: { + playlistId: 'playlist-1', + contentXtreamId: 650020, + contentType: 'vod', + }, + }); + + const component = fixture.componentInstance; + expect(component.isExternalStopAction()).toBe(true); + + await component.onPrimaryAction(null); + + expect(closeSession).toHaveBeenCalled(); + expect(playDownload).not.toHaveBeenCalled(); + }); + it('plays a completed download before provider resume or pin resolution', async () => { currentPlaylist.set({ id: 'playlist-1' }); downloadsAvailable.set(true); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts index ddac03d9e..14f67a120 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts @@ -107,6 +107,15 @@ describe('VodDetailsRouteComponent fallback actions', () => { container_extension: 'mp4', }, }) as XtreamVodDetails; + const unplayableRichItem = (): XtreamVodDetails => + ({ + ...richItem(), + movie_data: { + stream_id: 650020, + name: 'Metadata movie', + container_extension: '', + }, + }) as XtreamVodDetails; beforeEach(async () => { selectedItem.set(null); downloadsAvailable.set(false); @@ -430,6 +439,34 @@ describe('VodDetailsRouteComponent fallback actions', () => { ).toContain('DOWNLOADS.PLAY_LOCAL'); }); + it('plays a rich downloaded movie locally without a usable provider source', async () => { + selectedItem.set(unplayableRichItem()); + downloadsAvailable.set(true); + isDownloaded.mockReturnValue(true); + getDownloadedFilePath.mockReturnValue('/downloads/metadata-movie.mp4'); + + fixture.detectChanges(); + + const host = fixture.nativeElement as HTMLElement; + const primary = + host.querySelector('button.play-btn'); + expect(fixture.componentInstance.playableVodItem()).toBeNull(); + expect(host.textContent).toContain('DOWNLOADS.OFFLINE'); + expect(primary?.textContent).toContain('DOWNLOADS.PLAY_LOCAL'); + expect(host.textContent).not.toContain( + 'PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE' + ); + + primary?.click(); + await fixture.whenStable(); + + expect(playDownload).toHaveBeenCalledWith( + '/downloads/metadata-movie.mp4' + ); + expect(constructVodStreamUrl).not.toHaveBeenCalled(); + expect(openResolvedPlayback).not.toHaveBeenCalled(); + }); + it('keeps pinned provider playback behind the downloaded secondary action', async () => { selectedItem.set(sparseItem()); downloadsAvailable.set(true); 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 c26dc33c4..d3762fab8 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 @@ -1,5 +1,6 @@ - @if (playableVodItem(); as playableItem) { + @let playableItem = playableVodItem(); + @if (playableItem || isDownloaded()) { @if (hasPlaybackPosition()) { } - @if (!isFavorite()) { - - } @else { - - } - @if (isElectron()) { - @if (isDownloaded()) { - @if (externalPrimaryButtonState() === 'idle') { - - } - } @else if (isPausedDownload()) { - - } @else if (isDownloading()) { - } @else { } - } - - @if (multiSource.hasAlternatives()) { - - } - - @if (activeSourceCaption(); as caption) { -

- - {{ 'PORTALS.MULTI_SOURCE.PLAYING_FROM' | translate }} - - {{ caption.source }} - @if (multiSource.hasAlternatives()) { - - @if (caption.alternativeCount === 1) { + @if (isElectron()) { + @if (isDownloaded()) { + @if (externalPrimaryButtonState() === 'idle') { + + } + } @else if (isPausedDownload()) { + + } @else if (isDownloading()) { + + } @else { + } -

+ } + + @if (multiSource.hasAlternatives()) { + + } + + @if (activeSourceCaption(); as caption) { +

+ + {{ 'PORTALS.MULTI_SOURCE.PLAYING_FROM' | translate }} + + {{ + caption.source + }} + @if (multiSource.hasAlternatives()) { + + @if (caption.alternativeCount === 1) { + {{ + 'PORTALS.MULTI_SOURCE.ALSO_FOUND_IN_ONE' + | translate + }} + } @else { + {{ + 'PORTALS.MULTI_SOURCE.ALSO_FOUND_IN_OTHER' + | translate + : { + count: caption.alternativeCount, + } + }} + } + + } +

+ } } }
diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index c548b30df..f60417f35 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -503,7 +503,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { // make the control do the opposite of what it says — launching a // second player while the first keeps running. if (this.playback.isExternalStopAction()) { - this.playback.onPrimaryAction(vodItem); + await this.playback.stopExternalPlayback(); return; }