fix(portals): nineteenth review round and the web E2E line limit

- A queued Xtream episode launch checks the page owner before it closes
  the running player, so a viewer who left the series keeps playback.
- A Play or episode choice made while a forced launch settles starts
  once it settled instead of being dropped; only the latest choice runs.
- Both series menus disable the bulk watched actions while a start has
  not published its session yet.
- The series-menu E2E helpers are shared, which brings the Stalker web
  spec back under the line limit; the inline episode state derivation
  moves into ui/playback.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 14:21:46 +02:00
1 parent 956e7c36b7
commit 5b2fbe296b
11 files changed
+133 -64

No files matched your search

+21
View File
@@ -286,3 +286,24 @@ export async function expectSeriesSurfacesInBothThemes(
.click();
}
}
/** Opens the series "…" menu (if closed) and returns the row with that test id. */
export async function seriesMenuRow(page: Page, rowTestId: string) {
const row = page.locator(`[data-test-id="${rowTestId}"]`);
if (!(await row.isVisible().catch(() => false))) {
await page.locator('[data-testid="series-more-menu"]').click();
}
await expect(row).toBeVisible({ timeout: 15_000 });
return row;
}
/** Closes an open series menu without choosing a row. */
export async function closeSeriesMenu(page: Page): Promise<void> {
// The panel, not the backdrop: a backdrop locator can match a second
// overlay and then fail the strict check, leaving the menu open.
const panel = page.locator('[data-test-id="vod-more-menu"]');
if (await panel.isVisible().catch(() => false)) {
await page.keyboard.press('Escape');
}
await expect(panel).toBeHidden({ timeout: 10_000 });
}
+6 -18
View File
@@ -1,5 +1,10 @@
import { type APIRequestContext, type Page } from '@playwright/test';
import { expectSeriesSurfacesInBothThemes, setInputValue } from './e2e-helpers';
import {
closeSeriesMenu,
expectSeriesSurfacesInBothThemes,
seriesMenuRow,
setInputValue,
} from './e2e-helpers';
import {
verifyStalkerCategorySearch,
verifyStalkerPlaybackCategoryReturn,
@@ -1136,23 +1141,6 @@ test('@stalker season watched toggle — embedded series marks and clears every
await closeSeriesMenu(page);
});
/** Opens the series "…" menu (if closed) and returns the row with that test id. */
async function seriesMenuRow(page: Page, rowTestId: string) {
const row = page.locator(`[data-test-id="${rowTestId}"]`);
if (!(await row.isVisible().catch(() => false))) {
await page.locator('[data-testid="series-more-menu"]').click();
}
await expect(row).toBeVisible({ timeout: 15_000 });
return row;
}
async function closeSeriesMenu(page: Page): Promise<void> {
if (await page.locator('.cdk-overlay-backdrop').isVisible().catch(() => false)) {
await page.keyboard.press('Escape');
}
}
test('@stalker series watched toggle — embedded series marks and clears from the header menu', async ({
page,
request,
+2 -20
View File
@@ -1,8 +1,10 @@
import type { APIRequestContext, Page } from '@playwright/test';
import { expect, test } from './fixtures';
import {
closeSeriesMenu,
pressTab,
rasterizedBorderContrast,
seriesMenuRow,
setInputValue,
surfaceContrast,
waitForScrollIdle,
@@ -802,26 +804,6 @@ test('@xtream season cover — shows the provider season cover and follows the s
// unwatch-all once the whole series is watched, and survives a reload.
// ---------------------------------------------------------------------------
/** Opens the series "…" menu (if closed) and returns the row with that test id. */
async function seriesMenuRow(page: Page, rowTestId: string) {
const row = page.locator(`[data-test-id="${rowTestId}"]`);
if (!(await row.isVisible().catch(() => false))) {
await page.locator('[data-testid="series-more-menu"]').click();
}
await expect(row).toBeVisible({ timeout: 15_000 });
return row;
}
async function closeSeriesMenu(page: Page): Promise<void> {
// The panel, not the backdrop: a backdrop locator can match a second
// overlay and then fail the strict check, leaving the menu open.
const panel = page.locator('[data-test-id="vod-more-menu"]');
if (await panel.isVisible().catch(() => false)) {
await page.keyboard.press('Escape');
}
await expect(panel).toBeHidden({ timeout: 10_000 });
}
test('@xtream series watched toggle — marks every season from the header menu, survives reload, and clears again', async ({
page,
request,
@@ -18,6 +18,8 @@ interface StalkerSeriesMenuBindings {
readonly hasProgress: Signal<boolean>;
/** An episode plays or launches: its next tick would undo a reset. */
readonly playbackActive: Signal<boolean>;
/** A start has not published its session yet: bulk watched actions would include it. */
readonly startPending: Signal<boolean>;
readonly resetProgress: () => Promise<void>;
readonly openExternal: (player: ExternalPlayerName) => Promise<void>;
}
@@ -50,6 +52,7 @@ export class StalkerSeriesMenuService {
seasonFullyWatched: watch?.selectedSeasonFullyWatched() ?? false,
seasonEligibleCount: watch?.seasonWatchEligibleCount() ?? 0,
seasonActionDisabled:
(this.bindings()?.startPending() ?? false) ||
(container?.seasonWatchBatchRunning() ?? false) ||
(!(watch?.selectedSeasonFullyWatched() ?? false) &&
(watch?.seasonWatchEligibleCount() ?? 0) === 0),
@@ -57,7 +60,9 @@ export class StalkerSeriesMenuService {
seriesFullyWatched: watch?.seriesFullyWatched() ?? false,
seriesEligibleCount: watch?.seriesWatchEligibleCount() ?? 0,
seriesCountKnown: watch?.seriesCountKnown() ?? true,
seriesActionDisabled: watch?.seriesActionDisabled() ?? true,
seriesActionDisabled:
(this.bindings()?.startPending() ?? false) ||
(watch?.seriesActionDisabled() ?? true),
hasProgress: this.bindings()?.hasProgress() ?? false,
playbackActive: this.bindings()?.playbackActive() ?? false,
sourcesCount: 0,
@@ -417,6 +417,9 @@ export class StalkerSeriesViewComponent implements OnDestroy {
this.openingEpisodeId() !== null ||
this.activeEpisodeId() !== null
),
startPending: computed(() =>
this.pendingStartSeriesIds().includes(this.currentSeriesKey())
),
resetProgress: () => this.resetProgress(),
openExternal: (player) => this.openQuickStartExternally(player),
});
@@ -164,6 +164,37 @@ describe('openEpisodeExternally', () => {
expect(isEpisodeLaunchPending('xtream-1:103')).toBe(false);
});
it('leaves the player alone when the page moved on before a queued launch ran', async () => {
const running = session();
const t = host(running);
let settleLaunch: () => void = () => undefined;
t.openExternalPlayback.mockImplementationOnce(
() => new Promise<void>((resolve) => (settleLaunch = resolve))
);
const first = openEpisodeExternally(t.host, PLAYBACK, 'mpv');
const second = openEpisodeExternally(
t.host,
{
...PLAYBACK,
contentInfo: {
...PLAYBACK.contentInfo!,
contentXtreamId: 1003,
},
},
'mpv'
);
await flush();
t.closeSession.mockClear();
// The viewer left the series before the queued launch's turn.
t.launchOwner.mockReturnValue('xtream-1:999');
settleLaunch();
await first;
await second;
expect(t.closeSession).not.toHaveBeenCalled();
expect(t.openExternalPlayback).toHaveBeenCalledTimes(1);
});
it('keeps the running player when closing it fails', async () => {
const t = host(session());
t.closeSession.mockRejectedValue(new Error('still busy'));
@@ -46,6 +46,12 @@ export function isEpisodeLaunchPending(owner: string | null): boolean {
return !!owner && (pendingOwners().get(owner) ?? 0) > 0;
}
/** Resolves once every queued launch of the owner settled (at once when none is pending). */
export function whenEpisodeLaunchesSettle(owner: string | null): Promise<void> {
const tail = owner ? launchChains.get(owner) : undefined;
return tail ? tail.then(() => undefined) : Promise.resolve();
}
/**
* The "…" menu's MPV/VLC launch of an episode. An episode of this series
* still running externally is closed first: with instance reuse off a
@@ -94,6 +100,11 @@ async function launchEpisode(
playback: ResolvedPortalPlayback,
player: ExternalPlayerName
): Promise<ExternalPlayerSession | void> {
// Queued behind another launch: the page may have moved on meanwhile,
// and the running player then belongs to a series the viewer left.
if (host.launchOwner() !== owner) {
return;
}
const session = host.externalPlayback.activeSession();
const info = session?.contentInfo;
const ownSession =
@@ -37,6 +37,8 @@ interface SerialDetailsMenuBindings {
readonly episodePositions: Signal<ReadonlyMap<number, unknown>>;
/** An episode plays or launches: its next tick would undo a reset. */
readonly playbackActive: Signal<boolean>;
/** A forced launch has not published its session yet: bulk watched actions would include it. */
readonly startPending: Signal<boolean>;
readonly resetProgress: () => Promise<void>;
/** The regular episode start forced to MPV/VLC, so history and the launch position are recorded. */
readonly openEpisodeExternally: (
@@ -145,6 +147,7 @@ export class SerialDetailsMenuService {
seasonFullyWatched: watch?.selectedSeasonFullyWatched() ?? false,
seasonEligibleCount: watch?.seasonWatchEligibleCount() ?? 0,
seasonActionDisabled:
(this.bindings()?.startPending() ?? false) ||
(container?.seasonWatchBatchRunning() ?? false) ||
(!(watch?.selectedSeasonFullyWatched() ?? false) &&
(watch?.seasonWatchEligibleCount() ?? 0) === 0),
@@ -152,7 +155,9 @@ export class SerialDetailsMenuService {
seriesFullyWatched: watch?.seriesFullyWatched() ?? false,
seriesEligibleCount: watch?.seriesWatchEligibleCount() ?? 0,
seriesCountKnown: watch?.seriesCountKnown() ?? true,
seriesActionDisabled: watch?.seriesActionDisabled() ?? true,
seriesActionDisabled:
(this.bindings()?.startPending() ?? false) ||
(watch?.seriesActionDisabled() ?? true),
hasProgress: this.hasProgress(),
playbackActive: this.bindings()?.playbackActive() ?? false,
sourcesCount: 0,
@@ -34,6 +34,7 @@ import {
import {
getSeriesEpisodeMetadata,
getSeriesPlaybackNavigation,
inlineSeriesEpisodeState,
type PlaybackFallbackRequest,
resolveSeriesPlaybackEpisodeState,
type SeriesPlaybackEpisodeState,
@@ -43,6 +44,7 @@ import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.toke
import {
isEpisodeLaunchPending,
openEpisodeExternally,
whenEpisodeLaunchesSettle,
} from './serial-details-external-launch';
import { SerialDetailsPlaybackPositionState } from './serial-details-playback-position-state';
import {
@@ -112,7 +114,10 @@ export class SerialDetailsPlaybackService {
});
readonly inlineEpisodeState =
computed<SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null>(() =>
this.getInlineEpisodeState()
inlineSeriesEpisodeState(
this.inlinePlayback(),
this.selectedItem()?.episodes
)
);
readonly inlineEpisodeMetadata = computed(() =>
getSeriesEpisodeMetadata(this.inlineEpisodeState())
@@ -221,9 +226,13 @@ export class SerialDetailsPlaybackService {
episode: XtreamSerieEpisode,
player?: ExternalPlayerName
): void {
// A forced launch still settling owns the next start; a different
// episode launched the same way is queued behind it instead.
// A forced launch still settling owns the next start: the latest
// choice made meanwhile starts once it settled, never beside it.
if (!player && this.forcedLaunchPending()) {
this.queuedEpisode = episode;
void whenEpisodeLaunchesSettle(this.launchOwner()).then(() =>
this.playQueuedEpisode()
);
return;
}
const playlist = this.xtreamStore.currentPlaylist();
@@ -416,6 +425,16 @@ export class SerialDetailsPlaybackService {
readonly forcedLaunchPending = computed(() =>
isEpisodeLaunchPending(this.launchOwner())
);
/** The choice made while a forced launch settled; only the latest one starts. */
private queuedEpisode: XtreamSerieEpisode | null = null;
private playQueuedEpisode(): void {
const episode = this.queuedEpisode;
this.queuedEpisode = null;
if (episode && !this.forcedLaunchPending()) {
this.playEpisode(episode);
}
}
/** `playlist:series` of the page, null once it is gone or shows another series. */
launchOwner(): string | null {
@@ -444,25 +463,4 @@ export class SerialDetailsPlaybackService {
this.savePosition
);
}
private getInlineEpisodeState(): SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null {
const playback = this.inlinePlayback();
const episodesBySeason = this.selectedItem()?.episodes;
const currentEpisodeId = playback?.contentInfo?.contentXtreamId;
if (
!episodesBySeason ||
playback?.contentInfo?.contentType !== 'episode' ||
currentEpisodeId === undefined
) {
return null;
}
return resolveSeriesPlaybackEpisodeState({
episodesBySeason,
currentEpisodeId,
fallbackSeasonNumber: playback.contentInfo.seasonNumber,
fallbackEpisodeNumber: playback.contentInfo.episodeNumber,
});
}
}
@@ -228,6 +228,7 @@ export class SerialDetailsComponent implements OnDestroy {
this.playback.openingEpisodeId() !== null ||
this.playback.activeEpisodeId() !== null
),
startPending: this.playback.forcedLaunchPending,
resetProgress: () => this.resetProgress(),
openEpisodeExternally: (episode, player) =>
this.playback.playEpisode(episode, player),
@@ -1,3 +1,4 @@
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
export interface SeriesEpisodeMetadata {
label: string;
seasonNumber: number;
@@ -43,6 +44,29 @@ export function formatSeriesEpisodeLabel(
return `S${padSeriesNumber(seasonNumber)}E${padSeriesNumber(episodeNumber)}`;
}
/** The episode state of an inline playback, when it plays an episode of these seasons. */
export function inlineSeriesEpisodeState<
TEpisode extends SeriesPlaybackEpisodeLike,
>(
playback: ResolvedPortalPlayback | null,
episodesBySeason: ResolveSeriesPlaybackEpisodeStateOptions<TEpisode>['episodesBySeason']
): SeriesPlaybackEpisodeState<TEpisode> | null {
const info = playback?.contentInfo;
if (
!episodesBySeason ||
info?.contentType !== 'episode' ||
info.contentXtreamId === undefined
) {
return null;
}
return resolveSeriesPlaybackEpisodeState({
episodesBySeason,
currentEpisodeId: info.contentXtreamId,
fallbackSeasonNumber: info.seasonNumber,
fallbackEpisodeNumber: info.episodeNumber,
});
}
export function resolveSeriesPlaybackEpisodeState<
TEpisode extends SeriesPlaybackEpisodeLike,
>({