fix(downloads): keep offline Xtream playback available

This commit is contained in:
4gray committed 2026-08-01 16:29:50 +02:00
1 parent 5511d66651
commit 26b20658e3
4 files changed
+154 -82

No files matched your search

@@ -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);
@@ -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<HTMLButtonElement>('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);
@@ -1,5 +1,6 @@
<ng-template #vodActions>
@if (playableVodItem(); as playableItem) {
@let playableItem = playableVodItem();
@if (playableItem || isDownloaded()) {
@if (hasPlaybackPosition()) {
<button
class="play-btn play-btn--resume"
@@ -73,96 +74,106 @@
</span>
</button>
}
@if (!isFavorite()) {
<button class="favorite-btn" (click)="toggleFavorite()">
<mat-icon>add</mat-icon>
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
</button>
} @else {
<button
class="favorite-btn favorite-btn--active"
(click)="toggleFavorite()"
>
<mat-icon>check</mat-icon>
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
</button>
}
@if (isElectron()) {
@if (isDownloaded()) {
@if (externalPrimaryButtonState() === 'idle') {
<button
class="download-btn"
(click)="playFromProviderSource(playableItem)"
>
<mat-icon>play_arrow</mat-icon>
{{
'PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE' | translate
}}
</button>
}
} @else if (isPausedDownload()) {
<button
class="download-btn download-btn--paused"
(click)="resumePausedDownload()"
>
<mat-icon>play_arrow</mat-icon>
{{ 'DOWNLOADS.RESUME' | translate }}
</button>
} @else if (isDownloading()) {
<button class="download-btn download-btn--downloading" disabled>
<mat-icon>downloading</mat-icon>
{{ 'DOWNLOADS.STATUS.DOWNLOADING' | translate }}
@if (playableItem) {
@if (!isFavorite()) {
<button class="favorite-btn" (click)="toggleFavorite()">
<mat-icon>add</mat-icon>
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
</button>
} @else {
<button
class="download-btn"
(click)="downloadVod(playableItem)"
class="favorite-btn favorite-btn--active"
(click)="toggleFavorite()"
>
<mat-icon>download</mat-icon>
{{ 'DOWNLOADS.DOWNLOAD' | translate }}
<mat-icon>check</mat-icon>
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
</button>
}
}
@if (multiSource.hasAlternatives()) {
<app-vod-sources-chip
[sources]="multiSource.sources()"
[title]="multiSourceTitle()"
[matchKind]="multiSource.matchKind()"
[count]="multiSource.alternativeCount()"
[autoFailoverEnabled]="multiSource.autoFailoverEnabled()"
[autoFailoverSupported]="autoFailoverSupported()"
[playbackLive]="playbackLive()"
(playRequested)="playFromSource($event)"
(pinRequested)="pinSource($event)"
(checkRequested)="checkSource($event)"
(autoFailoverToggled)="setAutoFailover($event)"
/>
}
@if (activeSourceCaption(); as caption) {
<p class="source-caption">
<span class="source-caption__label">
{{ 'PORTALS.MULTI_SOURCE.PLAYING_FROM' | translate }}
</span>
<span class="source-caption__source">{{ caption.source }}</span>
@if (multiSource.hasAlternatives()) {
<span class="source-caption__extra">
@if (caption.alternativeCount === 1) {
@if (isElectron()) {
@if (isDownloaded()) {
@if (externalPrimaryButtonState() === 'idle') {
<button
class="download-btn"
(click)="playFromProviderSource(playableItem)"
>
<mat-icon>play_arrow</mat-icon>
{{
'PORTALS.MULTI_SOURCE.ALSO_FOUND_IN_ONE'
'PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE'
| translate
}}
} @else {
{{
'PORTALS.MULTI_SOURCE.ALSO_FOUND_IN_OTHER'
| translate
: { count: caption.alternativeCount }
}}
}
</span>
</button>
}
} @else if (isPausedDownload()) {
<button
class="download-btn download-btn--paused"
(click)="resumePausedDownload()"
>
<mat-icon>play_arrow</mat-icon>
{{ 'DOWNLOADS.RESUME' | translate }}
</button>
} @else if (isDownloading()) {
<button
class="download-btn download-btn--downloading"
disabled
>
<mat-icon>downloading</mat-icon>
{{ 'DOWNLOADS.STATUS.DOWNLOADING' | translate }}
</button>
} @else {
<button
class="download-btn"
(click)="downloadVod(playableItem)"
>
<mat-icon>download</mat-icon>
{{ 'DOWNLOADS.DOWNLOAD' | translate }}
</button>
}
</p>
}
@if (multiSource.hasAlternatives()) {
<app-vod-sources-chip
[sources]="multiSource.sources()"
[title]="multiSourceTitle()"
[matchKind]="multiSource.matchKind()"
[count]="multiSource.alternativeCount()"
[autoFailoverEnabled]="multiSource.autoFailoverEnabled()"
[autoFailoverSupported]="autoFailoverSupported()"
[playbackLive]="playbackLive()"
(playRequested)="playFromSource($event)"
(pinRequested)="pinSource($event)"
(checkRequested)="checkSource($event)"
(autoFailoverToggled)="setAutoFailover($event)"
/>
}
@if (activeSourceCaption(); as caption) {
<p class="source-caption">
<span class="source-caption__label">
{{ 'PORTALS.MULTI_SOURCE.PLAYING_FROM' | translate }}
</span>
<span class="source-caption__source">{{
caption.source
}}</span>
@if (multiSource.hasAlternatives()) {
<span class="source-caption__extra">
@if (caption.alternativeCount === 1) {
{{
'PORTALS.MULTI_SOURCE.ALSO_FOUND_IN_ONE'
| translate
}}
} @else {
{{
'PORTALS.MULTI_SOURCE.ALSO_FOUND_IN_OTHER'
| translate
: {
count: caption.alternativeCount,
}
}}
}
</span>
}
</p>
}
}
}
</ng-template>
@@ -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;
}