fix(portals): a forced Xtream episode launch blocks other starts and the reset

The launch helper keeps a reactive per-series pending count; the
playback service exposes it, refuses a non-forced start while a forced
launch settles, and the series menu disables the reset row for that
window. The inline progress row moves into a shared helper.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 14:08:09 +02:00
1 parent f7c71c222a
commit 956e7c36b7
6 files changed
+110 -17

No files matched your search

@@ -5,10 +5,36 @@ import {
playbackProgressPercent,
} from './playback-progress';
import { inlineProgressPosition } from './playback-progress';
describe('inlineProgressPosition', () => {
it('writes floored seconds onto the content identity', () => {
expect(
inlineProgressPosition(
{
playlistId: 'p1',
contentXtreamId: 7,
contentType: 'episode',
},
{ currentTime: 61.9, duration: 1800.2 }
)
).toEqual({
playlistId: 'p1',
contentXtreamId: 7,
contentType: 'episode',
positionSeconds: 61,
durationSeconds: 1800,
});
});
});
describe('playbackProgressPercent', () => {
it('floors the watched share to an integer percent', () => {
expect(
playbackProgressPercent({ positionSeconds: 924, durationSeconds: 1000 })
playbackProgressPercent({
positionSeconds: 924,
durationSeconds: 1000,
})
).toBe(92);
});
@@ -27,7 +53,10 @@ describe('formatRemainingLabel', () => {
params: { seconds: 30 },
});
expect(
formatRemainingLabel({ positionSeconds: 600, durationSeconds: 1920 })
formatRemainingLabel({
positionSeconds: 600,
durationSeconds: 1920,
})
).toEqual({
key: 'WORKSPACE.DASHBOARD.REMAINING_MINUTES',
params: { minutes: 22 },
@@ -1,3 +1,7 @@
import type {
PlaybackPositionData,
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
export interface RemainingTimeLabel {
readonly key: string;
readonly params: Record<string, number>;
@@ -9,6 +13,18 @@ interface ProgressPosition {
}
/** 0–100 watched share, or null when the duration is unknown. */
/** The position row an inline player's time update writes for its content. */
export function inlineProgressPosition(
contentInfo: PlayerContentInfo,
event: { currentTime: number; duration: number }
): PlaybackPositionData {
return {
...contentInfo,
positionSeconds: Math.floor(event.currentTime),
durationSeconds: Math.floor(event.duration),
};
}
export function playbackProgressPercent(
position: ProgressPosition | null | undefined
): number | null {
@@ -3,7 +3,10 @@ import type {
ExternalPlayerSession,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { openEpisodeExternally } from './serial-details-external-launch';
import {
isEpisodeLaunchPending,
openEpisodeExternally,
} from './serial-details-external-launch';
const PLAYBACK: ResolvedPortalPlayback = {
streamUrl: 'http://xtream.example/series/1002.mp4',
@@ -144,6 +147,23 @@ describe('openEpisodeExternally', () => {
);
});
it('reports the owner as pending until the launch settles', async () => {
const t = host(null);
let settleLaunch: () => void = () => undefined;
t.openExternalPlayback.mockImplementation(
() => new Promise<void>((resolve) => (settleLaunch = resolve))
);
expect(isEpisodeLaunchPending('xtream-1:103')).toBe(false);
const launch = openEpisodeExternally(t.host, PLAYBACK, 'mpv');
expect(isEpisodeLaunchPending('xtream-1:103')).toBe(true);
expect(isEpisodeLaunchPending('xtream-1:104')).toBe(false);
await flush();
settleLaunch();
await launch;
expect(isEpisodeLaunchPending('xtream-1:103')).toBe(false);
});
it('keeps the running player when closing it fails', async () => {
const t = host(session());
t.closeSession.mockRejectedValue(new Error('still busy'));
@@ -1,3 +1,4 @@
import { signal } from '@angular/core';
import type {
PortalExternalPlayback,
PortalPlayer,
@@ -24,6 +25,26 @@ export interface SeriesExternalLaunchHost {
const launchesInFlight = new Set<string>();
/** Per owner, the tail of its launch chain: a second episode waits its turn. */
const launchChains = new Map<string, Promise<unknown>>();
/** Owners with a launch in flight, reactive for the hosts' start guards. */
const pendingOwners = signal<ReadonlyMap<string, number>>(new Map());
function countPending(owner: string, delta: number): void {
pendingOwners.update((owners) => {
const next = new Map(owners);
const count = (next.get(owner) ?? 0) + delta;
if (count > 0) {
next.set(owner, count);
} else {
next.delete(owner);
}
return next;
});
}
/** Whether a forced launch of this owner (`playlist:series`) has not settled yet. */
export function isEpisodeLaunchPending(owner: string | null): boolean {
return !!owner && (pendingOwners().get(owner) ?? 0) > 0;
}
/**
* The "…" menu's MPV/VLC launch of an episode. An episode of this series
@@ -49,6 +70,7 @@ export async function openEpisodeExternally(
return;
}
launchesInFlight.add(key);
countPending(owner, 1);
const previous = launchChains.get(owner) ?? Promise.resolve();
const run = previous.then(() =>
launchEpisode(host, owner, playback, player)
@@ -59,6 +81,7 @@ export async function openEpisodeExternally(
return await run;
} finally {
launchesInFlight.delete(key);
countPending(owner, -1);
if (launchChains.get(owner) === tail) {
launchChains.delete(owner);
}
@@ -15,6 +15,7 @@ import {
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
getSeriesQuickStartAction,
inlineProgressPosition,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
@@ -39,7 +40,10 @@ import {
} from '@iptvnator/ui/playback';
import { injectXtreamRecentHistory } from '../xtream-recent-history';
import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.token';
import { openEpisodeExternally } from './serial-details-external-launch';
import {
isEpisodeLaunchPending,
openEpisodeExternally,
} from './serial-details-external-launch';
import { SerialDetailsPlaybackPositionState } from './serial-details-playback-position-state';
import {
SerialDetailsSeasonWatchService,
@@ -217,6 +221,11 @@ 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.
if (!player && this.forcedLaunchPending()) {
return;
}
const playlist = this.xtreamStore.currentPlaylist();
const selectedItem = this.selectedItem();
if (!playlist || !selectedItem) {
@@ -299,21 +308,11 @@ export class SerialDetailsPlaybackService {
duration: number;
}): void {
const playback = this.inlinePlayback();
if (!playback?.contentInfo) return;
const now = Date.now();
if (now - this.lastSaveTime <= 15000) return;
if (!playback?.contentInfo || now - this.lastSaveTime <= 15000) return;
this.lastSaveTime = now;
const position: PlaybackPositionData = {
...playback.contentInfo,
positionSeconds: Math.floor(event.currentTime),
durationSeconds: Math.floor(event.duration),
};
void this.playbackPositions.savePlaybackPosition(
playback.contentInfo.playlistId,
position
);
const position = inlineProgressPosition(playback.contentInfo, event);
void this.savePosition(playback.contentInfo.playlistId, position);
this.playbackPositionState.update(position);
}
@@ -413,6 +412,11 @@ export class SerialDetailsPlaybackService {
return this.bindings()?.selectedItem() ?? null;
}
/** A forced MPV/VLC launch of this series that has not settled yet. */
readonly forcedLaunchPending = computed(() =>
isEpisodeLaunchPending(this.launchOwner())
);
/** `playlist:series` of the page, null once it is gone or shows another series. */
launchOwner(): string | null {
const seriesId = this.selectedItem()?.series_id;
@@ -224,6 +224,7 @@ export class SerialDetailsComponent implements OnDestroy {
playbackActive: computed(
() =>
this.inlinePlayback() !== null ||
this.playback.forcedLaunchPending() ||
this.playback.openingEpisodeId() !== null ||
this.playback.activeEpisodeId() !== null
),