fix(playback): wait for mpv's reply to every reused-instance IPC command (#1876)

Every reused-instance MPV IPC command now carries a request_id and waits, within the existing 2 s bound, for mpv's matching reply; an error or missing reply falls back to a fresh launch like a socket failure, and the racing post-loadfile seek is gone. MPV launches run one at a time through a launch queue so overlapping launches never act on the same child; Stop on a queued launch delegates to the in-flight launch's own closer, a failed exit stays retryable, and the dock keeps the newest launch. Position polling and output-error reporting moved out of mpv-session.service.ts, which leaves the max-lines baseline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 authored and GitHub committed 2026-10-11 22:42:43 +02:00
1 parent 94570bc387
commit 4e9795ac54
21 files changed
+2920 -394

No files matched your search

@@ -62,6 +62,49 @@ describe('ExternalPlaybackService', () => {
expect(service.visibleSession()).toEqual(next);
});
it('keeps the newest launch when an older launch republishes as launching', () => {
const older = createSession({
id: 'session-older',
startedAt: '2026-03-07T10:00:00.000Z',
});
const newer = createSession({
id: 'session-newer',
title: 'Newer Example',
startedAt: '2026-03-07T10:00:01.000Z',
});
listener?.(older);
listener?.(newer);
// The older launch is still settling and republishes itself.
listener?.({
...older,
canClose: true,
updatedAt: '2026-03-07T10:00:02.000Z',
});
expect(service.activeSession()?.id).toBe('session-newer');
});
it('orders launches made in the same millisecond by launch order', () => {
const older = createSession({ id: 'session-older', launchOrder: 1 });
const newer = createSession({
id: 'session-newer',
title: 'Newer Example',
launchOrder: 2,
});
listener?.(older);
listener?.(newer);
// Same startedAt: only the launch order tells the two apart.
listener?.({
...older,
canClose: true,
updatedAt: '2026-03-07T10:00:02.000Z',
});
expect(service.activeSession()?.id).toBe('session-newer');
});
it('matches only active non-error sessions to content info', () => {
const playing = createSession({
status: 'playing',
@@ -4,6 +4,17 @@ import {
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
/** Launch order when both carry it, else the start time. */
function startedBefore(
session: ExternalPlayerSession,
other: ExternalPlayerSession
): boolean {
if (session.launchOrder !== undefined && other.launchOrder !== undefined) {
return session.launchOrder < other.launchOrder;
}
return Date.parse(session.startedAt) < Date.parse(other.startedAt);
}
@Injectable({
providedIn: 'root',
})
@@ -105,17 +116,25 @@ export class ExternalPlaybackService {
const current = this.activeSession();
const restoresCurrentReplacement =
current?.id === session.restoredFromSessionId;
// A launching update means a new launch only when it did not start
// before the current one: an older launch still settling (MPV runs
// launches one at a time) must not take the dock back from it.
const isLatestLaunch =
session.status === 'launching' &&
(!current ||
current.id === session.id ||
!startedBefore(session, current));
if (
!current ||
current.id === session.id ||
session.status === 'launching' ||
isLatestLaunch ||
restoresCurrentReplacement
) {
this.activeSession.set(session);
}
if (session.status === 'launching' || restoresCurrentReplacement) {
if (isLatestLaunch || restoresCurrentReplacement) {
this.dismissedSessionId.set(null);
}
}