mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
fix(portals): drop a forced episode launch once the page moved on
Closing the previous external session takes time; a launch whose series page is gone or shows another series by then no longer starts. The service clears its bindings on destroy and exposes the page's playlist:series owner for that check. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
d79e6504a5
commit
026a69c14e
3 files changed
+48
-28
No files matched your search
+17
-3
@@ -37,17 +37,18 @@ describe('openEpisodeExternally', () => {
|
||||
function host(active: ExternalPlayerSession | null) {
|
||||
const openExternalPlayback = jest.fn().mockResolvedValue(undefined);
|
||||
const closeSession = jest.fn().mockResolvedValue(undefined);
|
||||
const launchOwner = jest.fn().mockReturnValue('xtream-1:103');
|
||||
return {
|
||||
openExternalPlayback,
|
||||
closeSession,
|
||||
launchOwner,
|
||||
host: {
|
||||
portalPlayer: { openExternalPlayback },
|
||||
externalPlayback: {
|
||||
activeSession: () => active,
|
||||
closeSession,
|
||||
},
|
||||
currentPlaylistId: () => 'xtream-1',
|
||||
selectedItem: () => ({ series_id: '103' }),
|
||||
launchOwner,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -79,10 +80,23 @@ describe('openEpisodeExternally', () => {
|
||||
expect(t.openExternalPlayback).toHaveBeenCalledWith(PLAYBACK, 'mpv');
|
||||
});
|
||||
|
||||
it('drops the launch when the page moved on while the close ran', async () => {
|
||||
const t = host(session());
|
||||
t.closeSession.mockImplementation(async () => {
|
||||
t.launchOwner.mockReturnValue('xtream-1:999');
|
||||
});
|
||||
await openEpisodeExternally(t.host, PLAYBACK, 'mpv');
|
||||
|
||||
expect(t.closeSession).toHaveBeenCalledTimes(1);
|
||||
expect(t.openExternalPlayback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the running player when closing it fails', async () => {
|
||||
const t = host(session());
|
||||
t.closeSession.mockRejectedValue(new Error('still busy'));
|
||||
const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
const warn = jest
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
await expect(
|
||||
openEpisodeExternally(t.host, PLAYBACK, 'mpv')
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
+11
-7
@@ -16,21 +16,26 @@ export interface SeriesExternalLaunchHost {
|
||||
PortalExternalPlayback,
|
||||
'activeSession' | 'closeSession'
|
||||
>;
|
||||
currentPlaylistId(): string;
|
||||
selectedItem(): { series_id?: string | number } | null;
|
||||
/** `playlist:series` on screen, null once the page is gone. */
|
||||
launchOwner(): string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "…" menu's MPV/VLC launch of an episode. An episode of this series
|
||||
* still running externally is closed first: with instance reuse off a
|
||||
* second detached player would start beside it. When that close fails the
|
||||
* running player stays and nothing new launches.
|
||||
* second detached player would start beside it. When that close fails, or
|
||||
* the user moved on while it ran, the running player stays and nothing new
|
||||
* launches.
|
||||
*/
|
||||
export async function openEpisodeExternally(
|
||||
host: SeriesExternalLaunchHost,
|
||||
playback: ResolvedPortalPlayback,
|
||||
player: ExternalPlayerName
|
||||
): Promise<ExternalPlayerSession | void> {
|
||||
const owner = host.launchOwner();
|
||||
if (!owner) {
|
||||
return;
|
||||
}
|
||||
const session = host.externalPlayback.activeSession();
|
||||
const info = session?.contentInfo;
|
||||
const ownSession =
|
||||
@@ -38,8 +43,7 @@ export async function openEpisodeExternally(
|
||||
info &&
|
||||
session.status !== 'closed' &&
|
||||
info.contentType === 'episode' &&
|
||||
info.playlistId === host.currentPlaylistId() &&
|
||||
info.seriesXtreamId === Number(host.selectedItem()?.series_id ?? 0)
|
||||
`${info.playlistId}:${info.seriesXtreamId}` === owner
|
||||
? session
|
||||
: null;
|
||||
const replaced = await closeRunningExternalSession(
|
||||
@@ -48,7 +52,7 @@ export async function openEpisodeExternally(
|
||||
(message, error) =>
|
||||
console.warn(`[SerialDetailsPlayback] ${message}`, error)
|
||||
);
|
||||
if (!replaced) {
|
||||
if (!replaced || host.launchOwner() !== owner) {
|
||||
return;
|
||||
}
|
||||
return host.portalPlayer.openExternalPlayback(playback, player);
|
||||
|
||||
+20
-18
@@ -72,11 +72,15 @@ export class SerialDetailsPlaybackService {
|
||||
private readonly recordRecentItem = injectXtreamRecentHistory();
|
||||
private readonly resumeTarget = inject(XTREAM_SERIES_RESUME_TARGET);
|
||||
private readonly seasonWatch = inject(SerialDetailsSeasonWatchService);
|
||||
private readonly savePosition = (
|
||||
playlistId: string,
|
||||
position: PlaybackPositionData
|
||||
) => this.playbackPositions.savePlaybackPosition(playlistId, position);
|
||||
|
||||
private readonly bindings = signal<SerialDetailsPlaybackBindings | null>(
|
||||
null
|
||||
);
|
||||
readonly currentPlaylistId = computed(
|
||||
private readonly currentPlaylistId = computed(
|
||||
() => this.xtreamStore.currentPlaylist()?.id ?? ''
|
||||
);
|
||||
private readonly playbackPositionState =
|
||||
@@ -114,6 +118,8 @@ export class SerialDetailsPlaybackService {
|
||||
);
|
||||
|
||||
constructor() {
|
||||
// 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();
|
||||
@@ -262,18 +268,16 @@ export class SerialDetailsPlaybackService {
|
||||
|
||||
playPreviousEpisode(): void {
|
||||
const previous = this.inlineEpisodeState()?.previous;
|
||||
if (!previous) {
|
||||
return;
|
||||
if (previous) {
|
||||
this.playEpisode(previous);
|
||||
}
|
||||
this.playEpisode(previous);
|
||||
}
|
||||
|
||||
playNextEpisode(): void {
|
||||
const next = this.inlineEpisodeState()?.next;
|
||||
if (!next) {
|
||||
return;
|
||||
if (next) {
|
||||
this.playEpisode(next);
|
||||
}
|
||||
this.playEpisode(next);
|
||||
}
|
||||
|
||||
handleInlinePlaybackEnded(): void {
|
||||
@@ -322,11 +326,7 @@ export class SerialDetailsPlaybackService {
|
||||
void this.playbackPositionState.recordExternalLaunch(
|
||||
request.playback,
|
||||
launch,
|
||||
(playlistId, position) =>
|
||||
this.playbackPositions.savePlaybackPosition(
|
||||
playlistId,
|
||||
position
|
||||
)
|
||||
this.savePosition
|
||||
);
|
||||
}
|
||||
|
||||
@@ -409,10 +409,16 @@ export class SerialDetailsPlaybackService {
|
||||
);
|
||||
}
|
||||
|
||||
selectedItem(): XtreamSerieDetailsView | null {
|
||||
private selectedItem(): XtreamSerieDetailsView | null {
|
||||
return this.bindings()?.selectedItem() ?? null;
|
||||
}
|
||||
|
||||
/** `playlist:series` of the page, null once it is gone or shows another series. */
|
||||
launchOwner(): string | null {
|
||||
const seriesId = this.selectedItem()?.series_id;
|
||||
return seriesId ? `${this.currentPlaylistId()}:${seriesId}` : null;
|
||||
}
|
||||
|
||||
private startPlayback(
|
||||
playback: ResolvedPortalPlayback,
|
||||
episodeState: SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null,
|
||||
@@ -431,11 +437,7 @@ export class SerialDetailsPlaybackService {
|
||||
player
|
||||
? openEpisodeExternally(this, playback, player)
|
||||
: this.portalPlayer.openResolvedPlayback(playback, true),
|
||||
(playlistId, position) =>
|
||||
this.playbackPositions.savePlaybackPosition(
|
||||
playlistId,
|
||||
position
|
||||
)
|
||||
this.savePosition
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user