fix(embedded-mpv): isolate legacy feedback on handoff

This commit is contained in:
4gray committed 2026-07-16 16:58:26 +02:00
1 parent bb8b01efad
commit 7887d10828
7 files changed
+57 -11

No files matched your search

+1 -1
View File
@@ -617,7 +617,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
- Built-in HTML5 player with HLS.js or Video.js
- External players: MPV, VLC (via IPC to Electron backend)
- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=<x11-window>` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`.
- Embedded MPV frame-copy engine (experimental, macOS Apple Silicon + Linux + Windows; enabled via `Settings > Playback > Embedded MPV: frame-copy engine` (restart required) or `IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` on top of the embedded MPV experiment flag): a per-session helper renders mpv offscreen at viewport size (headless CGL on macOS, headless EGL on Linux, WGL against a hidden window on Windows) and publishes BGRA frames into a shm ring (POSIX shm; a `Local\` named file mapping on Windows); the preload frame pump uploads them onto a renderer `<canvas data-embedded-mpv-frame>`, so controls/dialogs are ordinary DOM above the video. Frame-copy is the first runtime consumer of shared `app-player-controls`; a component-scoped `EmbeddedMpvControlsAdapter` owns its DOM interactions, shortcuts, fullscreen, and correlated recording state, while native-view retains the legacy fixed dock. Stored and explicit opt-ins relax the sandbox only while the base embedded-MPV feature is enabled and a platform-supported packaged runtime contains both the regular-file helper (`iptvnator_mpv_helper` / `.exe`) and readable regular frame-reader addon; packaged discovery is restricted to packaged resources. A disabled base experiment keeps embedded MPV unavailable with the sandbox intact, while a missing, mode-stripped, or incomplete frame-copy runtime falls back to the native engine without relaxing the sandbox. On Linux the engine is dev-build-only for now: the helper links system libmpv (build deps: `libmpv-dev`, `libegl-dev`, `libgl-dev`, `libopengl-dev`, `libgbm-dev`) and is stripped from packages until bundled-runtime staging lands. On Windows the helper links vendored libmpv and package validation requires the exact MPV DLL named in the helper's PE import table beside the executable. Adapter: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts`; helper: `apps/electron-backend/native/helper/`; details in `docs/architecture/embedded-mpv-native.md` ("Frame-Copy Engine").
- Embedded MPV frame-copy engine (experimental, macOS Apple Silicon + Linux + Windows; enabled via `Settings > Playback > Embedded MPV: frame-copy engine` (restart required) or `IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` on top of the embedded MPV experiment flag): a per-session helper renders mpv offscreen at viewport size (headless CGL on macOS, headless EGL on Linux, WGL against a hidden window on Windows) and publishes BGRA frames into a shm ring (POSIX shm; a `Local\` named file mapping on Windows); the preload frame pump uploads them onto a renderer `<canvas data-embedded-mpv-frame>`, so controls/dialogs are ordinary DOM above the video. Frame-copy is the first runtime consumer of shared `app-player-controls`: `PlayerControlsComponent` and its surface/shortcut/fullscreen collaborators own the DOM UI interactions, while the component-scoped `EmbeddedMpvControlsAdapter` maps session state and commands and coordinates correlated recording state; native-view retains the legacy fixed dock. Stored and explicit opt-ins relax the sandbox only while the base embedded-MPV feature is enabled and a platform-supported packaged runtime contains both the regular-file helper (`iptvnator_mpv_helper` / `.exe`) and readable regular frame-reader addon; packaged discovery is restricted to packaged resources. A disabled base experiment keeps embedded MPV unavailable with the sandbox intact, while a missing, mode-stripped, or incomplete frame-copy runtime falls back to the native engine without relaxing the sandbox. On Linux the engine is dev-build-only for now: the helper links system libmpv (build deps: `libmpv-dev`, `libegl-dev`, `libgl-dev`, `libopengl-dev`, `libgbm-dev`) and is stripped from packages until bundled-runtime staging lands. On Windows the helper links vendored libmpv and package validation requires the exact MPV DLL named in the helper's PE import table beside the executable. Backend process adapter: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts`; shared-controls adapter: `libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts`; helper: `apps/electron-backend/native/helper/`; details in `docs/architecture/embedded-mpv-native.md` ("Frame-Copy Engine").
- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and a default-off web rollout token. Embedded MPV frame-copy consumes it through `EmbeddedMpvControlsAdapter`; the host selects exactly one UI, so native-view retains its compositor-safe dock. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and engine/session handoff cancels stale recording/timer ownership. HTML5/hls.js, Video.js, and ArtPlayer remain unwired with their existing skins. Contract: `docs/architecture/player-controls-contract.md`.
**VOD/Series Detail Pages (two-state layout)**:
+5 -3
View File
@@ -434,9 +434,11 @@ the helper's render size.
recording adapter is inert outside frame-copy.
An engine handoff clears native controls-hide/click/volume timers and closes
native menus when frame-copy takes ownership. It also changes the shared
recording owner, which cancels pending operations and feedback. This prevents
both systems from acting on the same session.
native menus when frame-copy takes ownership. Legacy feedback is cleared on
each engine transition and its overlay is never rendered for frame-copy, so a
late native command completion cannot paint above shared controls. The handoff
also changes the shared recording owner, which cancels pending operations and
feedback. This prevents both systems from acting on the same session.
Both engines keep the component's `fullscreenchange` listener because
fullscreen changes require bounds sync. Frame-copy's shared
@@ -39,8 +39,8 @@ the session, playback, engine, or component changes.
## Why this exists
Each playback engine currently owns both media integration and controls UI.
That makes behavior drift likely and makes a controls redesign depend on each
Historically each playback engine owned both media integration and controls UI.
That made behavior drift likely and made a controls redesign depend on each
engine's implementation details.
The shared contract separates:
@@ -291,7 +291,8 @@ first runtime consumer of `app-player-controls`, backed by a component-scoped
`EmbeddedMpvControlsAdapter`.
The shared controls receive the whole player root as their DOM surface. Turning
`showControls` off detaches surface interaction and shortcut ownership.
`showControls` off detaches surface interaction and playback-shortcut
ownership; Escape remains available for generic popover dismissal.
Backdrop-bearing overlays disable playback shortcuts. Fullscreen uses the DOM
Fullscreen API on that root, while the Embedded MPV component continues bounds
sync so the helper renders at the current viewport size.
@@ -82,7 +82,7 @@
</div>
}
@if (feedback.current(); as feedback) {
@if (!isFrameCopyEngine() && feedback.current(); as feedback) {
<div
class="embedded-mpv-player__feedback"
[attr.data-feedback-key]="feedback.key"
@@ -291,6 +291,38 @@ describe('EmbeddedMpvPlayerComponent shared controls host', () => {
expect(component.menus.volumeOpen()).toBe(true);
});
it('does not render or revive legacy feedback across a frame-copy handoff', () => {
jest.useFakeTimers();
const { fixture, component, controller } = render('native');
component.feedback.flash('forward_10', '+10s', 5000);
fixture.detectChanges();
expect(
fixture.debugElement.query(By.css('.embedded-mpv-player__feedback'))
).not.toBeNull();
controller.support.set(support('frame-copy'));
fixture.detectChanges();
expect(
fixture.debugElement.query(By.css('.embedded-mpv-player__feedback'))
).toBeNull();
component.feedback.flash('error_outline', 'Late native failure', 5000);
fixture.detectChanges();
expect(
fixture.debugElement.query(By.css('.embedded-mpv-player__feedback'))
).toBeNull();
controller.support.set(support('native'));
fixture.detectChanges();
expect(
fixture.debugElement.query(By.css('.embedded-mpv-player__feedback'))
).toBeNull();
});
it('leaves legacy pointer and popover state untouched on frame-copy', () => {
const { fixture, component } = render();
component.controlsVisible.set(false);
@@ -487,10 +487,13 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
});
effect(() => {
if (!this.isFrameCopyEngine()) {
return;
}
const isFrameCopyEngine = this.isFrameCopyEngine();
untracked(() => {
this.feedback.clear();
this.setRecordingMessage(null);
if (!isFrameCopyEngine) {
return;
}
this.clearControlsHideTimer();
this.clearVolumeCloseTimer();
this.clearViewportClickTimer();
@@ -97,6 +97,14 @@ export class EmbeddedMpvFeedback {
}, durationMs);
}
clear(): void {
if (this.timer !== null) {
clearTimeout(this.timer);
this.timer = null;
}
this.current.set(null);
}
dispose(): void {
if (this.timer !== null) {
clearTimeout(this.timer);