fix(downloads): prefer local playback in Xtream details

This commit is contained in:
4gray committed 2026-08-01 16:29:49 +02:00
1 parent 0313dd7a9b
commit 89e708c591
5 files changed
+339 -29

No files matched your search

@@ -1,5 +1,5 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { VideoPlayer } from '@iptvnator/shared/interfaces';
import { XtreamVodDetails } from '@iptvnator/shared/interfaces';
import { VodDetailsPlaybackService } from './vod-details-playback.service';
import { VodDetailsRouteComponent } from './vod-details-route.component';
import {
@@ -21,13 +21,12 @@ describe('VodDetailsRouteComponent — playback actions', () => {
const {
currentPlaylist,
activeSession,
addRecentItem,
closeSession,
downloadsAvailable,
getDownloadedFilePath,
getPlaybackPosition,
selectedItem,
selectedPlayer,
snackBarOpen,
updateSettings,
isDownloaded,
playDownload,
} = stubs;
beforeEach(async () => {
@@ -113,6 +112,9 @@ describe('VodDetailsRouteComponent — playback actions', () => {
it('stops the external player when the button says Stop', async () => {
currentPlaylist.set({ id: 'playlist-1' });
downloadsAvailable.set(true);
isDownloaded.mockReturnValue(true);
getDownloadedFilePath.mockReturnValue('/downloads/example.mp4');
activeSession.set({
player: 'mpv',
status: 'playing',
@@ -136,6 +138,105 @@ describe('VodDetailsRouteComponent — playback actions', () => {
// first keeps running — the control doing the opposite of its label.
expect(playPinned).not.toHaveBeenCalled();
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);
isDownloaded.mockReturnValue(true);
getDownloadedFilePath.mockReturnValue('/downloads/example.mp4');
const component = fixture.componentInstance;
const playback = fixture.debugElement.injector.get(
VodDetailsPlaybackService
);
playback.routePlaybackPosition.set({
playlistId: 'playlist-1',
contentXtreamId: 650020,
contentType: 'vod',
positionSeconds: 120,
durationSeconds: 7744,
});
const playPinned = jest.spyOn(
component.multiSource,
'playPinnedSource'
);
await component.onPrimaryAction({
movie_data: {
stream_id: 650020,
name: 'Example',
container_extension: 'mp4',
},
} as never);
expect(playDownload).toHaveBeenCalledWith('/downloads/example.mp4');
expect(playPinned).not.toHaveBeenCalled();
expect(stubs.openResolvedPlayback).not.toHaveBeenCalled();
});
it('does not bypass a pending external launch through the primary handler', async () => {
currentPlaylist.set({ id: 'playlist-1' });
downloadsAvailable.set(true);
isDownloaded.mockReturnValue(true);
getDownloadedFilePath.mockReturnValue('/downloads/example.mp4');
activeSession.set({
player: 'mpv',
status: 'launching',
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 650020,
contentType: 'vod',
},
});
const component = fixture.componentInstance;
const playPinned = jest.spyOn(
component.multiSource,
'playPinnedSource'
);
await component.onPrimaryAction({
movie_data: {
stream_id: 650020,
name: 'Example',
container_extension: 'mp4',
},
} as never);
expect(component.isExternalLaunchPending()).toBe(true);
expect(playDownload).not.toHaveBeenCalled();
expect(playPinned).not.toHaveBeenCalled();
expect(stubs.openResolvedPlayback).not.toHaveBeenCalled();
});
it('does not let the provider secondary bypass a pending external launch', async () => {
currentPlaylist.set({ id: 'playlist-1' });
activeSession.set({
player: 'mpv',
status: 'launching',
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 650020,
contentType: 'vod',
},
});
const component = fixture.componentInstance;
const playPinned = jest.spyOn(
component.multiSource,
'playPinnedSource'
);
await component.playFromProviderSource({
movie_data: {
stream_id: 650020,
name: 'Example',
container_extension: 'mp4',
},
} as never);
expect(component.isExternalLaunchPending()).toBe(true);
expect(playPinned).not.toHaveBeenCalled();
expect(stubs.openResolvedPlayback).not.toHaveBeenCalled();
});
it('follows external playback progress, which has no timeupdate', () => {
@@ -65,6 +65,7 @@ describe('VodDetailsRouteComponent fallback actions', () => {
const downloadsAvailable = signal(false);
const isFavorite = signal(false);
const downloads = signal([]);
const activeSession = signal<unknown>(null);
const currentPlaylist = signal({
id: 'playlist-1',
userAgent: 'IPTVnator',
@@ -81,6 +82,9 @@ describe('VodDetailsRouteComponent fallback actions', () => {
const isEmbeddedPlayer = jest.fn().mockReturnValue(true);
const openResolvedPlayback = jest.fn();
const startDownload = jest.fn().mockResolvedValue(undefined);
const isDownloaded = jest.fn().mockReturnValue(false);
const getDownloadedFilePath = jest.fn();
const playDownload = jest.fn().mockResolvedValue(undefined);
const toggleFavorite = jest.fn();
const sparseItem = (): SparseVodItem => ({
info: [],
@@ -89,9 +93,24 @@ describe('VodDetailsRouteComponent fallback actions', () => {
name: 'Catalog movie',
stream_icon: 'https://example.com/catalog-poster.jpg',
});
const richItem = (): XtreamVodDetails =>
({
info: {
name: 'Metadata movie',
description: 'A populated description',
movie_image: 'https://example.com/metadata-poster.jpg',
releasedate: '2025-01-02',
},
movie_data: {
stream_id: 650020,
name: 'Metadata movie',
container_extension: 'mp4',
},
}) as XtreamVodDetails;
beforeEach(async () => {
selectedItem.set(null);
downloadsAvailable.set(false);
activeSession.set(null);
isFavorite.set(false);
currentPlaylist.set({
id: 'playlist-1',
@@ -107,6 +126,9 @@ describe('VodDetailsRouteComponent fallback actions', () => {
isEmbeddedPlayer.mockReset().mockReturnValue(true);
openResolvedPlayback.mockClear();
startDownload.mockClear();
isDownloaded.mockReset().mockReturnValue(false);
getDownloadedFilePath.mockReset();
playDownload.mockClear();
toggleFavorite.mockClear();
await TestBed.configureTestingModule({
imports: [VodDetailsRouteComponent],
@@ -167,13 +189,13 @@ describe('VodDetailsRouteComponent fallback actions', () => {
useValue: {
isAvailable: downloadsAvailable,
downloads,
isDownloaded: jest.fn().mockReturnValue(false),
isDownloaded,
isDownloading: jest.fn().mockReturnValue(false),
isPaused: jest.fn().mockReturnValue(false),
resumeDownloadByContent: jest.fn(),
startDownload,
getDownloadedFilePath: jest.fn(),
playDownload: jest.fn(),
getDownloadedFilePath,
playDownload,
},
},
{
@@ -187,7 +209,7 @@ describe('VodDetailsRouteComponent fallback actions', () => {
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: {
activeSession: signal(null),
activeSession,
closeSession: jest.fn(),
},
},
@@ -358,4 +380,127 @@ describe('VodDetailsRouteComponent fallback actions', () => {
host.querySelector('app-portal-detail-shell')?.classList
).not.toContain('shell-host--watch');
});
it('renders Offline and offline-first actions for sparse downloaded details', async () => {
selectedItem.set(sparseItem());
downloadsAvailable.set(true);
isDownloaded.mockReturnValue(true);
getDownloadedFilePath.mockReturnValue('/downloads/catalog-movie.mp4');
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
const primary =
host.querySelector<HTMLButtonElement>('button.play-btn');
expect(host.textContent).toContain('DOWNLOADS.OFFLINE');
expect(primary?.textContent).toContain('DOWNLOADS.PLAY_LOCAL');
expect(
Array.from(host.querySelectorAll('button.download-btn')).some(
(button) =>
button.textContent?.includes(
'PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE'
)
)
).toBe(true);
primary?.click();
await fixture.whenStable();
expect(playDownload).toHaveBeenCalledWith(
'/downloads/catalog-movie.mp4'
);
expect(constructVodStreamUrl).not.toHaveBeenCalled();
});
it('renders Offline in rich downloaded details', () => {
selectedItem.set(richItem());
downloadsAvailable.set(true);
isDownloaded.mockReturnValue(true);
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
expect(
host.querySelector('[data-testid="xtream-vod-fallback"]')
).toBeNull();
expect(host.textContent).toContain('DOWNLOADS.OFFLINE');
expect(
host.querySelector<HTMLButtonElement>('button.play-btn')
?.textContent
).toContain('DOWNLOADS.PLAY_LOCAL');
});
it('keeps pinned provider playback behind the downloaded secondary action', async () => {
selectedItem.set(sparseItem());
downloadsAvailable.set(true);
isDownloaded.mockReturnValue(true);
const playPinned = jest
.spyOn(fixture.componentInstance.multiSource, 'playPinnedSource')
.mockResolvedValue('played');
fixture.detectChanges();
const providerButton = Array.from(
(
fixture.nativeElement as HTMLElement
).querySelectorAll<HTMLButtonElement>('button')
).find((button) =>
button.textContent?.includes(
'PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE'
)
);
providerButton?.click();
await fixture.whenStable();
expect(playPinned).toHaveBeenCalled();
expect(playDownload).not.toHaveBeenCalled();
expect(constructVodStreamUrl).not.toHaveBeenCalled();
});
it('hides Restart and provider playback while a downloaded movie is opening externally', async () => {
selectedItem.set(sparseItem());
downloadsAvailable.set(true);
isDownloaded.mockReturnValue(true);
activeSession.set({
player: 'mpv',
status: 'launching',
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 650020,
contentType: 'vod',
},
});
const position = {
contentXtreamId: 650020,
contentType: 'vod',
playlistId: 'playlist-1',
positionSeconds: 42,
durationSeconds: 120,
} satisfies PlaybackPositionData;
fixture.detectChanges();
fixture.componentInstance.vodPlaybackPosition.set(position);
fixture.componentInstance.routePlaybackPosition.set(position);
fixture.detectChanges();
await fixture.whenStable();
const host = fixture.nativeElement as HTMLElement;
const primary = host.querySelector<HTMLButtonElement>(
'button.play-btn--resume'
);
expect(primary?.disabled).toBe(true);
expect(primary?.textContent).toContain('Opening in MPV...');
expect(
Array.from(host.querySelectorAll('button')).some((button) =>
button.textContent?.includes('XTREAM.RESTART')
)
).toBe(false);
expect(
Array.from(host.querySelectorAll('button')).some((button) =>
button.textContent?.includes(
'PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE'
)
)
).toBe(false);
});
});
@@ -13,20 +13,26 @@
"
>
<span class="play-btn__icon-shell" aria-hidden="true">
<mat-icon class="play-btn__icon">{{
externalPrimaryIcon()
}}</mat-icon>
<mat-icon class="play-btn__icon">
{{
isOfflinePrimary()
? 'play_circle'
: externalPrimaryIcon()
}}
</mat-icon>
</span>
<span class="play-btn__label">
@if (externalPrimaryLabel(); as label) {
{{ label }}
} @else if (isOfflinePrimary()) {
{{ 'DOWNLOADS.PLAY_LOCAL' | translate }}
} @else {
{{ 'XTREAM.RESUME' | translate }}
{{ formatPosition() }}
}
</span>
</button>
@if (!isExternalStopAction()) {
@if (externalPrimaryButtonState() === 'idle' && !isDownloaded()) {
<button
class="play-btn play-btn--restart"
(click)="restartVod(playableItem)"
@@ -48,13 +54,19 @@
"
>
<span class="play-btn__icon-shell" aria-hidden="true">
<mat-icon class="play-btn__icon">{{
externalPrimaryIcon()
}}</mat-icon>
<mat-icon class="play-btn__icon">
{{
isOfflinePrimary()
? 'play_circle'
: externalPrimaryIcon()
}}
</mat-icon>
</span>
<span class="play-btn__label">
@if (externalPrimaryLabel(); as label) {
{{ label }}
} @else if (isOfflinePrimary()) {
{{ 'DOWNLOADS.PLAY_LOCAL' | translate }}
} @else {
{{ 'XTREAM.PLAY' | translate }}
}
@@ -77,13 +89,17 @@
}
@if (isElectron()) {
@if (isDownloaded()) {
<button
class="download-btn download-btn--completed"
(click)="playFromLocal()"
>
<mat-icon>play_circle</mat-icon>
{{ 'DOWNLOADS.PLAY_LOCAL' | translate }}
</button>
@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"
@@ -210,6 +226,11 @@
@if (info.country) {
<span class="details__tag">{{ info.country }}</span>
}
@if (isDownloaded()) {
<span class="details__tag">
{{ 'DOWNLOADS.OFFLINE' | translate }}
</span>
}
</ng-template>
<ng-template appDetailMeta>
@@ -451,6 +472,11 @@
@if (fallback.format) {
<span class="details__tag">{{ fallback.format }}</span>
}
@if (isDownloaded()) {
<span class="details__tag">
{{ 'DOWNLOADS.OFFLINE' | translate }}
</span>
}
</ng-template>
<ng-template appDetailMeta>
@@ -271,6 +271,10 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
readonly isDownloaded = this.downloads.isDownloaded;
readonly isDownloading = this.downloads.isDownloading;
readonly isPausedDownload = this.downloads.isPausedDownload;
readonly isOfflinePrimary = computed(
() =>
this.isDownloaded() && this.externalPrimaryButtonState() === 'idle'
);
readonly trailerEmbedUrl = computed(() =>
youtubeEmbedUrl(this.selectedVodInfo()?.youtube_trailer)
@@ -479,6 +483,25 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
return;
}
if (this.playback.isExternalLaunchPending()) {
return;
}
if (this.isDownloaded()) {
await this.playFromLocal();
return;
}
await this.playFromProviderSource(vodItem);
}
async playFromProviderSource(
vodItem: XtreamVodDetails | null
): Promise<void> {
if (this.externalPrimaryButtonState() !== 'idle') {
return;
}
// A pinned source is an explicit "play this movie from here", so it
// outranks the playlist the route happens to be on. Falls through to
// the normal path when nothing is pinned or the pin cannot resolve.
@@ -54,7 +54,14 @@ export function createVodDetailsRouteStubs() {
addRecentItem: jest.fn(),
cancelDetailsRequest: jest.fn(),
vodStreamsPlaylistId: signal<string | null>(null),
downloadsAvailable: signal(false),
downloads: signal([]),
isDownloaded: jest.fn().mockReturnValue(false),
isDownloading: jest.fn().mockReturnValue(false),
isPausedDownload: jest.fn().mockReturnValue(false),
resumeDownloadByContent: jest.fn().mockResolvedValue(undefined),
getDownloadedFilePath: jest.fn(),
playDownload: jest.fn().mockResolvedValue(undefined),
getPlaybackPosition: jest.fn().mockResolvedValue(null),
savePlaybackPosition: jest.fn().mockResolvedValue(undefined),
activeSession: signal<unknown>(null),
@@ -82,6 +89,7 @@ export function resetVodDetailsRouteStubs(stubs: VodDetailsRouteStubs): void {
stubs.currentPlaylist.set(null);
stubs.vodStreams.set([]);
stubs.vodCategories.set([]);
stubs.downloadsAvailable.set(false);
stubs.activeSession.set(null);
stubs.selectedPlayer.set(VideoPlayer.Html5Player);
stubs.updateSettings.mockClear().mockResolvedValue(undefined);
@@ -91,6 +99,11 @@ export function resetVodDetailsRouteStubs(stubs: VodDetailsRouteStubs): void {
value.mockClear();
}
}
stubs.isDownloaded.mockReturnValue(false);
stubs.isDownloading.mockReturnValue(false);
stubs.isPausedDownload.mockReturnValue(false);
stubs.getDownloadedFilePath.mockReturnValue(undefined);
}
/**
@@ -191,13 +204,15 @@ export async function configureVodDetailsRouteTestBed(
{
provide: DownloadsService,
useValue: {
isAvailable: signal(false),
isAvailable: stubs.downloadsAvailable,
downloads: stubs.downloads,
isDownloaded: jest.fn().mockReturnValue(false),
isDownloading: jest.fn().mockReturnValue(false),
isDownloaded: stubs.isDownloaded,
isDownloading: stubs.isDownloading,
isPaused: stubs.isPausedDownload,
resumeDownloadByContent: stubs.resumeDownloadByContent,
startDownload: stubs.startDownload,
getDownloadedFilePath: jest.fn(),
playDownload: jest.fn(),
getDownloadedFilePath: stubs.getDownloadedFilePath,
playDownload: stubs.playDownload,
},
},
{