mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
fix(downloads): prefer local playback in Xtream details
This commit is contained in:
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,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user