fix(portals): a completed series offers no episode rows in its menu

The completed quick start is a disabled label naming the last episode,
yet both series menus still offered to launch it externally or copy its
URL. The rows and their handlers now require a startable quick start.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 17:54:36 +02:00
1 parent 2166bbce2c
commit 392ce5cb43
5 files changed
+206 -4

No files matched your search

@@ -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> = {}
): 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<StalkerQuickStartButton | null>(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();
});
});
@@ -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'
@@ -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<void> {
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);
}
@@ -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> = {}
): 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<SeriesQuickStartAction | null>(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();
});
});
@@ -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<void> {
const bindings = this.bindings();
const episode = bindings?.quickStart()?.episode;
const episode = this.startableQuickStart()?.episode;
if (!bindings || !episode) {
return;
}