diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-menu.service.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-menu.service.spec.ts new file mode 100644 index 000000000..ad5b8a0d2 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-menu.service.spec.ts @@ -0,0 +1,74 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; +import { VideoPlayer } from '@iptvnator/shared/interfaces'; +import { SERIES_MENU_ACTION } from '@iptvnator/ui/components'; +import { StalkerSeriesMenuService } from './stalker-series-menu.service'; +import type { StalkerQuickStartButton } from './stalker-series-quick-start'; + +function button( + overrides: Partial = {} +): StalkerQuickStartButton { + return { + labelKey: 'XTREAM.PLAY_NEXT', + episodeLabel: 'S01E02', + icon: 'play_arrow', + disabled: false, + action: { + kind: 'play-next', + episode: { id: '1002', season: 1, episode_num: 2 }, + } as never, + lazySeason: null, + ...overrides, + }; +} + +describe('StalkerSeriesMenuService', () => { + const quickStart = signal(null); + let service: StalkerSeriesMenuService; + + beforeEach(() => { + quickStart.set(button()); + TestBed.configureTestingModule({ + providers: [ + StalkerSeriesMenuService, + { + provide: RuntimeCapabilitiesService, + useValue: { supportsManagedExternalPlayers: true }, + }, + { + provide: SettingsStore, + useValue: { player: signal(VideoPlayer.VLC) }, + }, + ], + }); + service = TestBed.inject(StalkerSeriesMenuService); + service.bind({ + quickStart, + seasonContainer: signal(undefined), + hasProgress: signal(false), + playbackActive: signal(false), + startPending: signal(false), + resetProgress: jest.fn(), + openExternal: jest.fn(), + }); + }); + + function externalRow() { + return service + .sections() + .flatMap((section) => section.items) + .find((item) => item.id === SERIES_MENU_ACTION.ExternalPlayer); + } + + it('offers the next episode to the configured external player', () => { + expect(externalRow()?.hint).toBe('VLC'); + }); + + it('offers no external launch once the series is completed', () => { + // The completed button still carries the last episode as its + // action, disabled: a label, not something to launch. + quickStart.set(button({ disabled: true })); + expect(externalRow()).toBeUndefined(); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-menu.service.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-menu.service.ts index 4490b8dc3..5d03495be 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-menu.service.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-menu.service.ts @@ -68,8 +68,11 @@ export class StalkerSeriesMenuService { startPending: this.bindings()?.startPending() ?? false, watchBatchRunning: container?.seasonWatchBatchRunning() ?? false, sourcesCount: 0, + // A completed series keeps a disabled quick start naming the + // last episode: nothing to launch there. externalPlayerHint: quickStart?.action && + !quickStart.disabled && this.runtime.supportsManagedExternalPlayers ? this.externalPlayer() === 'vlc' ? 'VLC' 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 28043d8ef..24d8c9210 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 @@ -1109,7 +1109,10 @@ export class StalkerSeriesViewComponent implements OnDestroy { /** "Open in external player": the next episode, straight to MPV/VLC. */ async openQuickStartExternally(player: ExternalPlayerName): Promise { - const episode = this.quickStartAction()?.action?.episode; + const quickStart = this.quickStartAction(); + const episode = quickStart?.disabled + ? undefined + : quickStart?.action?.episode; if (episode) { this.onEpisodeClicked(episode, undefined, player); } diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.spec.ts new file mode 100644 index 000000000..6009e08b9 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.spec.ts @@ -0,0 +1,112 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import type { SeriesQuickStartAction } from '@iptvnator/portal/shared/util'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; +import { VideoPlayer } from '@iptvnator/shared/interfaces'; +import { SERIES_MENU_ACTION } from '@iptvnator/ui/components'; +import { SerialDetailsMenuService } from './serial-details-menu.service'; + +function quickStart( + overrides: Partial = {} +): SeriesQuickStartAction { + return { + kind: 'play-next', + labelKey: 'XTREAM.PLAY_NEXT', + episodeLabel: 'S01E02', + icon: 'play_arrow', + episode: { id: '1002', season: 1, episode_num: 2, title: 'Two' }, + position: null, + disabled: false, + ...overrides, + } as SeriesQuickStartAction; +} + +describe('SerialDetailsMenuService', () => { + const quickStartSignal = signal(null); + const openEpisodeExternally = jest.fn().mockResolvedValue(undefined); + let service: SerialDetailsMenuService; + + beforeEach(() => { + quickStartSignal.set(quickStart()); + openEpisodeExternally.mockClear(); + TestBed.configureTestingModule({ + providers: [ + SerialDetailsMenuService, + { + provide: XtreamStore, + useValue: { + currentPlaylist: signal({ id: 'xtream-1' }), + loadRecentItems: jest.fn(), + serialCategories: signal([]), + recentItems: signal([]), + constructEpisodeStreamUrl: () => + 'http://xtream.example/episode.mp4', + }, + }, + { + provide: RuntimeCapabilitiesService, + useValue: { supportsManagedExternalPlayers: true }, + }, + { + provide: SettingsStore, + useValue: { player: signal(VideoPlayer.MPV) }, + }, + { provide: Router, useValue: { navigate: jest.fn() } }, + { provide: MatSnackBar, useValue: { open: jest.fn() } }, + { + provide: TranslateService, + useValue: { instant: (key: string) => key }, + }, + ], + }); + service = TestBed.inject(SerialDetailsMenuService); + service.bind({ + selectedItem: signal({ + series_id: 103, + info: {}, + episodes: {}, + } as never), + quickStart: quickStartSignal, + seasonContainer: signal(undefined), + categoryId: signal(''), + episodePositions: signal(new Map()), + playbackActive: signal(false), + startPending: signal(false), + resetProgress: jest.fn(), + pageToken: () => 'xtream-1:103#1.0', + openEpisodeExternally, + }); + }); + + function rowIds(): string[] { + return service + .sections() + .flatMap((section) => section.items) + .map((item) => item.id); + } + + it('offers the next episode to an external player and to the clipboard', () => { + expect(rowIds()).toEqual( + expect.arrayContaining([ + SERIES_MENU_ACTION.ExternalPlayer, + SERIES_MENU_ACTION.CopyUrl, + ]) + ); + }); + + it('offers no episode rows once the series is completed', async () => { + // The completed quick start still names the last episode, but it + // is a label, not something to launch or copy. + quickStartSignal.set(quickStart({ kind: 'completed', disabled: true })); + + expect(rowIds()).not.toContain(SERIES_MENU_ACTION.ExternalPlayer); + expect(rowIds()).not.toContain(SERIES_MENU_ACTION.CopyUrl); + + await service.run(SERIES_MENU_ACTION.ExternalPlayer); + expect(openEpisodeExternally).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.ts index f15789f34..4a99595f1 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.ts @@ -88,6 +88,16 @@ export class SerialDetailsMenuService { Number(this.bindings()?.selectedItem()?.series_id ?? 0) ); + /** + * The episode the menu's rows act on. A completed series has a quick + * start too, a disabled one naming the last episode: nothing to launch + * or copy there. + */ + private readonly startableQuickStart = computed(() => { + const quickStart = this.bindings()?.quickStart() ?? null; + return quickStart && !quickStart.disabled ? quickStart : null; + }); + /** Any episode with a saved position: "Reset progress" applies. */ private readonly hasProgress = computed( () => (this.bindings()?.episodePositions().size ?? 0) > 0 @@ -136,7 +146,7 @@ export class SerialDetailsMenuService { const container = this.bindings()?.seasonContainer(); const watch = container?.watchPresenter; const download = container?.downloadPresenter; - const quickStart = this.bindings()?.quickStart() ?? null; + const quickStart = this.startableQuickStart(); const category = this.category(); const episodeCode = quickStart ? formatSeriesEpisodeCode( @@ -213,7 +223,7 @@ export class SerialDetailsMenuService { /** The next episode, resolved the way the inline player would play it. */ private buildEpisodePlayback(): ResolvedPortalPlayback | null { - const quickStart = this.bindings()?.quickStart(); + const quickStart = this.startableQuickStart(); const item = this.bindings()?.selectedItem(); const playlist = this.xtreamStore.currentPlaylist(); if (!quickStart || !item || !playlist) { @@ -242,7 +252,7 @@ export class SerialDetailsMenuService { private async openExternal(): Promise { const bindings = this.bindings(); - const episode = bindings?.quickStart()?.episode; + const episode = this.startableQuickStart()?.episode; if (!bindings || !episode) { return; }