mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
2166bbce2c
commit
392ce5cb43
5 files changed
+206
-4
No files matched your search
+74
@@ -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'
|
||||
|
||||
+4
-1
@@ -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);
|
||||
}
|
||||
|
||||
+112
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user