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()) {
-
- add
- {{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
-
- } @else {
-
- check
- {{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
-
- }
- @if (isElectron()) {
- @if (isDownloaded()) {
- @if (externalPrimaryButtonState() === 'idle') {
-
- play_arrow
- {{
- 'PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE' | translate
- }}
-
- }
- } @else if (isPausedDownload()) {
-
- play_arrow
- {{ 'DOWNLOADS.RESUME' | translate }}
-
- } @else if (isDownloading()) {
-
- downloading
- {{ 'DOWNLOADS.STATUS.DOWNLOADING' | translate }}
+ @if (playableItem) {
+ @if (!isFavorite()) {
+
+ add
+ {{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
} @else {
- download
- {{ 'DOWNLOADS.DOWNLOAD' | translate }}
+ check
+ {{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
}
- }
-
- @if (multiSource.hasAlternatives()) {
-
- }
-
- @if (activeSourceCaption(); as caption) {
-
-
- {{ 'PORTALS.MULTI_SOURCE.PLAYING_FROM' | translate }}
-
- {{ caption.source }}
- @if (multiSource.hasAlternatives()) {
-
+
+ }
+ } @else if (isPausedDownload()) {
+
+ play_arrow
+ {{ 'DOWNLOADS.RESUME' | translate }}
+
+ } @else if (isDownloading()) {
+
+ downloading
+ {{ 'DOWNLOADS.STATUS.DOWNLOADING' | translate }}
+
+ } @else {
+
+ download
+ {{ 'DOWNLOADS.DOWNLOAD' | translate }}
+
}
-
+ }
+
+ @if (multiSource.hasAlternatives()) {
+
+ }
+
+ @if (activeSourceCaption(); as caption) {
+
+
+ {{ 'PORTALS.MULTI_SOURCE.PLAYING_FROM' | translate }}
+
+ {{
+ caption.source
+ }}
+ @if (multiSource.hasAlternatives()) {
+
+ }
+
+ }
}
}
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;
}