fix(playback): detect embedded mpv keep-open eof (#1050)

* fix(playback): detect embedded mpv keep-open eof

* fix(playback): keep mpv redirects loading
This commit is contained in:
4gray authored and GitHub committed 2026-06-13 10:19:51 +02:00
1 parent e8aa7c3a34
commit 83db27ef12
4 files changed
+164 -17

No files matched your search

@@ -1136,6 +1136,13 @@ void runEventLoop(const std::shared_ptr<Session>& session)
endFile->reason == MPV_END_FILE_REASON_EOF &&
session->running.load()) {
session->snapshot.status = SessionStatus::Ended;
} else if (
endFile &&
endFile->reason == MPV_END_FILE_REASON_REDIRECT &&
session->running.load()) {
session->snapshot.status = SessionStatus::Loading;
session->snapshot.error.clear();
session->loadedPath = false;
} else if (session->running.load()) {
session->snapshot.status = SessionStatus::Idle;
}
@@ -1181,6 +1188,19 @@ void runEventLoop(const std::shared_ptr<Session>& session)
break;
}
if (propertyName == "eof-reached" &&
property->format == MPV_FORMAT_FLAG &&
property->data) {
const bool eofReached =
*static_cast<int*>(property->data) != 0;
if (eofReached &&
session->running.load() &&
session->loadedPath) {
session->snapshot.status = SessionStatus::Ended;
}
break;
}
if (propertyName == "volume" &&
property->format == MPV_FORMAT_DOUBLE &&
property->data) {
@@ -1694,6 +1714,7 @@ Napi::Value CreateSession(const Napi::CallbackInfo& info)
"video-aspect-override",
MPV_FORMAT_STRING
);
mpv_observe_property(session->handle, 11, "eof-reached", MPV_FORMAT_FLAG);
session->running.store(true);
session->eventThread = std::thread(runEventLoop, session);
@@ -31,6 +31,7 @@ enum class SessionStatus {
Loading,
Playing,
Paused,
Ended,
Error,
Closed,
};
@@ -95,6 +96,8 @@ std::string toStatusString(SessionStatus status)
return "playing";
case SessionStatus::Paused:
return "paused";
case SessionStatus::Ended:
return "ended";
case SessionStatus::Error:
return "error";
case SessionStatus::Closed:
@@ -518,6 +521,19 @@ void runEventLoop(std::shared_ptr<Session> session)
session->snapshot.error = endFile->error < 0
? mpv_error_string(endFile->error)
: "Embedded MPV playback ended with an error.";
} else if (
endFile &&
endFile->reason == MPV_END_FILE_REASON_EOF &&
session->running.load()) {
session->snapshot.status = SessionStatus::Ended;
} else if (
endFile &&
endFile->reason == MPV_END_FILE_REASON_REDIRECT &&
session->running.load()) {
session->snapshot.status = SessionStatus::Loading;
session->snapshot.error.clear();
} else if (session->running.load()) {
session->snapshot.status = SessionStatus::Idle;
}
break;
}
@@ -536,10 +552,22 @@ void runEventLoop(std::shared_ptr<Session> session)
*static_cast<double*>(property->data);
} else if (name == "pause" && property->format == MPV_FORMAT_FLAG) {
const bool paused = *static_cast<int*>(property->data) != 0;
session->snapshot.status = paused
? SessionStatus::Paused
: SessionStatus::Playing;
session->snapshot.error.clear();
if (
session->snapshot.status != SessionStatus::Loading &&
session->snapshot.status != SessionStatus::Ended &&
session->snapshot.status != SessionStatus::Error
) {
session->snapshot.status = paused
? SessionStatus::Paused
: SessionStatus::Playing;
session->snapshot.error.clear();
}
} else if (name == "eof-reached" && property->format == MPV_FORMAT_FLAG) {
const bool eofReached =
*static_cast<int*>(property->data) != 0;
if (eofReached && session->running.load()) {
session->snapshot.status = SessionStatus::Ended;
}
} else if (name == "volume" && property->format == MPV_FORMAT_DOUBLE) {
session->snapshot.volumePercent =
*static_cast<double*>(property->data);
@@ -748,6 +776,7 @@ Napi::Value CreateSession(const Napi::CallbackInfo& info)
"video-aspect-override",
MPV_FORMAT_STRING
);
mpv_observe_property(session->handle, 11, "eof-reached", MPV_FORMAT_FLAG);
session->running.store(true);
session->eventThread = std::thread(runEventLoop, session);
@@ -53,6 +53,27 @@ describe('Embedded MPV native source recording invariants', () => {
throw new Error(`Unable to read ${name} body.`);
}
function eventCase(
source: string,
eventName: string,
nextEventName: string
): string {
const eventLoopBodyStart = source.indexOf('void runEventLoop(');
expect(eventLoopBodyStart).toBeGreaterThanOrEqual(0);
const eventCaseStart = source.indexOf(
`case ${eventName}`,
eventLoopBodyStart
);
expect(eventCaseStart).toBeGreaterThanOrEqual(0);
const nextCaseStart = source.indexOf(
`case ${nextEventName}`,
eventCaseStart
);
expect(nextCaseStart).toBeGreaterThan(eventCaseStart);
return source.slice(eventCaseStart, nextCaseStart);
}
it('tracks LoadPlayback recording auto-stop replies through the reconciler', () => {
const body = functionBody('LoadPlayback');
@@ -72,23 +93,96 @@ describe('Embedded MPV native source recording invariants', () => {
it('maps successful MPV end-file events to an ended session status', () => {
expect(nativeSource).toContain('Ended,');
expect(nativeSource).toContain('case SessionStatus::Ended:');
expect(widCommonSource).toContain('Ended,');
expect(widCommonSource).toContain('case SessionStatus::Ended:');
const eventLoopBodyStart = nativeSource.indexOf('void runEventLoop(');
expect(eventLoopBodyStart).toBeGreaterThanOrEqual(0);
const endFileCaseStart = nativeSource.indexOf(
'case MPV_EVENT_END_FILE',
eventLoopBodyStart
const endFileCase = eventCase(
nativeSource,
'MPV_EVENT_END_FILE',
'MPV_EVENT_PROPERTY_CHANGE'
);
expect(endFileCaseStart).toBeGreaterThanOrEqual(0);
const nextCaseStart = nativeSource.indexOf(
'case MPV_EVENT_PROPERTY_CHANGE',
endFileCaseStart
);
expect(nextCaseStart).toBeGreaterThan(endFileCaseStart);
const endFileCase = nativeSource.slice(endFileCaseStart, nextCaseStart);
expect(endFileCase).toContain('SessionStatus::Ended');
expect(endFileCase).toContain('MPV_END_FILE_REASON_EOF');
const widEndFileCase = eventCase(
widCommonSource,
'MPV_EVENT_END_FILE',
'MPV_EVENT_PROPERTY_CHANGE'
);
expect(widEndFileCase).toContain('SessionStatus::Ended');
expect(widEndFileCase).toContain('MPV_END_FILE_REASON_EOF');
});
it('keeps MPV redirect end-file events in loading state', () => {
for (const endFileCase of [
eventCase(
nativeSource,
'MPV_EVENT_END_FILE',
'MPV_EVENT_PROPERTY_CHANGE'
),
eventCase(
widCommonSource,
'MPV_EVENT_END_FILE',
'MPV_EVENT_PROPERTY_CHANGE'
),
]) {
const redirectBranchStart = endFileCase.indexOf(
'MPV_END_FILE_REASON_REDIRECT'
);
expect(redirectBranchStart).toBeGreaterThanOrEqual(0);
const idleFallbackStart = endFileCase.indexOf(
'SessionStatus::Idle',
redirectBranchStart
);
expect(idleFallbackStart).toBeGreaterThan(redirectBranchStart);
const redirectBranch = endFileCase.slice(
redirectBranchStart,
idleFallbackStart
);
expect(redirectBranch).toContain('SessionStatus::Loading');
expect(redirectBranch).toContain('session->snapshot.error.clear();');
}
});
it('maps keep-open eof-reached property changes to an ended session status', () => {
expect(nativeSource).toContain(
'mpv_observe_property(session->handle, 11, "eof-reached", MPV_FORMAT_FLAG);'
);
expect(widCommonSource).toContain(
'mpv_observe_property(session->handle, 11, "eof-reached", MPV_FORMAT_FLAG);'
);
const nativeEofBranchStart = nativeSource.indexOf(
'propertyName == "eof-reached"'
);
expect(nativeEofBranchStart).toBeGreaterThanOrEqual(0);
const nativeVolumeBranchStart = nativeSource.indexOf(
'propertyName == "volume"',
nativeEofBranchStart
);
expect(nativeVolumeBranchStart).toBeGreaterThan(nativeEofBranchStart);
const nativeEofBranch = nativeSource.slice(
nativeEofBranchStart,
nativeVolumeBranchStart
);
expect(nativeEofBranch).toContain('SessionStatus::Ended');
expect(nativeEofBranch).toContain('session->loadedPath');
const widEofBranchStart = widCommonSource.indexOf(
'name == "eof-reached"'
);
expect(widEofBranchStart).toBeGreaterThanOrEqual(0);
const widVolumeBranchStart = widCommonSource.indexOf(
'name == "volume"',
widEofBranchStart
);
expect(widVolumeBranchStart).toBeGreaterThan(widEofBranchStart);
const widEofBranch = widCommonSource.slice(
widEofBranchStart,
widVolumeBranchStart
);
expect(widEofBranch).toContain('SessionStatus::Ended');
});
it('keeps Windows/Linux non-load async MPV command failures non-fatal', () => {
+3
View File
@@ -82,11 +82,14 @@ The renderer learns which features the loaded addon binary supports through the
- `ended` when the end-file reason is `MPV_END_FILE_REASON_EOF`
- `error` when MPV reports an end-file error
- `loading` when MPV reports `MPV_END_FILE_REASON_REDIRECT`, because playback continues with the redirected playlist contents
- `idle` for other successful end-file reasons such as replacement/stop
- `closed` only for dispose/manual teardown
Renderer autoplay must use `ended` only. It must not treat `closed`, `idle`, or `error` as a request to continue to the next episode.
The native addon also observes mpv's `eof-reached` property and maps a true value to `ended`. This is required because embedded sessions run with `keep-open=yes`; MPV can pause at EOF while keeping the file loaded, so relying only on `MPV_EVENT_END_FILE` can leave the renderer in a paused-at-end state and block series autoplay.
Series episode navigation is owned by the portal feature components and passed through the shared inline player to `EmbeddedMpvPlayerComponent`. The embedded MPV controls show `skip_previous` and `skip_next` buttons only as part of the embedded player control surface. The shared navigation payload contains `canPrevious`, `canNext`, and `autoplayEnabled`; the component disables previous/next at the current-season boundaries and guards the output handlers as well as the button disabled state.
Autoplay is enabled by default for series playback in embedded MPV. On `ended`, Xtream and Stalker series detail views start the next episode only when the current episode has a next item in the same season. Playback stops on the last episode of the current season. Previous always switches to the previous episode in the current season; it does not implement a restart-threshold behavior.