mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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 {
|
||||
|
||||
+21
-1
@@ -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);
|
||||
}
|
||||
|
||||
+18
-14
@@ -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
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user