mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
fix(portals): twenty-fourth review round on the cinematic details
- 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
d71a92cec8
commit
d087cf4d1c
15 files changed
+209
-71
No files matched your search
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+35
@@ -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',
|
||||
|
||||
@@ -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<PortalPlayer, 'openExternalPlayback'>;
|
||||
|
||||
@@ -44,7 +44,7 @@ interface SerialDetailsMenuBindings {
|
||||
readonly openEpisodeExternally: (
|
||||
episode: XtreamSerieEpisode,
|
||||
player: ExternalPlayerName
|
||||
) => void;
|
||||
) => Promise<unknown> | 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<void> {
|
||||
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<void> {
|
||||
|
||||
+6
-1
@@ -151,7 +151,9 @@ export class SerialDetailsPlaybackPositionState {
|
||||
save: (
|
||||
playlistId: string,
|
||||
position: PlaybackPositionData
|
||||
) => Promise<void>
|
||||
) => Promise<void>,
|
||||
/** The page still shows the series; checked again after the save. */
|
||||
stillShown: () => boolean = () => true
|
||||
): Promise<void> {
|
||||
const session = await launch;
|
||||
if (!session) {
|
||||
@@ -178,6 +180,9 @@ export class SerialDetailsPlaybackPositionState {
|
||||
};
|
||||
|
||||
await save(contentInfo.playlistId, position);
|
||||
if (!stillShown()) {
|
||||
return;
|
||||
}
|
||||
this.update(position);
|
||||
}
|
||||
|
||||
|
||||
+41
-51
@@ -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<ExternalPlayerSession | void> | 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<ExternalPlayerSession | void>) {
|
||||
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<XtreamSerieEpisode> | null,
|
||||
player?: ExternalPlayerName
|
||||
): void {
|
||||
): Promise<ExternalPlayerSession | void> | 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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
[meta]="primary.meta"
|
||||
[icon]="primary.icon"
|
||||
[state]="primary.state"
|
||||
[disabled]="isExternalLaunchPending()"
|
||||
[disabled]="isExternalLaunchPending() || playbackStartPending()"
|
||||
testId="vod-primary-action"
|
||||
(pressed)="onPrimaryAction()"
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user