mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
feat(downloads): hand off to provider-only details
This commit is contained in:
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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
+7
-3
@@ -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()" />
|
||||
}
|
||||
+188
@@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
+14
@@ -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
|
||||
);
|
||||
|
||||
+10
-2
@@ -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()"
|
||||
/>
|
||||
}
|
||||
+93
@@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
+2
-4
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+25
-13
@@ -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>
|
||||
|
||||
+28
-3
@@ -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();
|
||||
|
||||
+32
-33
@@ -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)"
|
||||
>
|
||||
|
||||
+32
-10
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user