From 956e7c36b732df807d84d1cfe9d755b19454ae3b Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 3 Oct 2026 14:08:09 +0200 Subject: [PATCH] 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 --- .../src/lib/detail/playback-progress.spec.ts | 33 +++++++++++++++++-- .../util/src/lib/detail/playback-progress.ts | 16 +++++++++ .../serial-details-external-launch.spec.ts | 22 ++++++++++++- .../serial-details-external-launch.ts | 23 +++++++++++++ .../serial-details-playback.service.ts | 32 ++++++++++-------- .../serial-details.component.ts | 1 + 6 files changed, 110 insertions(+), 17 deletions(-) diff --git a/libs/portal/shared/util/src/lib/detail/playback-progress.spec.ts b/libs/portal/shared/util/src/lib/detail/playback-progress.spec.ts index 74c1c4479..3ce772e7b 100644 --- a/libs/portal/shared/util/src/lib/detail/playback-progress.spec.ts +++ b/libs/portal/shared/util/src/lib/detail/playback-progress.spec.ts @@ -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 }, diff --git a/libs/portal/shared/util/src/lib/detail/playback-progress.ts b/libs/portal/shared/util/src/lib/detail/playback-progress.ts index 127afad7b..472fb55ae 100644 --- a/libs/portal/shared/util/src/lib/detail/playback-progress.ts +++ b/libs/portal/shared/util/src/lib/detail/playback-progress.ts @@ -1,3 +1,7 @@ +import type { + PlaybackPositionData, + PlayerContentInfo, +} from '@iptvnator/shared/interfaces'; export interface RemainingTimeLabel { readonly key: string; readonly params: Record; @@ -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 { 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 cc739c248..1d7669347 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 @@ -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((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')); 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 12faf5274..0ec1baf78 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 @@ -1,3 +1,4 @@ +import { signal } from '@angular/core'; import type { PortalExternalPlayback, PortalPlayer, @@ -24,6 +25,26 @@ export interface SeriesExternalLaunchHost { const launchesInFlight = new Set(); /** Per owner, the tail of its launch chain: a second episode waits its turn. */ const launchChains = new Map>(); +/** Owners with a launch in flight, reactive for the hosts' start guards. */ +const pendingOwners = signal>(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); } 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 bae59a29c..e401cf5a6 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 @@ -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; 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 cd1fce6ea..eb3f11a85 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 @@ -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 ),