fix(playback): keep the early-EOF retry on a refused reload or after a seek

The resume point that tells a reload ending where it resumed from a new
drop was recorded before the reload ran, so a reload the addon refused made
the original EOF look like the end and the next poll cancelled the retry.
Record it only once the reload started, and forget it when the user seeks:
an EOF after a seek is judged on its position alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-12 08:55:48 +02:00
1 parent df1667c866
commit 0a574a717a
5 files changed
+162 -7

No files matched your search

@@ -631,6 +631,30 @@ describe('EmbeddedMpvNativeService reconnect', () => {
expect(addon.loadPlayback).toHaveBeenCalledTimes(2);
});
it('forgets where the reload resumed once the user seeks', () => {
service.createSession(BOUNDS, 'Title', 0.5, {
extraOptions: [],
autoReconnect: true,
});
service.loadPlayback('session-1', VOD);
poll('loading');
positionSeconds = 1_260;
durationSeconds = 2_700;
poll('playing');
poll('ended');
status = 'loading';
jest.advanceTimersByTime(2_000);
poll('playing');
// A seek away and back: the next early EOF near the old resume
// point is a new drop, not the reload ending where it resumed.
service.seek('session-1', 600);
service.seekBy('session-1', 662);
poll('ended');
expect(lastUpdate()?.reconnect?.attempt).toBe(2);
});
it('treats EOF on a live stream as a drop', () => {
startPlayingLive();
@@ -741,6 +741,7 @@ export class EmbeddedMpvNativeService {
seek(sessionId: string, seconds: number): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
this.onUserSeek(sessionId);
this.getAddon().seek(sessionId, seconds);
return this.refreshSession(sessionId);
}
@@ -760,6 +761,7 @@ export class EmbeddedMpvNativeService {
if (!Number.isFinite(deltaSeconds)) {
return this.refreshSession(sessionId);
}
this.onUserSeek(sessionId);
if (typeof addon.seekBy === 'function') {
addon.seekBy(sessionId, deltaSeconds);
} else {
@@ -770,6 +772,14 @@ export class EmbeddedMpvNativeService {
return this.refreshSession(sessionId);
}
/** A seek invalidates the reconnect's resume point (embedded-mpv-reconnect.ts). */
private onUserSeek(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (session) {
this.reconnect.onUserSeek(session.reconnect);
}
}
setVolume(sessionId: string, volume: number): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
this.getAddon().setVolume(sessionId, volume);
@@ -411,6 +411,114 @@ describe('EmbeddedMpvReconnectCoordinator', () => {
});
});
it('keeps retrying an early EOF when the addon refuses the reload', () => {
startPlaying(VOD);
coordinator.observe(
SESSION,
state,
'playing',
'playing',
1_200,
undefined,
2_700
);
coordinator.observe(
SESSION,
state,
'ended',
'playing',
1_200,
undefined,
2_700
);
reload.mockImplementationOnce(() => {
throw new Error('addon exploded');
});
jest.advanceTimersByTime(2_000);
// The refused reload left the original EOF in the snapshot: it is
// still the drop, not a reload that ended where it resumed.
expect(
coordinator.observe(
SESSION,
state,
'ended',
'ended',
1_200,
undefined,
2_700
)?.attempt
).toBe(2);
jest.advanceTimersByTime(4_000);
expect(reload).toHaveBeenCalledTimes(2);
});
it('judges an EOF after a user seek on its position alone', () => {
startPlaying(VOD);
coordinator.observe(
SESSION,
state,
'playing',
'playing',
1_200,
undefined,
2_700
);
coordinator.observe(
SESSION,
state,
'ended',
'playing',
1_200,
undefined,
2_700
);
jest.advanceTimersByTime(2_000);
coordinator.observe(SESSION, state, 'loading', 'ended');
coordinator.observe(
SESSION,
state,
'playing',
'loading',
1_201,
undefined,
2_700
);
// Back to 10 min; the connection drops again a few minutes later.
coordinator.onUserSeek(state);
coordinator.observe(
SESSION,
state,
'playing',
'playing',
600,
undefined,
2_700
);
coordinator.observe(
SESSION,
state,
'playing',
'playing',
1_202,
undefined,
2_700
);
expect(
coordinator.observe(
SESSION,
state,
'ended',
'playing',
1_203,
undefined,
2_700
)?.attempt
).toBe(2);
});
it('resumes a VOD reload where it dropped but keeps live at the live edge', () => {
startPlaying(VOD);
coordinator.observe(SESSION, state, 'playing', 'playing', 95);
@@ -89,8 +89,9 @@ export interface EmbeddedMpvReconnectState {
*/
positionReported: boolean;
/**
* Where the last VOD reload resumed, until playback gets past it. An
* early EOF before then ended where the file does, not where it dropped.
* Where the last VOD reload resumed, until playback gets past it or the
* user seeks. An early EOF before then ended where the file does, not
* where it dropped.
*/
resumedAtSeconds: number | null;
timer: NodeJS.Timeout | null;
@@ -201,6 +202,14 @@ export class EmbeddedMpvReconnectCoordinator {
state.pending = null;
}
/**
* The user moved the playhead: an EOF after that is not the reload
* ending where it resumed, so it is judged on its position alone.
*/
onUserSeek(state: EmbeddedMpvReconnectState): void {
state.resumedAtSeconds = null;
}
cancel(state: EmbeddedMpvReconnectState): void {
this.clearTimer(state);
state.attemptInFlight = false;
@@ -350,9 +359,11 @@ export class EmbeddedMpvReconnectCoordinator {
state.timer = null;
state.attempts = attempt;
state.attemptInFlight = true;
state.resumedAtSeconds = playback.startTime ?? null;
try {
this.hooks.reload(sessionId, playback);
// Only a reload that started can end where it resumed; a
// refused one leaves the original EOF, still a drop.
state.resumedAtSeconds = playback.startTime ?? null;
} catch (error) {
state.attemptInFlight = false;
if (isEmbeddedMpvSessionGoneError(error)) {