diff --git a/apps/web-e2e/src/e2e-helpers.ts b/apps/web-e2e/src/e2e-helpers.ts index 3dd3fdfa1..fce681780 100644 --- a/apps/web-e2e/src/e2e-helpers.ts +++ b/apps/web-e2e/src/e2e-helpers.ts @@ -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 { + // 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 }); +} diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 171f9bf3d..dd269f17c 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -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 { - 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, diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index a13489149..0b7029188 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -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 { - // 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, 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 d5c5b9190..6fae579be 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 @@ -18,6 +18,8 @@ interface StalkerSeriesMenuBindings { readonly hasProgress: Signal; /** An episode plays or launches: its next tick would undo a reset. */ readonly playbackActive: Signal; + /** A start has not published its session yet: bulk watched actions would include it. */ + readonly startPending: Signal; readonly resetProgress: () => Promise; readonly openExternal: (player: ExternalPlayerName) => Promise; } @@ -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, 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 5186b2f13..aea1aa101 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 @@ -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), }); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.spec.ts index 1d7669347..64c2a99da 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.spec.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.spec.ts @@ -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((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')); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.ts index 0ec1baf78..a55739a27 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.ts @@ -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 { + 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 { + // 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 = 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 c97cea292..a8df46844 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 @@ -37,6 +37,8 @@ interface SerialDetailsMenuBindings { readonly episodePositions: Signal>; /** An episode plays or launches: its next tick would undo a reset. */ readonly playbackActive: Signal; + /** A forced launch has not published its session yet: bulk watched actions would include it. */ + readonly startPending: Signal; readonly resetProgress: () => Promise; /** 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, diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts index e401cf5a6..480b144d7 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts @@ -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 | 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 | 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, - }); - } } diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index eb3f11a85..193452cdc 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -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), diff --git a/libs/ui/playback/src/lib/portal-inline-player/series-playback-navigation.ts b/libs/ui/playback/src/lib/portal-inline-player/series-playback-navigation.ts index 790b3adf7..7e15f2b4f 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/series-playback-navigation.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/series-playback-navigation.ts @@ -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['episodesBySeason'] +): SeriesPlaybackEpisodeState | 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, >({