feat(downloads): hand off to provider-only details

This commit is contained in:
4gray committed 2026-08-01 16:29:51 +02:00
1 parent 942435cc98
commit 72baf0bf1c
26 files changed
+675 -153

No files matched your search

@@ -10,6 +10,9 @@ import type { Playlist, StalkerPortalItem } from '@iptvnator/shared/interfaces';
import { DownloadLibraryNavigationService } from './download-library-navigation.service';
const PLAYLIST_ID = 'playlist-a';
const PROVIDER_ONLY_STATE = {
state: { detailPresentation: 'provider-only' },
} as const;
const XTREAM_PLAYLIST = {
_id: PLAYLIST_ID,
title: 'Xtream source',
@@ -170,14 +173,10 @@ describe('DownloadLibraryNavigationService', () => {
PLAYLIST_ID,
'movie'
);
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'xtreams',
PLAYLIST_ID,
'vod',
'7',
'41',
]);
expect(router.navigate).toHaveBeenCalledWith(
['/workspace', 'xtreams', PLAYLIST_ID, 'vod', '7', '41'],
PROVIDER_ONLY_STATE
);
});
it('returns false when the router refuses an Xtream navigation', async () => {
@@ -188,14 +187,10 @@ describe('DownloadLibraryNavigationService', () => {
await expect(navigation.open(download())).resolves.toBe(false);
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'xtreams',
PLAYLIST_ID,
'vod',
'7',
'41',
]);
expect(router.navigate).toHaveBeenCalledWith(
['/workspace', 'xtreams', PLAYLIST_ID, 'vod', '7', '41'],
PROVIDER_ONLY_STATE
);
});
it.each(['xtream', 'stalker'] as const)(
@@ -241,14 +236,10 @@ describe('DownloadLibraryNavigationService', () => {
PLAYLIST_ID,
'series'
);
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'xtreams',
PLAYLIST_ID,
'series',
'12',
'93',
]);
expect(router.navigate).toHaveBeenCalledWith(
['/workspace', 'xtreams', PLAYLIST_ID, 'series', '12', '93'],
PROVIDER_ONLY_STATE
);
});
it.each([
@@ -279,14 +270,10 @@ describe('DownloadLibraryNavigationService', () => {
await expect(navigation.open(item)).resolves.toBe(true);
expect(db.getContentByXtreamId).not.toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'xtreams',
PLAYLIST_ID,
'vod',
'18',
'41',
]);
expect(router.navigate).toHaveBeenCalledWith(
['/workspace', 'xtreams', PLAYLIST_ID, 'vod', '18', '41'],
PROVIDER_ONLY_STATE
);
});
it('resolves a concrete target before navigating that exact cached target', async () => {
@@ -303,7 +290,10 @@ describe('DownloadLibraryNavigationService', () => {
await expect(navigation.navigateResolvedTarget(target)).resolves.toBe(
true
);
expect(router.navigate).toHaveBeenCalledWith(target.link);
expect(router.navigate).toHaveBeenCalledWith(
target.link,
PROVIDER_ONLY_STATE
);
expect(db.getContentByXtreamId).toHaveBeenCalledTimes(1);
});
@@ -345,6 +335,7 @@ describe('DownloadLibraryNavigationService', () => {
['/workspace', 'stalker', PLAYLIST_ID, 'vod', 'vod'],
{
state: {
detailPresentation: 'provider-only',
openStalkerItem: {
...recent,
id: '41',
@@ -385,6 +376,7 @@ describe('DownloadLibraryNavigationService', () => {
['/workspace', 'stalker', PLAYLIST_ID, 'vod', 'vod'],
{
state: {
detailPresentation: 'provider-only',
openStalkerItem: {
...recent,
id: '72',
@@ -420,6 +412,7 @@ describe('DownloadLibraryNavigationService', () => {
['/workspace', 'stalker', PLAYLIST_ID, 'vod', 'series'],
{
state: {
detailPresentation: 'provider-only',
openStalkerItem: {
...recent,
id: expectedId,
@@ -451,6 +444,7 @@ describe('DownloadLibraryNavigationService', () => {
['/workspace', 'stalker', PLAYLIST_ID, 'vod', 'vod'],
{
state: {
detailPresentation: 'provider-only',
openStalkerItem: {
...recent,
id: '41:episode',
@@ -494,6 +488,7 @@ describe('DownloadLibraryNavigationService', () => {
],
{
state: {
detailPresentation: 'provider-only',
openStalkerItem: expect.objectContaining({
category_id: expected,
}),
@@ -515,6 +510,7 @@ describe('DownloadLibraryNavigationService', () => {
['/workspace', 'stalker', PLAYLIST_ID, 'vod', 'vod'],
{
state: {
detailPresentation: 'provider-only',
openStalkerItem: {
id: '41',
category_id: 'vod',
@@ -546,6 +542,7 @@ describe('DownloadLibraryNavigationService', () => {
['/workspace', 'stalker', PLAYLIST_ID, 'series', 'series'],
{
state: {
detailPresentation: 'provider-only',
openStalkerItem: {
id: '72',
category_id: 'series',
@@ -8,6 +8,7 @@ import {
} from '@iptvnator/services';
import {
buildStalkerDetailNavigationTarget,
PROVIDER_ONLY_DETAIL_PRESENTATION,
type WorkspaceNavigationTarget,
} from '@iptvnator/portal/shared/util';
import type { StalkerPortalItem } from '@iptvnator/shared/interfaces';
@@ -68,11 +69,12 @@ export class DownloadLibraryNavigationService {
target: WorkspaceNavigationTarget
): Promise<boolean> {
try {
return target.state
? await this.router.navigate(target.link, {
state: target.state,
})
: await this.router.navigate(target.link);
return await this.router.navigate(target.link, {
state: {
...target.state,
detailPresentation: PROVIDER_ONLY_DETAIL_PRESENTATION,
},
});
} catch {
return false;
}
+1
View File
@@ -23,6 +23,7 @@ export * from './lib/workspace-header-context.service';
export * from './lib/workspace-view-command.types';
export * from './lib/workspace-view-command.service';
export * from './lib/navigation/portal-rail-links';
export * from './lib/navigation/provider-detail-mode';
export * from './lib/navigation/portal-route.utils';
export * from './lib/navigation/workspace-portal-navigation';
export * from './lib/collection';
@@ -0,0 +1,20 @@
import {
PROVIDER_ONLY_DETAIL_PRESENTATION,
isProviderOnlyDetailState,
} from './provider-detail-mode';
describe('provider detail presentation state', () => {
it('recognizes only the explicit provider-only marker', () => {
expect(
isProviderOnlyDetailState({
detailPresentation: PROVIDER_ONLY_DETAIL_PRESENTATION,
})
).toBe(true);
expect(isProviderOnlyDetailState(null)).toBe(false);
expect(isProviderOnlyDetailState({})).toBe(false);
expect(
isProviderOnlyDetailState({ detailPresentation: 'offline' })
).toBe(false);
});
});
@@ -0,0 +1,10 @@
export const PROVIDER_ONLY_DETAIL_PRESENTATION = 'provider-only' as const;
export function isProviderOnlyDetailState(state: unknown): boolean {
return (
typeof state === 'object' &&
state !== null &&
(state as { detailPresentation?: unknown }).detailPresentation ===
PROVIDER_ONLY_DETAIL_PRESENTATION
);
}
@@ -1,8 +1,11 @@
@if (contentType() === 'vod') {
@if ((selectedItem()?.series?.length ?? 0) > 0) {
<app-stalker-series-view [vodWithSeries]="selectedItem()" />
<app-stalker-series-view
[vodWithSeries]="selectedItem()"
[providerOnly]="providerOnly()"
/>
} @else if (isSeriesDetail()) {
<app-stalker-series-view />
<app-stalker-series-view [providerOnly]="providerOnly()" />
} @else if (vodDetailsItem()) {
<app-vod-details
[item]="vodDetailsItem()!"
@@ -10,6 +13,7 @@
[playbackPosition]="selectedVodPlaybackPosition()"
[inlinePlayback]="inlinePlayback()"
[externalPlayback]="externalPlayback.activeSession()"
[providerOnly]="providerOnly()"
(playClicked)="onVodPlay($event)"
(resumeClicked)="onVodResume($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
@@ -24,5 +28,5 @@
/>
}
} @else if (contentType() === 'series') {
<app-stalker-series-view />
<app-stalker-series-view [providerOnly]="providerOnly()" />
}
@@ -0,0 +1,188 @@
import { Component, input, output, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import {
DownloadsService,
PlaybackPositionRuntimeBridgeService,
PlaylistsService,
} from '@iptvnator/services';
import { VodDetailsComponent } from '@iptvnator/ui/playback';
import { EMPTY, of } from 'rxjs';
import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service';
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
import { StalkerCatalogDetailComponent } from './stalker-catalog-detail.component';
@Component({ selector: 'app-vod-details', template: '' })
class StubVodDetailsComponent {
readonly item = input.required<unknown>();
readonly providerOnly = input(false);
readonly isFavorite = input(false);
readonly playbackPosition = input<number | null>(null);
readonly inlinePlayback = input<unknown>(null);
readonly externalPlayback = input<unknown>(null);
readonly playClicked = output<unknown>();
readonly resumeClicked = output<unknown>();
readonly favoriteToggled = output<unknown>();
readonly downloadRequested = output<unknown>();
readonly backClicked = output<void>();
readonly inlineTimeUpdated = output<unknown>();
readonly inlinePlaybackClosed = output<void>();
readonly streamUrlCopied = output<void>();
readonly inlineExternalFallbackRequested = output<unknown>();
}
@Component({ selector: 'app-stalker-series-view', template: '' })
class StubStalkerSeriesViewComponent {
readonly vodWithSeries = input<unknown>(null);
readonly providerOnly = input(false);
}
describe('StalkerCatalogDetailComponent provider presentation', () => {
let fixture: ComponentFixture<StalkerCatalogDetailComponent>;
const contentType = signal<'vod' | 'series'>('vod');
const selectedItem = signal<unknown>({
id: '42',
cmd: '/media/42',
info: { name: 'Portal movie' },
});
beforeEach(async () => {
window.history.replaceState(
{
detailPresentation: 'provider-only',
openStalkerItem: { id: '42' },
},
'',
window.location.href
);
contentType.set('vod');
selectedItem.set({
id: '42',
cmd: '/media/42',
info: { name: 'Portal movie' },
});
await TestBed.configureTestingModule({
imports: [StalkerCatalogDetailComponent],
providers: [
{
provide: StalkerCatalogFacadeService,
useValue: {
contentType,
selectedItem,
playlist: signal({ id: 'stalker-1' }),
clearSelectedItem: jest.fn(),
},
},
{
provide: PORTAL_PLAYBACK_POSITIONS,
useValue: {
getPlaybackPosition: jest.fn().mockResolvedValue(null),
savePlaybackPosition: jest.fn(),
},
},
{
provide: PORTAL_PLAYER,
useValue: { isEmbeddedPlayer: jest.fn() },
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: { activeSession: signal(null) },
},
{
provide: PlaybackPositionRuntimeBridgeService,
useValue: { onPlaybackPositionUpdate: jest.fn() },
},
{
provide: PlaylistsService,
useValue: { getPortalFavorites: jest.fn(() => of([])) },
},
{ provide: DownloadsService, useValue: {} },
{ provide: Router, useValue: { navigateByUrl: jest.fn() } },
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
onLangChange: EMPTY,
onTranslationChange: EMPTY,
onDefaultLangChange: EMPTY,
},
},
],
})
.overrideComponent(StalkerCatalogDetailComponent, {
remove: {
imports: [StalkerSeriesViewComponent, VodDetailsComponent],
},
add: {
imports: [
StubStalkerSeriesViewComponent,
StubVodDetailsComponent,
],
},
})
.compileComponents();
fixture = TestBed.createComponent(StalkerCatalogDetailComponent);
});
afterEach(() => {
window.history.replaceState({}, '', window.location.href);
fixture.destroy();
});
it('passes provider-only mode to the matching regular VOD', async () => {
await fixture.whenStable();
const child = fixture.debugElement.query(
By.directive(StubVodDetailsComponent)
).componentInstance as StubVodDetailsComponent;
expect(fixture.componentInstance.providerOnly()).toBe(true);
expect(child.providerOnly()).toBe(true);
});
it('does not apply stale provider-only state to a different selected item', async () => {
selectedItem.set({
id: '99',
cmd: '/media/99',
info: { name: 'Another movie' },
});
await fixture.whenStable();
const child = fixture.debugElement.query(
By.directive(StubVodDetailsComponent)
).componentInstance as StubVodDetailsComponent;
expect(fixture.componentInstance.providerOnly()).toBe(false);
expect(child.providerOnly()).toBe(false);
});
it.each([
['embedded VOD series', 'vod', { id: '42', series: [1] }],
['Ministra VOD series', 'vod', { id: '42', is_series: '1' }],
['regular series', 'series', { id: '42' }],
] as const)(
'passes provider-only mode to %s',
async (_label, type, item) => {
contentType.set(type);
selectedItem.set({
...item,
cmd: '/media/42',
info: { name: 'Portal series' },
});
await fixture.whenStable();
const child = fixture.debugElement.query(
By.directive(StubStalkerSeriesViewComponent)
).componentInstance as StubStalkerSeriesViewComponent;
expect(child.providerOnly()).toBe(true);
}
);
});
@@ -16,12 +16,15 @@ import {
PORTAL_PLAYER,
createLogger,
createInlinePlaybackPositionWriter,
getOpenStalkerItemState,
isProviderOnlyDetailState,
} from '@iptvnator/portal/shared/util';
import {
createPortalFavoritesResource,
createRefreshTrigger,
isStalkerSeriesFlag,
isSelectedStalkerVodFavorite,
normalizeStalkerEntityId,
StalkerSelectedVodItem,
toggleStalkerVodFavorite,
} from '@iptvnator/portal/stalker/data-access';
@@ -83,6 +86,17 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
null
);
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
readonly providerOnly = computed(() => {
const selected = this.selectedItem();
const opened = getOpenStalkerItemState(window.history.state);
return (
isProviderOnlyDetailState(window.history.state) &&
!!selected &&
!!opened &&
normalizeStalkerEntityId(selected.id) ===
normalizeStalkerEntityId(opened.id ?? opened.stream_id)
);
});
private readonly selectedVodPosition = signal<PlaybackPositionData | null>(
null
);
@@ -3,11 +3,15 @@
<!-- VOD item with embedded series (vclub) -->
<app-stalker-series-view
[vodWithSeries]="seriesItem()"
[providerOnly]="providerOnly()"
(backClicked)="onBackClicked()"
/>
} @else if (isSeries()) {
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
<app-stalker-series-view (backClicked)="onBackClicked()" />
<app-stalker-series-view
[providerOnly]="providerOnly()"
(backClicked)="onBackClicked()"
/>
} @else if (vodDetailsItem(); as item) {
<!-- Regular VOD item -->
<app-vod-details
@@ -16,6 +20,7 @@
[playbackPosition]="playbackPosition()"
[inlinePlayback]="inlinePlayback()"
[externalPlayback]="externalPlayback()"
[providerOnly]="providerOnly()"
(playClicked)="onPlayClicked($event)"
(resumeClicked)="onResumeClicked($event)"
(favoriteToggled)="onFavoriteToggled($event)"
@@ -29,5 +34,8 @@
/>
}
} @else if (categoryId() === 'series') {
<app-stalker-series-view (backClicked)="onBackClicked()" />
<app-stalker-series-view
[providerOnly]="providerOnly()"
(backClicked)="onBackClicked()"
/>
}
@@ -0,0 +1,93 @@
import { Component, input, output } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { createStalkerVodItem } from '@iptvnator/shared/interfaces';
import { VodDetailsComponent } from '@iptvnator/ui/playback';
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
import { StalkerInlineDetailComponent } from './stalker-inline-detail.component';
@Component({ selector: 'app-vod-details', template: '' })
class StubVodDetailsComponent {
readonly item = input.required<unknown>();
readonly providerOnly = input(false);
readonly isFavorite = input(false);
readonly playbackPosition = input<number | null>(null);
readonly inlinePlayback = input<unknown>(null);
readonly externalPlayback = input<unknown>(null);
readonly playClicked = output<unknown>();
readonly resumeClicked = output<unknown>();
readonly favoriteToggled = output<unknown>();
readonly backClicked = output<void>();
readonly inlineTimeUpdated = output<unknown>();
readonly inlinePlaybackClosed = output<void>();
readonly streamUrlCopied = output<void>();
readonly inlineExternalFallbackRequested = output<unknown>();
}
@Component({ selector: 'app-stalker-series-view', template: '' })
class StubStalkerSeriesViewComponent {
readonly vodWithSeries = input<unknown>(null);
readonly providerOnly = input(false);
readonly backClicked = output<void>();
}
const VOD_ITEM = createStalkerVodItem(
{ id: '42', cmd: '/media/42', info: { name: 'Movie' } },
'stalker-1'
);
describe('StalkerInlineDetailComponent provider presentation', () => {
let fixture: ComponentFixture<StalkerInlineDetailComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [StalkerInlineDetailComponent],
})
.overrideComponent(StalkerInlineDetailComponent, {
remove: {
imports: [StalkerSeriesViewComponent, VodDetailsComponent],
},
add: {
imports: [
StubStalkerSeriesViewComponent,
StubVodDetailsComponent,
],
},
})
.compileComponents();
fixture = TestBed.createComponent(StalkerInlineDetailComponent);
fixture.componentRef.setInput('providerOnly', true);
});
afterEach(() => fixture.destroy());
it('passes provider-only mode to regular VOD details', async () => {
fixture.componentRef.setInput('categoryId', 'vod');
fixture.componentRef.setInput('vodDetailsItem', VOD_ITEM);
await fixture.whenStable();
const child = fixture.debugElement.query(
By.directive(StubVodDetailsComponent)
).componentInstance as StubVodDetailsComponent;
expect(child.providerOnly()).toBe(true);
});
it.each([
['embedded VOD series', 'vod', { id: '42', series: [1] }, false],
['Ministra VOD series', 'vod', { id: '42' }, true],
['regular series', 'series', { id: '42' }, false],
] as const)(
'passes provider-only mode to %s details',
async (_label, categoryId, seriesItem, isSeries) => {
fixture.componentRef.setInput('categoryId', categoryId);
fixture.componentRef.setInput('seriesItem', seriesItem);
fixture.componentRef.setInput('isSeries', isSeries);
await fixture.whenStable();
const child = fixture.debugElement.query(
By.directive(StubStalkerSeriesViewComponent)
).componentInstance as StubStalkerSeriesViewComponent;
expect(child.providerOnly()).toBe(true);
}
);
});
@@ -40,6 +40,7 @@ export class StalkerInlineDetailComponent {
readonly playbackPosition = input<number | null>(null);
readonly inlinePlayback = input<ResolvedPortalPlayback | null>(null);
readonly externalPlayback = input<ExternalPlayerSession | null>(null);
readonly providerOnly = input(false);
readonly backClicked = output<void>();
readonly playClicked = output<VodDetailsItem>();
@@ -68,10 +69,7 @@ export class StalkerInlineDetailComponent {
this.playClicked.emit(item);
}
onResumeClicked(event: {
item: VodDetailsItem;
positionSeconds: number;
}) {
onResumeClicked(event: { item: VodDetailsItem; positionSeconds: number }) {
this.resumeClicked.emit(event);
}
@@ -42,7 +42,9 @@
<div class="details__cast-strip">
@for (
member of serial.info.tmdb_cast;
track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index
track member.tmdbPersonId
? 'p' + member.tmdbPersonId
: 'i' + $index
) {
<span
class="details__cast-chip"
@@ -85,10 +87,17 @@
}
@if (serial.info.director || serial.info.tmdb_directors?.length) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.DIRECTOR' | translate }}</span>
<span class="label">{{
'XTREAM.DIRECTOR' | translate
}}</span>
@if (serial.info.tmdb_directors?.length) {
<div class="details__cast-strip">
@for (member of serial.info.tmdb_directors; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) {
@for (
member of serial.info.tmdb_directors;
track member.tmdbPersonId
? 'p' + member.tmdbPersonId
: 'i' + $index
) {
<span
class="details__cast-chip"
[class.details__cast-chip--clickable]="
@@ -141,8 +150,7 @@
<span class="play-btn__copy">
<span class="play-btn__label">
{{
action.labelKey
| translate: action.labelParams
action.labelKey | translate: action.labelParams
}}
</span>
@if (action.episodeLabel) {
@@ -193,6 +201,7 @@
inlinePlayback()?.contentInfo?.contentXtreamId ?? null
"
[seasonDescriptions]="seasonDescriptions()"
[downloadsEnabled]="!providerOnly()"
[isLoading]="
isVodSeriesSeasonsLoading() ||
isCurrentSeasonLoading(seasonContainer?.selectedSeason())
@@ -209,10 +218,7 @@
<div detail-extras>
@if (similarInPortals().length) {
<section
class="details__similar"
data-test-id="tmdb-similar"
>
<section class="details__similar" data-test-id="tmdb-similar">
<h3>{{ 'XTREAM.SIMILAR' | translate }}</h3>
<div class="details__similar-strip">
@for (
@@ -223,9 +229,7 @@
type="button"
class="details__similar-card"
[title]="
item.title +
' — ' +
item.match.playlistName
item.title + ' — ' + item.match.playlistName
"
(click)="openSimilarInPortals(item)"
>
@@ -263,7 +267,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>
@@ -38,6 +38,7 @@ class StubSeasonContainerComponent {
readonly playingEpisodeId = input<number | null>(null);
readonly seasonDescriptions = input<unknown>(null);
readonly isLoading = input(false);
readonly downloadsEnabled = input(true);
readonly seasonSelected = output<string>();
readonly episodeClicked = output<unknown>();
readonly episodeDownloadRequested = output<unknown>();
@@ -122,7 +123,7 @@ describe('StalkerSeriesViewComponent', () => {
episodeId?: number,
startTime?: number
) => ({
streamUrl: 'http://stalker.example/episode.mpg',
streamUrl: 'http://stalker.example/episode.mpg',
title: title ?? 'Regular Series',
thumbnail: thumbnail ?? 'poster.jpg',
startTime,
@@ -305,6 +306,28 @@ describe('StalkerSeriesViewComponent', () => {
);
});
it('keeps provider episodes playable while hiding download presentation', async () => {
fixture.componentRef.setInput('providerOnly', true);
fixture.detectChanges();
await fixture.whenStable();
const seasonContainer = fixture.debugElement.query(
By.directive(StubSeasonContainerComponent)
).componentInstance as StubSeasonContainerComponent;
expect(seasonContainer.downloadsEnabled()).toBe(false);
expect(
Object.values(
seasonContainer.seasons() as Record<string, unknown[]>
).flat()
).toHaveLength(2);
expect(
fixture.nativeElement.querySelector(
'[data-testid="series-quick-start"]'
)
).not.toBeNull();
});
it('interpolates the episode number for a recently started VOD is_series episode', async () => {
selectedContentType.set('vod');
selectedItem.set({
@@ -614,10 +637,12 @@ describe('StalkerSeriesViewComponent', () => {
]);
fetchVodSeriesEpisodes.mockClear();
const seasonOneEpisodes = fixture.componentInstance.mappedSeasons()['1'];
const seasonOneEpisodes =
fixture.componentInstance.mappedSeasons()['1'];
const firstEpisode = seasonOneEpisodes[0];
const secondEpisode = seasonOneEpisodes[1];
const seasonTwoEpisode = fixture.componentInstance.mappedSeasons()['2'][0];
const seasonTwoEpisode =
fixture.componentInstance.mappedSeasons()['2'][0];
fixture.componentInstance.onEpisodeClicked(firstEpisode);
await fixture.whenStable();
@@ -181,6 +181,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
* When provided, uses this instead of fetching seasons from API
*/
readonly vodWithSeries = input<StalkerVodSource | null>(null);
readonly providerOnly = input(false);
readonly selectedItem = this.stalkerStore.selectedItem;
@@ -197,8 +198,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
*/
readonly similarInPortals = signal<CrossPortalSimilarItem[]>([]);
private readonly loadSimilarInPortals = effect(() => {
const recommendations =
this.displayItem()?.info?.tmdb_recommendations;
const recommendations = this.displayItem()?.info?.tmdb_recommendations;
untracked(() => {
this.similarInPortals.set([]);
if (
@@ -293,21 +293,22 @@ export class StalkerSeriesViewComponent implements OnDestroy {
const seasons = this.mappedSeasons();
const episodes = seasonKey ? seasons[seasonKey] : undefined;
if (tmdbId && seasonKey && !seasonsLoading && episodes?.length) {
untracked(() =>
void this.tmdbSeasons.fetchSeason(
tmdbId,
seasonKey,
episodes,
{
// The season marker can live in either title
// field (generic name + descriptive o_name)
rawTitle: pickSeasonMarkedTitle(
item?.info?.name,
item?.info?.o_name
),
seasonCount: Object.keys(seasons).length,
}
)
untracked(
() =>
void this.tmdbSeasons.fetchSeason(
tmdbId,
seasonKey,
episodes,
{
// The season marker can live in either title
// field (generic name + descriptive o_name)
rawTitle: pickSeasonMarkedTitle(
item?.info?.name,
item?.info?.o_name
),
seasonCount: Object.keys(seasons).length,
}
)
);
}
});
@@ -596,19 +597,20 @@ export class StalkerSeriesViewComponent implements OnDestroy {
// before the answer arrives. A failure releases the claim below.
this.prefetchedSpilloverSeasonIds.add(prefetchKey);
untracked(() =>
void this.loadEpisodesForSeason(nextSeason).then((answered) => {
if (answered) {
this.spilloverPrefetchFailure = null;
return;
}
untracked(
() =>
void this.loadEpisodesForSeason(nextSeason).then((answered) => {
if (answered) {
this.spilloverPrefetchFailure = null;
return;
}
this.spilloverPrefetchFailure = {
key: prefetchKey,
episodeId,
};
this.prefetchedSpilloverSeasonIds.delete(prefetchKey);
})
this.spilloverPrefetchFailure = {
key: prefetchKey,
episodeId,
};
this.prefetchedSpilloverSeasonIds.delete(prefetchKey);
})
);
});
@@ -955,10 +957,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
}
this.closeInlinePlayer();
void this.portalPlayer.openResolvedPlayback(
resolvedPlayback,
true
);
void this.portalPlayer.openResolvedPlayback(resolvedPlayback, true);
} catch (error) {
this.logger.error('Failed to start inline series playback', error);
const errorMessage =
@@ -42,7 +42,12 @@
<span class="label">{{ 'XTREAM.CAST' | translate }}</span>
@if (info.tmdb_cast?.length) {
<div class="details__cast-strip">
@for (member of info.tmdb_cast; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) {
@for (
member of info.tmdb_cast;
track member.tmdbPersonId
? 'p' + member.tmdbPersonId
: 'i' + $index
) {
<span
class="details__cast-chip"
[class.details__cast-chip--clickable]="
@@ -84,10 +89,17 @@
}
@if (info.director || info.tmdb_directors?.length) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.DIRECTOR' | translate }}</span>
<span class="label">{{
'XTREAM.DIRECTOR' | translate
}}</span>
@if (info.tmdb_directors?.length) {
<div class="details__cast-strip">
@for (member of info.tmdb_directors; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) {
@for (
member of info.tmdb_directors;
track member.tmdbPersonId
? 'p' + member.tmdbPersonId
: 'i' + $index
) {
<span
class="details__cast-chip"
[class.details__cast-chip--clickable]="
@@ -139,8 +151,7 @@
<span class="play-btn__copy">
<span class="play-btn__label">
{{
action.labelKey
| translate: action.labelParams
action.labelKey | translate: action.labelParams
}}
</span>
<span class="play-btn__meta">
@@ -195,6 +206,7 @@
[seriesTitle]="info.name"
[playbackPositions]="episodePlaybackPositions()"
[xtreamDownloadContext]="xtreamDownloadContext()"
[downloadsEnabled]="!providerOnly()"
[openingEpisodeId]="openingEpisodeId()"
[activeEpisodeId]="activeEpisodeId()"
[playingEpisodeId]="
@@ -239,15 +251,16 @@
}
@for (
item of similarInPortals();
track 'x' + item.match.playlistId + '-' + item.match.xtreamId
track 'x' +
item.match.playlistId +
'-' +
item.match.xtreamId
) {
<button
type="button"
class="details__similar-card"
[title]="
item.title +
' — ' +
item.match.playlistName
item.title + ' — ' + item.match.playlistName
"
(click)="openSimilarInPortals(item)"
>
@@ -21,7 +21,7 @@ import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
import { of } from 'rxjs';
import { EMPTY, of } from 'rxjs';
import { SerialDetailsComponent } from './serial-details.component';
import { SerialDetailsPlaybackService } from './serial-details-playback.service';
import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.token';
@@ -38,6 +38,7 @@ class StubSeasonContainerComponent {
readonly seriesTitle = input<string | undefined>(undefined);
readonly playbackPositions = input<unknown>(null);
readonly xtreamDownloadContext = input<unknown>(null);
readonly downloadsEnabled = input(true);
readonly openingEpisodeId = input<number | null>(null);
readonly activeEpisodeId = input<number | null>(null);
readonly playingEpisodeId = input<number | null>(null);
@@ -105,6 +106,7 @@ describe('SerialDetailsComponent', () => {
>;
beforeEach(async () => {
window.history.replaceState({}, '', window.location.href);
selectedItem.set({
series_id: 103,
info: {
@@ -256,9 +258,9 @@ describe('SerialDetailsComponent', () => {
instant: (key: string) => key,
get: (key: string) => of(key),
stream: (key: string) => of(key),
onLangChange: of(null),
onTranslationChange: of(null),
onDefaultLangChange: of(null),
onLangChange: EMPTY,
onTranslationChange: EMPTY,
onDefaultLangChange: EMPTY,
},
},
{
@@ -296,6 +298,7 @@ describe('SerialDetailsComponent', () => {
});
afterEach(() => {
window.history.replaceState({}, '', window.location.href);
fixture?.destroy();
});
@@ -348,6 +351,29 @@ describe('SerialDetailsComponent', () => {
},
],
});
expect(seasonContainer?.downloadsEnabled()).toBe(true);
});
it('keeps every provider episode but disables download presentation in provider-only mode', async () => {
window.history.replaceState(
{ detailPresentation: 'provider-only' },
'',
window.location.href
);
fixture.detectChanges();
await fixture.whenStable();
const seasonContainer = fixture.debugElement.query(
By.directive(StubSeasonContainerComponent)
)?.componentInstance as StubSeasonContainerComponent;
expect(fixture.componentInstance.providerOnly()).toBe(true);
expect(seasonContainer.downloadsEnabled()).toBe(false);
expect(
Object.values(
seasonContainer.seasons() as Record<string, unknown[]>
).flat()
).toHaveLength(3);
});
it('invalidates an in-flight detail request on teardown', () => {
@@ -500,9 +526,7 @@ describe('SerialDetailsComponent', () => {
fixture.nativeElement.querySelector(
'[data-testid="series-quick-start"]'
);
expect(quickStartButton?.textContent).toContain(
'XTREAM.PLAY_EPISODE'
);
expect(quickStartButton?.textContent).toContain('XTREAM.PLAY_EPISODE');
expect(quickStartButton?.textContent).toContain(
'S02E01 \u00b7 Season 2 Episode 1'
);
@@ -621,9 +645,7 @@ describe('SerialDetailsComponent', () => {
expect(quickStartButton()?.textContent).toContain(
'XTREAM.RESUME_EPISODE'
);
expect(quickStartButton()?.textContent).toContain(
'S01E01 · Episode 1'
);
expect(quickStartButton()?.textContent).toContain('S01E01 · Episode 1');
});
it('persists the launched episode after an external fallback succeeds', async () => {
@@ -38,6 +38,7 @@ import {
XtreamSerieEpisode,
XtreamSerieInfo,
} from '@iptvnator/shared/interfaces';
import { isProviderOnlyDetailState } from '@iptvnator/portal/shared/util';
import {
CrossPortalSimilarItem,
CrossPortalSimilarService,
@@ -112,6 +113,10 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
private readonly routeParams = toSignal(this.route.params, {
initialValue: this.route.snapshot.params,
});
readonly providerOnly = computed(() => {
this.routeParams();
return isProviderOnlyDetailState(window.history.state);
});
// Episode playback state, re-exposed for the template.
readonly inlinePlayback = this.playback.inlinePlayback;
@@ -172,8 +177,7 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
});
private readonly loadCrossPortalSimilar = effect(() => {
const recommendations =
this.selectedItem()?.info?.tmdb_recommendations;
const recommendations = this.selectedItem()?.info?.tmdb_recommendations;
const playlistId = this.xtreamStore.currentPlaylist()?.id;
untracked(() => {
this.crossPortalItems.set([]);
@@ -25,7 +25,7 @@ import {
XtreamVodStream,
} from '@iptvnator/shared/interfaces';
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
import { NEVER, of } from 'rxjs';
import { BehaviorSubject, NEVER, of } from 'rxjs';
import { VodDetailsRouteComponent } from './vod-details-route.component';
@Component({
@@ -77,6 +77,10 @@ describe('VodDetailsRouteComponent fallback actions', () => {
const vodCategories = signal<Partial<XtreamCategory>[]>([]);
const vodStreamsPlaylistId = signal<string | null>('playlist-1');
const vodCategoriesPlaylistId = signal<string | null>('playlist-1');
const routeParams = new BehaviorSubject({
vodId: '650020',
categoryId: '235',
});
const constructVodStreamUrl = jest
.fn()
.mockReturnValue('http://example.com/movie/650020.mp4');
@@ -118,6 +122,8 @@ describe('VodDetailsRouteComponent fallback actions', () => {
},
}) as XtreamVodDetails;
beforeEach(async () => {
window.history.replaceState({}, '', window.location.href);
routeParams.next({ vodId: '650020', categoryId: '235' });
selectedItem.set(null);
downloads.set([]);
downloadsAvailable.set(false);
@@ -186,10 +192,7 @@ describe('VodDetailsRouteComponent fallback actions', () => {
{
provide: ActivatedRoute,
useValue: {
params: of({
vodId: '650020',
categoryId: '235',
}),
params: routeParams,
snapshot: {
params: {
vodId: '650020',
@@ -299,6 +302,11 @@ describe('VodDetailsRouteComponent fallback actions', () => {
.compileComponents();
fixture = TestBed.createComponent(VodDetailsRouteComponent);
});
afterEach(() => {
window.history.replaceState({}, '', window.location.href);
fixture?.destroy();
});
it('ignores catalog data owned by another playlist', () => {
currentPlaylist.set({
id: 'playlist-b',
@@ -512,6 +520,61 @@ describe('VodDetailsRouteComponent fallback actions', () => {
).toContain('DOWNLOADS.PLAY_LOCAL');
});
it('keeps provider playback and hides offline actions in provider-only mode', async () => {
window.history.replaceState(
{ detailPresentation: 'provider-only' },
'',
window.location.href
);
selectedItem.set(richItem());
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.providerOnly()).toBe(true);
expect(host.textContent).not.toContain('DOWNLOADS.OFFLINE');
expect(host.textContent).not.toContain('DOWNLOADS.PLAY_LOCAL');
expect(host.textContent).not.toContain(
'PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE'
);
expect(primary?.textContent).toContain('XTREAM.PLAY');
primary?.click();
await fixture.whenStable();
expect(constructVodStreamUrl).toHaveBeenCalled();
expect(playDownload).not.toHaveBeenCalled();
});
it('does not leak provider-only mode when the route host is reused', () => {
window.history.replaceState(
{ detailPresentation: 'provider-only' },
'',
window.location.href
);
selectedItem.set(richItem());
fixture.detectChanges();
expect(fixture.componentInstance.providerOnly()).toBe(true);
window.history.replaceState({}, '', window.location.href);
selectedItem.set({
...richItem(),
movie_data: {
...richItem().movie_data,
stream_id: 650021,
},
} as XtreamVodDetails);
routeParams.next({ vodId: '650021', categoryId: '236' });
fixture.detectChanges();
expect(fixture.componentInstance.providerOnly()).toBe(false);
});
it('plays a rich downloaded movie locally without a usable provider source', async () => {
selectedItem.set(unplayableRichItem());
downloadsAvailable.set(true);
@@ -89,7 +89,7 @@
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
</button>
}
@if (isElectron()) {
@if (isElectron() && !providerOnly()) {
@if (isDownloaded()) {
@if (externalPrimaryButtonState() === 'idle') {
<button
@@ -23,7 +23,10 @@ import {
VodSourcesChipComponent,
} from '@iptvnator/ui/components';
import { SafePipe } from '@iptvnator/pipes';
import { createLogger } from '@iptvnator/portal/shared/util';
import {
createLogger,
isProviderOnlyDetailState,
} from '@iptvnator/portal/shared/util';
import {
resolveXtreamVodPlaybackSource,
XtreamStore,
@@ -155,6 +158,10 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
readonly isFavorite = this.xtreamStore.isFavorite;
readonly selectedVodId = computed(() => Number(this.routeParams().vodId));
readonly providerOnly = computed(() => {
this.routeParams();
return isProviderOnlyDetailState(window.history.state);
});
readonly selectedItem = computed(() => {
const item =
this.xtreamStore.selectedItem() as unknown as XtreamVodDetails | null;
@@ -292,7 +299,10 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
readonly hasPlaybackPosition = this.msUi.hasPlaybackPosition;
readonly isDownloaded = this.downloads.isDownloaded;
private readonly downloadedFromLibrary = this.downloads.isDownloaded;
readonly isDownloaded = computed(
() => !this.providerOnly() && this.downloadedFromLibrary()
);
readonly isDownloading = this.downloads.isDownloading;
readonly isPausedDownload = this.downloads.isPausedDownload;
readonly isOfflinePrimary = computed(
@@ -155,7 +155,7 @@
}}</mat-icon>
</button>
<div class="episode-card__actions">
@if (isElectron()) {
@if (isElectron() && downloadsEnabled()) {
@if (isEpisodeDownloaded(episode)) {
<button
mat-icon-button
@@ -173,9 +173,7 @@
>
<mat-icon>play_circle</mat-icon>
</button>
} @else if (
isEpisodePaused(episode)
) {
} @else if (isEpisodePaused(episode)) {
<button
mat-icon-button
class="download-btn download-btn--paused"
@@ -260,10 +258,7 @@
class="episode-card__info-btn"
data-testid="episode-info-button"
(click)="
openEpisodeInfo(
$event,
episode
)
openEpisodeInfo($event, episode)
"
[matTooltip]="
'PORTALS.EPISODE_INFO'
@@ -417,7 +412,7 @@
}}</mat-icon>
}
</button>
@if (isElectron()) {
@if (isElectron() && downloadsEnabled()) {
@if (isEpisodeDownloaded(episode)) {
<button
mat-icon-button
@@ -58,6 +58,7 @@ describe('SeasonContainerComponent', () => {
};
beforeEach(async () => {
downloadsServiceStub.isAvailable.set(false);
await TestBed.configureTestingModule({
imports: [
NoopAnimationsModule,
@@ -132,6 +133,21 @@ describe('SeasonContainerComponent', () => {
).toBe(2);
});
it('keeps episodes playable while download presentation is disabled', () => {
downloadsServiceStub.isAvailable.set(true);
fixture.componentRef.setInput('downloadsEnabled', false);
setRequiredInputs({ '1': [createEpisode()] });
fixture.detectChanges();
expect(
fixture.nativeElement.querySelectorAll('.episode-card').length
).toBe(1);
expect(
fixture.nativeElement.querySelectorAll('.download-btn').length
).toBe(0);
});
it('renders the season-level placeholder when the selected season has no episodes', () => {
setRequiredInputs({ '1': [] });
fixture.detectChanges();
@@ -149,9 +165,8 @@ describe('SeasonContainerComponent', () => {
});
fixture.detectChanges();
const pills = fixture.nativeElement.querySelectorAll(
'.season-tabs__pill'
);
const pills =
fixture.nativeElement.querySelectorAll('.season-tabs__pill');
(pills[1] as HTMLButtonElement).click();
fixture.detectChanges();
@@ -220,9 +235,8 @@ describe('SeasonContainerComponent', () => {
});
fixture.detectChanges();
const pills = fixture.nativeElement.querySelectorAll(
'.season-tabs__pill'
);
const pills =
fixture.nativeElement.querySelectorAll('.season-tabs__pill');
(pills[1] as HTMLButtonElement).click();
fixture.detectChanges();
expect(component.selectedSeason()).toBe('2');
@@ -272,9 +286,8 @@ describe('SeasonContainerComponent', () => {
)
).toBeNull();
const pills = fixture.nativeElement.querySelectorAll(
'.season-tabs__pill'
);
const pills =
fixture.nativeElement.querySelectorAll('.season-tabs__pill');
(pills[1] as HTMLButtonElement).click();
fixture.detectChanges();
@@ -94,6 +94,7 @@ export class SeasonContainerComponent implements OnInit {
);
readonly xtreamDownloadContext =
input<SeasonContainerXtreamDownloadContext | null>(null);
readonly downloadsEnabled = input(true);
readonly openingEpisodeId = input<number | null>(null);
/** Episode currently playing in an EXTERNAL player session. */
readonly activeEpisodeId = input<number | null>(null);
@@ -187,9 +188,7 @@ export class SeasonContainerComponent implements OnInit {
return;
}
this.lastAutoSelectKey = key;
this.selectedSeason.set(
untracked(() => this.resolveAutoSeason())
);
this.selectedSeason.set(untracked(() => this.resolveAutoSeason()));
});
// Fire the lazy-load/enrichment hooks for auto-selected seasons too —
@@ -364,6 +363,7 @@ export class SeasonContainerComponent implements OnInit {
async downloadEpisode(event: Event, episode: XtreamSerieEpisode) {
event.stopPropagation();
if (!this.downloadsEnabled()) return;
if (isStalkerEpisode(episode)) {
this.episodeDownloadRequested.emit(episode);
@@ -389,7 +389,7 @@ export class SeasonContainerComponent implements OnInit {
}
isEpisodeDownloaded(episode: XtreamSerieEpisode): boolean {
if (!this.playlistId()) {
if (!this.downloadsEnabled() || !this.playlistId()) {
return false;
}
@@ -402,7 +402,7 @@ export class SeasonContainerComponent implements OnInit {
}
isEpisodeDownloading(episode: XtreamSerieEpisode): boolean {
if (!this.playlistId()) {
if (!this.downloadsEnabled() || !this.playlistId()) {
return false;
}
@@ -415,7 +415,7 @@ export class SeasonContainerComponent implements OnInit {
}
isEpisodePaused(episode: XtreamSerieEpisode): boolean {
if (!this.playlistId()) {
if (!this.downloadsEnabled() || !this.playlistId()) {
return false;
}
@@ -230,7 +230,7 @@
}
<!-- Download buttons (Electron only) -->
@if (isElectron()) {
@if (isElectron() && !providerOnly()) {
@if (isDownloaded()) {
@if (externalPrimaryButtonState() === 'idle') {
<button class="download-btn" (click)="onProviderAction()">
@@ -110,14 +110,17 @@ describe('VodDetailsComponent offline playback', () => {
const render = async ({
playbackPosition = null,
externalPlayback = null,
providerOnly = false,
}: {
playbackPosition?: number | null;
externalPlayback?: ExternalPlayerSession | null;
providerOnly?: boolean;
} = {}) => {
fixture = TestBed.createComponent(VodDetailsComponent);
fixture.componentRef.setInput('item', STALKER_VOD);
fixture.componentRef.setInput('playbackPosition', playbackPosition);
fixture.componentRef.setInput('externalPlayback', externalPlayback);
fixture.componentRef.setInput('providerOnly', providerOnly);
playClicked = jest.fn();
resumeClicked = jest.fn();
fixture.componentInstance.playClicked.subscribe(playClicked);
@@ -379,6 +382,25 @@ describe('VodDetailsComponent offline playback', () => {
expect(playDownload).not.toHaveBeenCalled();
});
it('keeps provider Play and hides offline actions in provider-only mode', async () => {
completeDownload();
await render({ providerOnly: true });
expect(fixture.nativeElement.textContent).not.toContain('Offline');
expect(fixture.nativeElement.textContent).not.toContain('Play Local');
expect(fixture.nativeElement.textContent).not.toContain(
'Play from this source'
);
expect(fixture.nativeElement.textContent).not.toContain('Download');
expect(buttonText(primaryButton())).toContain('Play');
primaryButton().click();
await fixture.whenStable();
expect(playClicked).toHaveBeenCalledWith(STALKER_VOD);
expect(playDownload).not.toHaveBeenCalled();
});
it('keeps provider Resume and Restart actions when undownloaded', async () => {
await render({ playbackPosition: 83 });
@@ -92,6 +92,9 @@ export class VodDetailsComponent {
/** Active external playback session for launch state */
readonly externalPlayback = input<ExternalPlayerSession | null>(null);
/** Provider detail handoff hides local/download presentation only. */
readonly providerOnly = input(false);
// ============ Outputs ============
/** Emitted when play button is clicked */
@@ -212,9 +215,15 @@ export class VodDetailsComponent {
this.downloadsService,
this.item
);
readonly isDownloaded = this.downloadState.isDownloaded;
readonly isDownloading = this.downloadState.isDownloading;
readonly isPausedDownload = this.downloadState.isPausedDownload;
readonly isDownloaded = computed(
() => !this.providerOnly() && this.downloadState.isDownloaded()
);
readonly isDownloading = computed(
() => !this.providerOnly() && this.downloadState.isDownloading()
);
readonly isPausedDownload = computed(
() => !this.providerOnly() && this.downloadState.isPausedDownload()
);
private readonly externalButton = createExternalPlaybackButtonState({
session: this.externalPlayback,