From 72baf0bf1c4c07d3e3ec9aaaa678b3884473e554 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 31 Jul 2026 16:23:32 +0200 Subject: [PATCH] feat(downloads): hand off to provider-only details --- ...ownload-library-navigation.service.spec.ts | 63 +++--- .../download-library-navigation.service.ts | 12 +- libs/portal/shared/util/src/index.ts | 1 + .../navigation/provider-detail-mode.spec.ts | 20 ++ .../lib/navigation/provider-detail-mode.ts | 10 + .../stalker-catalog-detail.component.html | 10 +- .../stalker-catalog-detail.component.spec.ts | 188 ++++++++++++++++++ .../stalker-catalog-detail.component.ts | 14 ++ .../stalker-inline-detail.component.html | 12 +- .../stalker-inline-detail.component.spec.ts | 93 +++++++++ .../stalker-inline-detail.component.ts | 6 +- .../stalker-series-view.component.html | 38 ++-- .../stalker-series-view.component.spec.ts | 31 ++- .../stalker-series-view.component.ts | 65 +++--- .../serial-details.component.html | 31 ++- .../serial-details.component.spec.ts | 42 +++- .../serial-details.component.ts | 8 +- .../vod-details-route.actions.spec.ts | 73 ++++++- .../vod-details-route.component.html | 2 +- .../vod-details-route.component.ts | 14 +- .../season-container.component.html | 13 +- .../season-container.component.spec.ts | 31 ++- .../season-container.component.ts | 12 +- .../vod-details/vod-details.component.html | 2 +- .../vod-details/vod-details.component.spec.ts | 22 ++ .../lib/vod-details/vod-details.component.ts | 15 +- 26 files changed, 675 insertions(+), 153 deletions(-) create mode 100644 libs/portal/shared/util/src/lib/navigation/provider-detail-mode.spec.ts create mode 100644 libs/portal/shared/util/src/lib/navigation/provider-detail-mode.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.spec.ts diff --git a/libs/portal/downloads/feature/src/lib/download-library-navigation.service.spec.ts b/libs/portal/downloads/feature/src/lib/download-library-navigation.service.spec.ts index 597fb17e3..25082e564 100644 --- a/libs/portal/downloads/feature/src/lib/download-library-navigation.service.spec.ts +++ b/libs/portal/downloads/feature/src/lib/download-library-navigation.service.spec.ts @@ -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', diff --git a/libs/portal/downloads/feature/src/lib/download-library-navigation.service.ts b/libs/portal/downloads/feature/src/lib/download-library-navigation.service.ts index 715dce1c1..8b281c0bc 100644 --- a/libs/portal/downloads/feature/src/lib/download-library-navigation.service.ts +++ b/libs/portal/downloads/feature/src/lib/download-library-navigation.service.ts @@ -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 { 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; } diff --git a/libs/portal/shared/util/src/index.ts b/libs/portal/shared/util/src/index.ts index a1e6be3cd..b7490f4cf 100644 --- a/libs/portal/shared/util/src/index.ts +++ b/libs/portal/shared/util/src/index.ts @@ -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'; diff --git a/libs/portal/shared/util/src/lib/navigation/provider-detail-mode.spec.ts b/libs/portal/shared/util/src/lib/navigation/provider-detail-mode.spec.ts new file mode 100644 index 000000000..14628ee62 --- /dev/null +++ b/libs/portal/shared/util/src/lib/navigation/provider-detail-mode.spec.ts @@ -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); + }); +}); diff --git a/libs/portal/shared/util/src/lib/navigation/provider-detail-mode.ts b/libs/portal/shared/util/src/lib/navigation/provider-detail-mode.ts new file mode 100644 index 000000000..d9c1af96e --- /dev/null +++ b/libs/portal/shared/util/src/lib/navigation/provider-detail-mode.ts @@ -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 + ); +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.html b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.html index 24ca1636b..74eeca1f1 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.html @@ -1,8 +1,11 @@ @if (contentType() === 'vod') { @if ((selectedItem()?.series?.length ?? 0) > 0) { - + } @else if (isSeriesDetail()) { - + } @else if (vodDetailsItem()) { } } @else if (contentType() === 'series') { - + } diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts new file mode 100644 index 000000000..a606ceef9 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts @@ -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(); + readonly providerOnly = input(false); + readonly isFavorite = input(false); + readonly playbackPosition = input(null); + readonly inlinePlayback = input(null); + readonly externalPlayback = input(null); + readonly playClicked = output(); + readonly resumeClicked = output(); + readonly favoriteToggled = output(); + readonly downloadRequested = output(); + readonly backClicked = output(); + readonly inlineTimeUpdated = output(); + readonly inlinePlaybackClosed = output(); + readonly streamUrlCopied = output(); + readonly inlineExternalFallbackRequested = output(); +} + +@Component({ selector: 'app-stalker-series-view', template: '' }) +class StubStalkerSeriesViewComponent { + readonly vodWithSeries = input(null); + readonly providerOnly = input(false); +} + +describe('StalkerCatalogDetailComponent provider presentation', () => { + let fixture: ComponentFixture; + const contentType = signal<'vod' | 'series'>('vod'); + const selectedItem = signal({ + 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); + } + ); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts index a04627bf4..bdc5f2968 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts @@ -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(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( null ); diff --git a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html index 16cdd2eff..b97e8e102 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html @@ -3,11 +3,15 @@ } @else if (isSeries()) { - + } @else if (vodDetailsItem(); as item) { } } @else if (categoryId() === 'series') { - + } diff --git a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.spec.ts new file mode 100644 index 000000000..57594f7ad --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.spec.ts @@ -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(); + readonly providerOnly = input(false); + readonly isFavorite = input(false); + readonly playbackPosition = input(null); + readonly inlinePlayback = input(null); + readonly externalPlayback = input(null); + readonly playClicked = output(); + readonly resumeClicked = output(); + readonly favoriteToggled = output(); + readonly backClicked = output(); + readonly inlineTimeUpdated = output(); + readonly inlinePlaybackClosed = output(); + readonly streamUrlCopied = output(); + readonly inlineExternalFallbackRequested = output(); +} + +@Component({ selector: 'app-stalker-series-view', template: '' }) +class StubStalkerSeriesViewComponent { + readonly vodWithSeries = input(null); + readonly providerOnly = input(false); + readonly backClicked = output(); +} + +const VOD_ITEM = createStalkerVodItem( + { id: '42', cmd: '/media/42', info: { name: 'Movie' } }, + 'stalker-1' +); + +describe('StalkerInlineDetailComponent provider presentation', () => { + let fixture: ComponentFixture; + + 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); + } + ); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts index 38f4fdea1..37cfaf1ef 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts @@ -40,6 +40,7 @@ export class StalkerInlineDetailComponent { readonly playbackPosition = input(null); readonly inlinePlayback = input(null); readonly externalPlayback = input(null); + readonly providerOnly = input(false); readonly backClicked = output(); readonly playClicked = output(); @@ -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); } diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html index 07ae93308..0256b9952 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html @@ -42,7 +42,9 @@
@for ( member of serial.info.tmdb_cast; - track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index + track member.tmdbPersonId + ? 'p' + member.tmdbPersonId + : 'i' + $index ) { - {{ 'XTREAM.DIRECTOR' | translate }} + {{ + 'XTREAM.DIRECTOR' | translate + }} @if (serial.info.tmdb_directors?.length) {
- @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 + ) { {{ - action.labelKey - | translate: action.labelParams + action.labelKey | translate: action.labelParams }} @if (action.episodeLabel) { @@ -193,6 +201,7 @@ inlinePlayback()?.contentInfo?.contentXtreamId ?? null " [seasonDescriptions]="seasonDescriptions()" + [downloadsEnabled]="!providerOnly()" [isLoading]=" isVodSeriesSeasonsLoading() || isCurrentSeasonLoading(seasonContainer?.selectedSeason()) @@ -209,10 +218,7 @@
@if (similarInPortals().length) { -
+

{{ 'XTREAM.SIMILAR' | translate }}

@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 @@ diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts index dbccdd37a..80a9ffaa1 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts @@ -38,6 +38,7 @@ class StubSeasonContainerComponent { readonly playingEpisodeId = input(null); readonly seasonDescriptions = input(null); readonly isLoading = input(false); + readonly downloadsEnabled = input(true); readonly seasonSelected = output(); readonly episodeClicked = output(); readonly episodeDownloadRequested = output(); @@ -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 + ).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(); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index f21b184a6..950e0b651 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -181,6 +181,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { * When provided, uses this instead of fetching seasons from API */ readonly vodWithSeries = input(null); + readonly providerOnly = input(false); readonly selectedItem = this.stalkerStore.selectedItem; @@ -197,8 +198,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { */ readonly similarInPortals = signal([]); 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 = diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html index 3069de5d8..2be57f9a8 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html @@ -42,7 +42,12 @@ {{ 'XTREAM.CAST' | translate }} @if (info.tmdb_cast?.length) {
- @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 + ) { - {{ 'XTREAM.DIRECTOR' | translate }} + {{ + 'XTREAM.DIRECTOR' | translate + }} @if (info.tmdb_directors?.length) {
- @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 + ) { {{ - action.labelKey - | translate: action.labelParams + action.labelKey | translate: action.labelParams }} @@ -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 ) { } - @if (isElectron()) { + @if (isElectron() && !providerOnly()) { @if (isDownloaded()) { @if (externalPrimaryButtonState() === 'idle') {
- @if (isElectron()) { + @if (isElectron() && downloadsEnabled()) { @if (isEpisodeDownloaded(episode)) { - } @else if ( - isEpisodePaused(episode) - ) { + } @else if (isEpisodePaused(episode)) {