mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
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:
1 parent
956e7c36b7
commit
5b2fbe296b
11 files changed
+133
-64
No files matched your search
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+6
-1
@@ -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,
|
||||
|
||||
+3
@@ -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),
|
||||
});
|
||||
|
||||
+31
@@ -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,
|
||||
|
||||
+22
-24
@@ -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,
|
||||
>({
|
||||
|
||||
Reference in new issue
Block a user