fix(downloads): prefer local playback in shared details

This commit is contained in:
4gray committed 2026-08-01 16:29:49 +02:00
1 parent 34fa69e6f5
commit 95aa73058b
3 files changed
+473 -31

No files matched your search

@@ -28,6 +28,11 @@
@if (meta.country) {
<span class="details__tag">{{ meta.country }}</span>
}
@if (isDownloaded()) {
<span class="details__tag">
{{ 'DOWNLOADS.OFFLINE' | translate }}
</span>
}
</ng-template>
<!-- Metadata Grid -->
@@ -37,7 +42,12 @@
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
@if (meta.tmdbCast?.length) {
<div class="details__cast-strip">
@for (member of meta.tmdbCast; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) {
@for (
member of meta.tmdbCast;
track member.tmdbPersonId
? 'p' + member.tmdbPersonId
: 'i' + $index
) {
<span
class="details__cast-chip"
[class.details__cast-chip--clickable]="
@@ -80,7 +90,12 @@
<span class="label">{{ 'XTREAM.DIRECTOR' | translate }}</span>
@if (meta.tmdbDirectors?.length) {
<div class="details__cast-strip">
@for (member of meta.tmdbDirectors; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) {
@for (
member of meta.tmdbDirectors;
track member.tmdbPersonId
? 'p' + member.tmdbPersonId
: 'i' + $index
) {
<span
class="details__cast-chip"
[class.details__cast-chip--clickable]="
@@ -116,7 +131,9 @@
}
@if (meta.ratingKinopoisk) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.KINOPOISK_RATING' | translate }}</span>
<span class="label">{{
'XTREAM.KINOPOISK_RATING' | translate
}}</span>
<span class="value">{{ meta.ratingKinopoisk }}</span>
</div>
}
@@ -130,21 +147,34 @@
class="play-btn play-btn--resume"
(click)="onPrimaryAction()"
[disabled]="isExternalLaunchPending()"
[class.play-btn--state-launching]="externalPrimaryButtonState() === 'launching'"
[class.play-btn--state-stop]="externalPrimaryButtonState() === 'stop'"
[class.play-btn--state-launching]="
externalPrimaryButtonState() === 'launching'
"
[class.play-btn--state-stop]="
externalPrimaryButtonState() === 'stop'
"
>
<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 }} {{ formattedPosition() }}
{{ 'XTREAM.RESUME' | translate }}
{{ formattedPosition() }}
}
</span>
</button>
@if (!isExternalStopAction()) {
@if (!isExternalStopAction() && !isDownloaded()) {
<button class="play-btn play-btn--restart" (click)="onPlay()">
<mat-icon>replay</mat-icon>
{{ 'XTREAM.RESTART' | translate }}
@@ -155,15 +185,27 @@
class="play-btn"
(click)="onPrimaryAction()"
[disabled]="isExternalLaunchPending()"
[class.play-btn--state-launching]="externalPrimaryButtonState() === 'launching'"
[class.play-btn--state-stop]="externalPrimaryButtonState() === 'stop'"
[class.play-btn--state-launching]="
externalPrimaryButtonState() === 'launching'
"
[class.play-btn--state-stop]="
externalPrimaryButtonState() === 'stop'
"
>
<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 }}
}
@@ -190,10 +232,14 @@
<!-- Download buttons (Electron only) -->
@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)="onProviderAction()">
<mat-icon>play_arrow</mat-icon>
{{
'PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE' | translate
}}
</button>
}
} @else if (isPausedDownload()) {
<button
class="download-btn download-btn--paused"
@@ -235,11 +281,16 @@
<section class="details__similar" data-test-id="tmdb-similar">
<h3>{{ 'XTREAM.SIMILAR' | translate }}</h3>
<div class="details__similar-strip">
@for (item of similarInPortals(); track item.match.xtreamId) {
@for (
item of similarInPortals();
track item.match.xtreamId
) {
<button
type="button"
class="details__similar-card"
[title]="item.title + ' — ' + item.match.playlistName"
[title]="
item.title + ' — ' + item.match.playlistName
"
(click)="openSimilarInPortals(item)"
>
@if (item.posterUrl) {
@@ -276,7 +327,15 @@
<iframe
[src]="trailerUrl | safe"
title="YouTube video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allow="
accelerometer;
autoplay;
clipboard-write;
encrypted-media;
gyroscope;
picture-in-picture;
web-share;
"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
></iframe>
@@ -0,0 +1,358 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
import {
CrossPortalSimilarService,
DownloadItem,
DownloadsService,
} from '@iptvnator/services';
import {
ExternalPlayerSession,
VodDetailsItem,
createStalkerVodItem,
} from '@iptvnator/shared/interfaces';
import type { VodDetailsComponent as VodDetailsComponentInstance } from './vod-details.component';
jest.unstable_mockModule('video.js', () => ({
default: jest.fn(),
}));
jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({}));
jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({}));
jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
const STALKER_VOD: VodDetailsItem = createStalkerVodItem(
{
id: '42',
cmd: '/media/file_42.mpg',
info: {
name: 'Catalog Movie',
movie_image: '',
description: 'A movie from a Stalker portal',
actors: '',
director: '',
releasedate: '2026-01-02',
genre: 'Drama',
rating_imdb: '',
rating_kinopoisk: '',
},
},
'stalker-1'
);
const MATCHING_MPV_SESSION: ExternalPlayerSession = {
id: 'mpv-session-1',
player: 'mpv',
status: 'playing',
title: 'Catalog Movie',
streamUrl: 'https://portal.example/movie.mp4',
contentInfo: {
playlistId: 'stalker-1',
contentXtreamId: 42,
contentType: 'vod',
},
startedAt: '2026-07-30T10:00:00.000Z',
updatedAt: '2026-07-30T10:01:00.000Z',
canClose: true,
};
const MATCHING_LAUNCHING_MPV_SESSION: ExternalPlayerSession = {
...MATCHING_MPV_SESSION,
status: 'launching',
};
describe('VodDetailsComponent offline playback', () => {
let VodDetailsComponent: typeof import('./vod-details.component').VodDetailsComponent;
let fixture: ComponentFixture<VodDetailsComponentInstance>;
let downloads: ReturnType<typeof signal<DownloadItem[]>>;
let playDownload: jest.Mock;
let closeSession: jest.Mock;
let playClicked: jest.Mock;
let resumeClicked: jest.Mock;
const matchingDownload = (
xtreamId: number,
playlistId: string,
contentType: 'vod' | 'episode'
) =>
downloads().find(
(download) =>
download.xtreamId === xtreamId &&
download.playlistId === playlistId &&
download.contentType === contentType
);
const buttonText = (button: HTMLButtonElement): string =>
button.textContent?.replace(/\s+/g, ' ').trim() ?? '';
const primaryButton = (): HTMLButtonElement => {
const button = fixture.nativeElement.querySelector(
'.action-buttons > .play-btn'
) as HTMLButtonElement | null;
expect(button).not.toBeNull();
return button as HTMLButtonElement;
};
const findButtonWithText = (text: string): HTMLButtonElement | undefined =>
Array.from(
fixture.nativeElement.querySelectorAll('.action-buttons button')
).find((candidate) =>
buttonText(candidate as HTMLButtonElement).includes(text)
) as HTMLButtonElement | undefined;
const buttonWithText = (text: string): HTMLButtonElement => {
const button = findButtonWithText(text);
expect(button).toBeDefined();
return button as HTMLButtonElement;
};
const render = async ({
playbackPosition = null,
externalPlayback = null,
}: {
playbackPosition?: number | null;
externalPlayback?: ExternalPlayerSession | null;
} = {}) => {
fixture = TestBed.createComponent(VodDetailsComponent);
fixture.componentRef.setInput('item', STALKER_VOD);
fixture.componentRef.setInput('playbackPosition', playbackPosition);
fixture.componentRef.setInput('externalPlayback', externalPlayback);
playClicked = jest.fn();
resumeClicked = jest.fn();
fixture.componentInstance.playClicked.subscribe(playClicked);
fixture.componentInstance.resumeClicked.subscribe(resumeClicked);
await fixture.whenStable();
};
const completeDownload = () => {
downloads.set([
{
id: 7,
playlistId: 'stalker-1',
xtreamId: 42,
contentType: 'vod',
title: 'Catalog Movie',
url: 'https://portal.example/movie.mp4',
filePath: '/downloads/movie.mp4',
status: 'completed',
},
]);
};
beforeAll(async () => {
({ VodDetailsComponent } = await import('./vod-details.component'));
});
beforeEach(async () => {
downloads = signal<DownloadItem[]>([]);
playDownload = jest.fn().mockResolvedValue({ success: true });
closeSession = jest.fn().mockResolvedValue(undefined);
await TestBed.configureTestingModule({
imports: [VodDetailsComponent, TranslateModule.forRoot()],
providers: [
{
provide: DownloadsService,
useValue: {
downloads,
isAvailable: signal(true),
isDownloaded: jest.fn(
(
xtreamId: number,
playlistId: string,
contentType: 'vod' | 'episode'
) =>
matchingDownload(
xtreamId,
playlistId,
contentType
)?.status === 'completed'
),
isDownloading: jest.fn(() => false),
isPaused: jest.fn(() => false),
getDownloadedFilePath: jest.fn(
(
xtreamId: number,
playlistId: string,
contentType: 'vod' | 'episode'
) =>
matchingDownload(
xtreamId,
playlistId,
contentType
)?.filePath
),
playDownload,
resumeDownloadByContent: jest.fn(),
},
},
{
provide: CrossPortalSimilarService,
useValue: {
isAvailable: false,
matchRecommendations: jest.fn().mockResolvedValue([]),
buildLink: jest.fn(),
},
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: {
activeSession: signal(null),
visibleSession: signal(null),
dismissActiveSession: jest.fn(),
closeSession,
},
},
{
provide: Router,
useValue: {
navigate: jest.fn(),
},
},
],
}).compileComponents();
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', {
XTREAM: {
PLAY: 'Play',
RESUME: 'Resume',
RESTART: 'Restart',
},
PORTALS: {
ADD_TO_FAVORITES: 'Add to favorites',
REMOVE_FROM_FAVORITES: 'Remove from favorites',
MULTI_SOURCE: {
PLAY_FROM_SOURCE: 'Play from this source',
},
},
DOWNLOADS: {
DOWNLOAD: 'Download',
PLAY_LOCAL: 'Play Local',
RESUME: 'Resume download',
OFFLINE: 'Offline',
STATUS: {
DOWNLOADING: 'Downloading',
},
},
});
translate.use('en');
});
afterEach(() => {
fixture?.destroy();
});
it('renders Offline and plays a completed download from the primary action', async () => {
completeDownload();
await render();
expect(fixture.nativeElement.textContent).toContain('Offline');
expect(buttonText(primaryButton())).toContain('Play Local');
primaryButton().click();
await fixture.whenStable();
expect(playDownload).toHaveBeenCalledWith('/downloads/movie.mp4');
expect(playClicked).not.toHaveBeenCalled();
expect(resumeClicked).not.toHaveBeenCalled();
});
it('plays the provider source from the downloaded secondary action', async () => {
completeDownload();
await render();
buttonWithText('Play from this source').click();
await fixture.whenStable();
expect(playClicked).toHaveBeenCalledWith(STALKER_VOD);
expect(resumeClicked).not.toHaveBeenCalled();
expect(playDownload).not.toHaveBeenCalled();
});
it('resumes the provider source from the downloaded secondary action', async () => {
completeDownload();
await render({ playbackPosition: 83 });
buttonWithText('Play from this source').click();
await fixture.whenStable();
expect(resumeClicked).toHaveBeenCalledWith({
item: STALKER_VOD,
positionSeconds: 83,
});
expect(playClicked).not.toHaveBeenCalled();
expect(playDownload).not.toHaveBeenCalled();
expect(
Array.from(
fixture.nativeElement.querySelectorAll('.action-buttons button')
).some((button) =>
buttonText(button as HTMLButtonElement).includes('Restart')
)
).toBe(false);
});
it('keeps Stop MPV ahead of local and provider playback', async () => {
completeDownload();
await render({ externalPlayback: MATCHING_MPV_SESSION });
expect(buttonText(primaryButton())).toContain('Stop MPV');
expect(findButtonWithText('Play from this source')).toBeUndefined();
primaryButton().click();
await fixture.whenStable();
expect(closeSession).toHaveBeenCalledWith(MATCHING_MPV_SESSION);
expect(playDownload).not.toHaveBeenCalled();
expect(playClicked).not.toHaveBeenCalled();
expect(resumeClicked).not.toHaveBeenCalled();
});
it('keeps an opening MPV session ahead of every downloaded action', async () => {
completeDownload();
await render({
playbackPosition: 83,
externalPlayback: MATCHING_LAUNCHING_MPV_SESSION,
});
expect(buttonText(primaryButton())).toContain('Opening in MPV...');
expect(primaryButton().disabled).toBe(true);
expect(findButtonWithText('Play from this source')).toBeUndefined();
expect(findButtonWithText('Restart')).toBeUndefined();
expect(playDownload).not.toHaveBeenCalled();
expect(playClicked).not.toHaveBeenCalled();
expect(resumeClicked).not.toHaveBeenCalled();
});
it('keeps normal provider Play as the undownloaded primary action', async () => {
await render();
expect(buttonText(primaryButton())).toContain('Play');
expect(fixture.nativeElement.textContent).not.toContain('Offline');
primaryButton().click();
await fixture.whenStable();
expect(playClicked).toHaveBeenCalledWith(STALKER_VOD);
expect(resumeClicked).not.toHaveBeenCalled();
expect(playDownload).not.toHaveBeenCalled();
});
it('keeps provider Resume and Restart actions when undownloaded', async () => {
await render({ playbackPosition: 83 });
expect(buttonText(primaryButton())).toContain('Resume 1:23');
primaryButton().click();
await fixture.whenStable();
expect(resumeClicked).toHaveBeenCalledWith({
item: STALKER_VOD,
positionSeconds: 83,
});
buttonWithText('Restart').click();
await fixture.whenStable();
expect(playClicked).toHaveBeenCalledWith(STALKER_VOD);
expect(playDownload).not.toHaveBeenCalled();
});
});
@@ -1,4 +1,13 @@
import { Component, computed, effect, inject, input, output, signal, untracked } from '@angular/core';
import {
Component,
computed,
effect,
inject,
input,
output,
signal,
untracked,
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { SafePipe } from '@iptvnator/pipes';
@@ -89,10 +98,16 @@ export class VodDetailsComponent {
readonly playClicked = output<VodDetailsItem>();
/** Emitted when resume button is clicked (includes position) */
readonly resumeClicked = output<{ item: VodDetailsItem; positionSeconds: number }>();
readonly resumeClicked = output<{
item: VodDetailsItem;
positionSeconds: number;
}>();
/** Emitted when favorite toggle is clicked */
readonly favoriteToggled = output<{ item: VodDetailsItem; isFavorite: boolean }>();
readonly favoriteToggled = output<{
item: VodDetailsItem;
isFavorite: boolean;
}>();
/** Emitted when back button is clicked */
readonly backClicked = output<void>();
@@ -212,6 +227,10 @@ export class VodDetailsComponent {
readonly isExternalLaunchPending = this.externalButton.isLaunchPending;
readonly isExternalStopAction = this.externalButton.isStopAction;
readonly externalPrimaryButtonState = this.externalButton.buttonState;
readonly isOfflinePrimary = computed(
() =>
this.isDownloaded() && this.externalPrimaryButtonState() === 'idle'
);
// ============ Actions ============
@@ -220,12 +239,21 @@ export class VodDetailsComponent {
this.playClicked.emit(this.item());
}
onPrimaryAction(): void {
async onPrimaryAction(): Promise<void> {
if (this.isExternalStopAction()) {
void this.stopExternalPlayback();
await this.stopExternalPlayback();
return;
}
if (this.isDownloaded()) {
await this.playFromLocal();
return;
}
this.onProviderAction();
}
onProviderAction(): void {
if (this.hasPlaybackPosition()) {
this.onResume();
return;
@@ -260,7 +288,9 @@ export class VodDetailsComponent {
}
const item = this.item();
const basePath =
item.type === 'stalker' ? '/workspace/stalker' : '/workspace/xtreams';
item.type === 'stalker'
? '/workspace/stalker'
: '/workspace/xtreams';
void this.router.navigate([
basePath,
item.playlistId,
@@ -288,10 +318,7 @@ export class VodDetailsComponent {
);
}
onInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
onInlineTimeUpdate(event: { currentTime: number; duration: number }): void {
this.inlineTimeUpdated.emit(event);
}
@@ -303,9 +330,7 @@ export class VodDetailsComponent {
this.streamUrlCopied.emit();
}
onInlineExternalFallbackRequested(
request: PlaybackFallbackRequest
): void {
onInlineExternalFallbackRequested(request: PlaybackFallbackRequest): void {
this.inlineExternalFallbackRequested.emit(request);
}