From d087cf4d1c03e8e5d85ec4c369f2188a5a11a8dd Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 3 Oct 2026 14:57:51 +0200 Subject: [PATCH] fix(portals): twenty-fourth review round on the cinematic details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A forced Xtream episode launch returns its promise to the menu, which reports a rejection; the bookkeeping consumes a settled copy. - Launch bookkeeping is guarded by a page token (series + visit), checked again after the asynchronous save, so neither a return to the same series nor a navigation during the save contaminates another page. - A Stalker launch re-anchors to the host's request id after the host's own inline teardown: only a later start supersedes it, and that still silences the old launch's failure. - The shared movie page disables Play and Start Over while a start is pending, so nothing starts beside an in-flight launch. - Episode sublines honour duration_secs before the formatted duration. - The external session → episode ids derivation moves into the launch helper. Co-Authored-By: Claude Fable 5.1 --- .../lib/stalker-vod-detail-actions.spec.ts | 22 +++++ .../src/lib/stalker-vod-detail-actions.ts | 17 ++-- .../lib/stalker-vod-playback-controller.ts | 5 +- .../serial-details-external-launch.spec.ts | 35 +++++++ .../serial-details-external-launch.ts | 38 ++++++++ .../serial-details-menu.service.ts | 16 +++- .../serial-details-playback-position-state.ts | 7 +- .../serial-details-playback.service.ts | 92 +++++++++---------- .../serial-details.component.spec.ts | 4 +- .../season-container/episode-progress.util.ts | 10 ++ .../season-container/episode-utils.spec.ts | 15 +++ .../season-container.component.ts | 9 +- .../vod-details-presentation.spec.ts | 7 ++ .../vod-details/vod-details-presentation.ts | 1 + .../vod-details/vod-details.component.html | 2 +- 15 files changed, 209 insertions(+), 71 deletions(-) diff --git a/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.spec.ts index 0fce2509f..3396bb3a3 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.spec.ts @@ -43,6 +43,7 @@ describe('createStalkerVodDetailActions openExternal', () => { const beginPendingStart = jest.fn().mockReturnValue({ settle: settlePendingStart, isCurrent: () => launchCurrent, + rebase: () => (launchCurrent = true), }); const actions = createStalkerVodDetailActions({ resolvePlayback: resolvePlayback as never, @@ -227,6 +228,27 @@ describe('createStalkerVodDetailActions openExternal', () => { expect(t.logError).not.toHaveBeenCalled(); }); + it('stays silent when a newer start superseded a launch already running', async () => { + const t = setup(() => 42); + let rejectLaunch: (error: unknown) => void = () => undefined; + t.openExternalPlayback.mockImplementation( + () => new Promise((_, reject) => (rejectLaunch = reject)) + ); + const launch = t.actions.openExternal({ + item: MOVIE, + player: 'mpv', + positionSeconds: null, + }); + t.resolveLink(); + await new Promise((resolve) => setTimeout(resolve)); + // Play pressed while the IPC was pending, then the old launch fails. + t.supersede(); + rejectLaunch(new Error('no player')); + await launch; + + expect(t.open).not.toHaveBeenCalled(); + }); + it('reports a failed launch although its own teardown retired the request', async () => { const t = setup(() => 42); // The host closes the inline player before the launch, bumping its diff --git a/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.ts b/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.ts index d3d801976..acb551741 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.ts @@ -19,6 +19,8 @@ import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback'; export interface PendingExternalLaunch { settle(): void; isCurrent(): boolean; + /** Re-anchors to the host's current request id after the host's own teardown bumped it. */ + rebase(): void; } /** Builds a `PendingExternalLaunch` on a host that tracks its own request ids. */ @@ -30,7 +32,7 @@ export function beginTrackedExternalLaunch(host: { playbackOwnerKey(): string; playbackRequestId: number; }): PendingExternalLaunch { - const requestId = ++host.playbackRequestId; + let requestId = ++host.playbackRequestId; const ownerKey = host.playbackOwnerKey(); const startId = host.pendingStart.begin(ownerKey); return { @@ -38,6 +40,9 @@ export function beginTrackedExternalLaunch(host: { isCurrent: () => requestId === host.playbackRequestId && host.playbackOwnerKey() === ownerKey, + rebase: () => { + requestId = host.playbackRequestId; + }, }; } @@ -141,10 +146,6 @@ export function createStalkerVodDetailActions( deps.playlistId() === playlistId; const pending = deps.beginPendingStart?.(); const superseded = () => pending?.isCurrent() === false; - // Set once this launch is the one running: the host's inline - // teardown before it retires the request id as well, which is - // not a newer start. - let launched = false; try { const playback = await deps.resolvePlayback( event.item.cmd, @@ -167,7 +168,9 @@ export function createStalkerVodDetailActions( return; } deps.beforeExternalLaunch?.(); - launched = true; + // The host's inline teardown retired the request id too; + // only a start made after this point supersedes the launch. + pending?.rebase(); await deps.portalPlayer.openExternalPlayback( playback, event.player @@ -175,7 +178,7 @@ export function createStalkerVodDetailActions( } catch (error) { // A launch a newer start superseded fails on its own; the // newer one reports for the movie now. - if (!stillSelected() || (!launched && superseded())) { + if (!stillSelected() || superseded()) { return; } deps.logError('External VOD playback failed', error); diff --git a/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts index 02fc2747c..179ebf2d8 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts @@ -49,13 +49,16 @@ export class StalkerVodPlaybackController { * an earlier start and is dropped once a later one begins. */ beginPendingStart(): PendingExternalLaunch { - const requestId = ++this.playbackRequestId; + let requestId = ++this.playbackRequestId; const playbackOwnerKey = this.config.playbackOwnerKey?.(); const startId = this.pendingStart.begin(playbackOwnerKey); return { settle: () => this.pendingStart.settle(startId), isCurrent: () => this.isPlaybackRequestCurrent(requestId, playbackOwnerKey), + rebase: () => { + requestId = this.playbackRequestId; + }, }; } 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 aa7fc98f3..2ed46e85d 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 @@ -4,11 +4,46 @@ import type { ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; import { + externalEpisodeSessionIds, isEpisodeLaunchPending, openEpisodeExternally, queueEpisodeChoice, } from './serial-details-external-launch'; +describe('externalEpisodeSessionIds', () => { + it('reports the launching or playing episode of the shown series only', () => { + expect(externalEpisodeSessionIds(null, '103', 'xtream-1')).toEqual({ + opening: null, + active: null, + }); + expect( + externalEpisodeSessionIds( + session({ status: 'launching' }), + '103', + 'xtream-1' + ) + ).toEqual({ opening: 1001, active: null }); + expect(externalEpisodeSessionIds(session(), '103', 'xtream-1')).toEqual( + { + opening: null, + active: 1001, + } + ); + expect(externalEpisodeSessionIds(session(), '104', 'xtream-1')).toEqual( + { + opening: null, + active: null, + } + ); + expect(externalEpisodeSessionIds(session(), '103', 'xtream-2')).toEqual( + { + opening: null, + active: null, + } + ); + }); +}); + const PLAYBACK: ResolvedPortalPlayback = { streamUrl: 'http://xtream.example/series/1002.mp4', title: 'Episode 2', 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 900abcab1..b45654df1 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 @@ -8,8 +8,46 @@ import type { ExternalPlayerSession, ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; +import { isLiveExternalPlayerSession } from '@iptvnator/portal/shared/util'; import { closeRunningExternalSession } from '../vod-details/vod-details-external-session'; +/** The episode ids an external session contributes to the series page. */ +export interface ExternalEpisodeSessionIds { + readonly opening: number | null; + readonly active: number | null; +} + +/** + * Which episode of the shown series the active external session is + * opening or playing; both null when the session belongs to something + * else or has ended. + */ +export function externalEpisodeSessionIds( + session: ExternalPlayerSession | null, + seriesXtreamId: string | number | undefined, + playlistId: string +): ExternalEpisodeSessionIds { + const info = session?.contentInfo; + if ( + !session || + !info || + !seriesXtreamId || + !playlistId || + info.contentType !== 'episode' || + info.playlistId !== playlistId || + info.seriesXtreamId !== Number(seriesXtreamId) + ) { + return { opening: null, active: null }; + } + if (session.status === 'launching') { + return { opening: info.contentXtreamId, active: null }; + } + if (isLiveExternalPlayerSession(session)) { + return { opening: null, active: info.contentXtreamId }; + } + return { opening: null, active: null }; +} + /** What a forced MPV/VLC episode launch reads from the series page. */ export interface SeriesExternalLaunchHost { readonly portalPlayer: Pick; 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 a8df46844..d2890af0e 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 @@ -44,7 +44,7 @@ interface SerialDetailsMenuBindings { readonly openEpisodeExternally: ( episode: XtreamSerieEpisode, player: ExternalPlayerName - ) => void; + ) => Promise | void; } /** @@ -193,7 +193,7 @@ export class SerialDetailsMenuService { await container?.downloadPresenter.enqueueSeason(); return; case SERIES_MENU_ACTION.ExternalPlayer: - this.openExternal(); + await this.openExternal(); return; case SERIES_MENU_ACTION.CopyUrl: await this.copyStreamUrl(); @@ -236,13 +236,21 @@ export class SerialDetailsMenuService { }; } - private openExternal(): void { + private async openExternal(): Promise { const bindings = this.bindings(); const episode = bindings?.quickStart()?.episode; if (!bindings || !episode) { return; } - bindings.openEpisodeExternally(episode, this.externalPlayer()); + try { + await bindings.openEpisodeExternally( + episode, + this.externalPlayer() + ); + } catch (error) { + this.logger.warn('External episode launch failed', error); + this.notify('PORTALS.PLAYBACK_ERROR'); + } } private async copyStreamUrl(): Promise { diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback-position-state.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback-position-state.ts index 365bfd150..dce6581fe 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback-position-state.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback-position-state.ts @@ -151,7 +151,9 @@ export class SerialDetailsPlaybackPositionState { save: ( playlistId: string, position: PlaybackPositionData - ) => Promise + ) => Promise, + /** The page still shows the series; checked again after the save. */ + stillShown: () => boolean = () => true ): Promise { const session = await launch; if (!session) { @@ -178,6 +180,9 @@ export class SerialDetailsPlaybackPositionState { }; await save(contentInfo.playlistId, position); + if (!stillShown()) { + return; + } this.update(position); } 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 28729ea2a..22bcc076f 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 @@ -10,7 +10,6 @@ import { } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { - isLiveExternalPlayerSession, PORTAL_EXTERNAL_PLAYBACK, PORTAL_PLAYBACK_POSITIONS, PORTAL_PLAYER, @@ -43,6 +42,7 @@ import { import { injectXtreamRecentHistory } from '../xtream-recent-history'; import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.token'; import { + externalEpisodeSessionIds, isEpisodeLaunchPending, openEpisodeExternally, queueEpisodeChoice, @@ -131,38 +131,13 @@ export class SerialDetailsPlaybackService { // A launch still closing its predecessor must not outlive the page. inject(DestroyRef).onDestroy(() => this.bindings.set(null)); effect(() => { - const session = this.externalPlayback.activeSession(); - const selectedItem = this.selectedItem(); - const playlistId = this.currentPlaylistId(); - - if ( - !session?.contentInfo || - !selectedItem?.series_id || - !playlistId || - session.contentInfo.contentType !== 'episode' || - session.contentInfo.playlistId !== playlistId || - session.contentInfo.seriesXtreamId !== - Number(selectedItem.series_id) - ) { - this.openingEpisodeId.set(null); - this.activeEpisodeId.set(null); - return; - } - - if (session.status === 'launching') { - this.openingEpisodeId.set(session.contentInfo.contentXtreamId); - this.activeEpisodeId.set(null); - return; - } - - if (isLiveExternalPlayerSession(session)) { - this.openingEpisodeId.set(null); - this.activeEpisodeId.set(session.contentInfo.contentXtreamId); - return; - } - - this.openingEpisodeId.set(null); - this.activeEpisodeId.set(null); + const ids = externalEpisodeSessionIds( + this.externalPlayback.activeSession(), + this.selectedItem()?.series_id, + this.currentPlaylistId() + ); + this.openingEpisodeId.set(ids.opening); + this.activeEpisodeId.set(ids.active); }); effect(() => { @@ -216,6 +191,7 @@ export class SerialDetailsPlaybackService { /** Clears all playback state when switching to another series. */ resetForNewSeries(): void { + this.pageGeneration += 1; this.closeInlinePlayer(); this.playbackPositionState.reset(); this.openingEpisodeId.set(null); @@ -226,7 +202,7 @@ export class SerialDetailsPlaybackService { playEpisode( episode: XtreamSerieEpisode, player?: ExternalPlayerName - ): void { + ): Promise | void { // A forced launch still settling owns the next start: the latest // choice made meanwhile starts once it settled, never beside it. const owner = this.launchOwner(); @@ -275,7 +251,7 @@ export class SerialDetailsPlaybackService { fallbackSeasonNumber: Number(episode.season), fallbackEpisodeNumber: Number(episode.episode_num), }); - this.startPlayback(playback, episodeState, player); + return this.startPlayback(playback, episodeState, player); } playQuickStartEpisode(): void { @@ -332,10 +308,12 @@ export class SerialDetailsPlaybackService { request.player ); request.trackLaunch(launch); + const token = this.pageToken(); void this.playbackPositionState.recordExternalLaunch( request.playback, launch, - this.savePosition + this.savePosition, + () => this.pageToken() === token ); } @@ -427,16 +405,16 @@ export class SerialDetailsPlaybackService { isEpisodeLaunchPending(this.launchOwner()) ); + /** Bumped for every series the page shows: a return to the same series is a new visit. */ + private pageGeneration = 0; + /** - * The launch's bookkeeping applies only while the page still shows the - * series it started on; a session that lands after a navigation is not - * written into the next series' position map. + * Identifies the series AND the visit: a launch's bookkeeping applies + * only while the page still shows what it showed when the launch + * started, not after leaving and coming back. */ - private ownedLaunch(launch: Promise) { - const owner = this.launchOwner(); - return launch.then((session) => - this.launchOwner() === owner ? session : undefined - ); + private pageToken(): string { + return `${this.launchOwner()}#${this.pageGeneration}`; } /** `playlist:series` of the page, null once it is gone or shows another series. */ @@ -449,7 +427,7 @@ export class SerialDetailsPlaybackService { playback: ResolvedPortalPlayback, episodeState: SeriesPlaybackEpisodeState | null, player?: ExternalPlayerName - ): void { + ): Promise | void { this.lastSaveTime = 0; if (!player && this.portalPlayer.isEmbeddedPlayer()) { this.inlinePlaybackSessionEpisodeState.set(episodeState); @@ -458,14 +436,26 @@ export class SerialDetailsPlaybackService { } this.closeInlinePlayer(); + const launch = player + ? openEpisodeExternally(this, playback, player) + : this.portalPlayer.openResolvedPlayback(playback, true); + // The bookkeeping never rejects; a forced launch's failure is the + // menu's to report, which awaits the launch it asked for. + const token = this.pageToken(); + const stillShown = () => this.pageToken() === token; + const settled = launch.catch((error) => { + console.warn( + '[SerialDetailsPlayback] External launch failed', + error + ); + return undefined; + }); void this.playbackPositionState.recordExternalLaunch( playback, - this.ownedLaunch( - player - ? openEpisodeExternally(this, playback, player) - : this.portalPlayer.openResolvedPlayback(playback, true) - ), - this.savePosition + settled.then((session) => (stillShown() ? session : undefined)), + this.savePosition, + stillShown ); + return launch; } } diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts index 207a790b3..7b2b21177 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts @@ -674,8 +674,8 @@ describe('SerialDetailsComponent', () => { season: 2, } as never); await fixture.whenStable(); - // One more hop: the launch's owner check runs before the save. - await Promise.resolve(); + // The launch settles through its page checks before the save. + await new Promise((resolve) => setTimeout(resolve)); expect(savePlaybackPosition).toHaveBeenCalledWith( 'xtream-1', diff --git a/libs/ui/components/src/lib/season-container/episode-progress.util.ts b/libs/ui/components/src/lib/season-container/episode-progress.util.ts index a966ed6ee..91868e8d5 100644 --- a/libs/ui/components/src/lib/season-container/episode-progress.util.ts +++ b/libs/ui/components/src/lib/season-container/episode-progress.util.ts @@ -51,3 +51,13 @@ export function formatEpisodePositionText( .join(':'); return `${formatted}${suffix}`; } + +/** The episode runtime: the provider's `duration_secs` first, then its formatted `duration`. */ +export function episodeRuntimeSeconds( + info: { duration_secs?: number; duration?: string } | null | undefined +): number { + const seconds = Number(info?.duration_secs); + return Number.isFinite(seconds) && seconds > 0 + ? Math.floor(seconds) + : parseDuration(info?.duration); +} diff --git a/libs/ui/components/src/lib/season-container/episode-utils.spec.ts b/libs/ui/components/src/lib/season-container/episode-utils.spec.ts index 0d2914d58..29951e5d9 100644 --- a/libs/ui/components/src/lib/season-container/episode-utils.spec.ts +++ b/libs/ui/components/src/lib/season-container/episode-utils.spec.ts @@ -1,8 +1,23 @@ import { + episodeRuntimeSeconds, formatEpisodePositionText, parseDuration, } from './episode-progress.util'; +describe('episodeRuntimeSeconds', () => { + it('prefers the provider seconds over the formatted string', () => { + expect(episodeRuntimeSeconds({ duration_secs: 2520 })).toBe(2520); + expect( + episodeRuntimeSeconds({ duration_secs: 2520, duration: '00:10:00' }) + ).toBe(2520); + expect(episodeRuntimeSeconds({ duration: '00:42:00' })).toBe(2520); + expect( + episodeRuntimeSeconds({ duration_secs: 0, duration: '00:42:00' }) + ).toBe(2520); + expect(episodeRuntimeSeconds(undefined)).toBe(0); + }); +}); + describe('episode-progress.util', () => { it('parses duration strings', () => { expect(parseDuration('01:00:30')).toBe(3630); diff --git a/libs/ui/components/src/lib/season-container/season-container.component.ts b/libs/ui/components/src/lib/season-container/season-container.component.ts index 9d89dbe32..26b265c13 100644 --- a/libs/ui/components/src/lib/season-container/season-container.component.ts +++ b/libs/ui/components/src/lib/season-container/season-container.component.ts @@ -26,7 +26,6 @@ import { getPortalPlaybackProgressPercent, isPortalPlaybackInProgress, isPortalPlaybackWatched, - parseDurationSeconds, } from '@iptvnator/portal/shared/util'; import { PlaybackPositionData, @@ -39,7 +38,10 @@ import { EpisodeInfoDialogComponent, buildEpisodeInfoDialogData, } from './episode-info-dialog.component'; -import { formatEpisodePositionText } from './episode-progress.util'; +import { + formatEpisodePositionText, + episodeRuntimeSeconds, +} from './episode-progress.util'; import { resolveAutoSelectedSeason } from './season-auto-select.util'; import { SeasonDownloadPresenter } from './season-download-presenter'; import { SeasonTabsComponent } from './season-tabs.component'; @@ -433,8 +435,7 @@ export class SeasonContainerComponent implements OnInit { /** "42 min · 18m left", "42 min · watched", "42 min" — or null. */ getEpisodeSubline(episode: XtreamSerieEpisode): string | null { const info = this.getEpisodeInfo(episode); - const seconds = parseDurationSeconds(info?.duration); - const duration = formatDurationLabel(seconds); + const duration = formatDurationLabel(episodeRuntimeSeconds(info)); const position = this.playbackPositions().get(Number(episode.id)); const remaining = formatRemainingLabel(position); const parts = [ diff --git a/libs/ui/playback/src/lib/vod-details/vod-details-presentation.spec.ts b/libs/ui/playback/src/lib/vod-details/vod-details-presentation.spec.ts index 1105f564c..7a8f3ba0a 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details-presentation.spec.ts +++ b/libs/ui/playback/src/lib/vod-details/vod-details-presentation.spec.ts @@ -39,6 +39,13 @@ describe('buildVodMenuSections', () => { expect(reset?.disabled).toBeFalsy(); }); + it('disables Start Over while a start still resolves', () => { + const restart = rows({ startPending: true }).find( + (row) => row.id === VOD_DETAILS_MENU_ACTION.StartOver + ); + expect(restart?.disabled).toBe(true); + }); + it('disables the external launch while a start still resolves', () => { const external = rows({ startPending: true }).find( (row) => row.id === VOD_DETAILS_MENU_ACTION.ExternalPlayer diff --git a/libs/ui/playback/src/lib/vod-details/vod-details-presentation.ts b/libs/ui/playback/src/lib/vod-details/vod-details-presentation.ts index 48c6c2148..15192eef5 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details-presentation.ts +++ b/libs/ui/playback/src/lib/vod-details/vod-details-presentation.ts @@ -200,6 +200,7 @@ export function buildVodMenuSections(input: { id: VOD_DETAILS_MENU_ACTION.StartOver, labelKey: 'XTREAM.RESTART', icon: 'replay', + disabled: input.startPending, testId: 'vod-menu-start-over', }); } diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.html b/libs/ui/playback/src/lib/vod-details/vod-details.component.html index c47f32016..45fc9ed38 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details.component.html +++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.html @@ -105,7 +105,7 @@ [meta]="primary.meta" [icon]="primary.icon" [state]="primary.state" - [disabled]="isExternalLaunchPending()" + [disabled]="isExternalLaunchPending() || playbackStartPending()" testId="vod-primary-action" (pressed)="onPrimaryAction()" />