From 83db27ef12f76595b7b4a136c478502163d1a384 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 13 Jun 2026 10:19:51 +0200 Subject: [PATCH] fix(playback): detect embedded mpv keep-open eof (#1050) * fix(playback): detect embedded mpv keep-open eof * fix(playback): keep mpv redirects loading --- .../native/src/embedded_mpv.mm | 21 +++ .../native/src/embedded_mpv_wid_common.h | 37 +++++- .../embedded-mpv-native-source.spec.ts | 120 ++++++++++++++++-- docs/architecture/embedded-mpv-native.md | 3 + 4 files changed, 164 insertions(+), 17 deletions(-) diff --git a/apps/electron-backend/native/src/embedded_mpv.mm b/apps/electron-backend/native/src/embedded_mpv.mm index d44e3332f..23f46f114 100644 --- a/apps/electron-backend/native/src/embedded_mpv.mm +++ b/apps/electron-backend/native/src/embedded_mpv.mm @@ -1136,6 +1136,13 @@ void runEventLoop(const std::shared_ptr& 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) break; } + if (propertyName == "eof-reached" && + property->format == MPV_FORMAT_FLAG && + property->data) { + const bool eofReached = + *static_cast(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); diff --git a/apps/electron-backend/native/src/embedded_mpv_wid_common.h b/apps/electron-backend/native/src/embedded_mpv_wid_common.h index a1d2a7489..4e76478b5 100644 --- a/apps/electron-backend/native/src/embedded_mpv_wid_common.h +++ b/apps/electron-backend/native/src/embedded_mpv_wid_common.h @@ -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->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) *static_cast(property->data); } else if (name == "pause" && property->format == MPV_FORMAT_FLAG) { const bool paused = *static_cast(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(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(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); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts index 28c562b48..fd287922e 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts @@ -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', () => { diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index 0a1536426..bf6d38147 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -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.