fix(playback): re-sync embedded MPV bounds on position-only layout shifts (#1476)

An ancestor re-layout can translate the player host without resizing it
(sidebar content settling, EPG panel loading below the player).
ResizeObserver reports size changes only and no DOM event observes
"position changed", so the native child window silently kept its stale
coordinates and rendered offset from the DOM stage.

The session controller now polls the host bounds every 500 ms while a
session is active, compares them against the last synced bounds with a
half-pixel tolerance, and re-syncs only on drift — idle cost is one
getBoundingClientRect per tick with no IPC. The interval is registered
via NgZone.runOutsideAngular and never re-enters the zone, so zone change
detection does not run every tick for the whole stream. Frame-copy
sessions skip the measurement entirely: the canvas is laid out by the DOM
and moves with the layout, so only the native-view child window can go
stale on a position-only shift.

Fixes #1428

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-08-27 08:35:34 +02:00
1 parent 069b8b3cc9
commit 7a3d5eae56
5 files changed
+346 -5

No files matched your search

@@ -0,0 +1,10 @@
---
type: fix
area: playback
issues: [1428]
---
The embedded MPV video no longer drifts out of its frame when the layout
shifts around it — for example when the channel sidebar or the EPG panel
finishes loading after playback has started. The player now notices such
moves and snaps the video back into place within half a second.
+1 -1
View File
@@ -970,7 +970,7 @@ app as a real argument, so it is not an option.
API. Radio's `<audio>` deliberately never blocks display sleep. Embedded
MPV holds its own blocker in `EmbeddedMpvNativeService`; external MPV/VLC
inhibit the screensaver themselves.
- 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. Renderer bounds are CSS pixels; the service converts them to native units in the main process (`embedded-mpv-bounds.util.ts`: × page zoom everywhere, × display scale on Windows/Linux whose child windows are positioned in physical pixels; frame-copy bounds stay unscaled), and the session controller re-syncs bounds when `devicePixelRatio` changes. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`.
- 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. Renderer bounds are CSS pixels; the service converts them to native units in the main process (`embedded-mpv-bounds.util.ts`: × page zoom everywhere, × display scale on Windows/Linux whose child windows are positioned in physical pixels; frame-copy bounds stay unscaled), and the session controller re-syncs bounds when `devicePixelRatio` changes and polls (500 ms, drift-gated) for position-only layout shifts that `ResizeObserver` cannot observe. 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
x64 + Windows; enabled via `Settings > Playback > Embedded MPV: frame-copy
engine` (restart required) or
+16
View File
@@ -667,6 +667,22 @@ re-armed `matchMedia('(resolution: …dppx)')` query and re-syncs bounds when
it changes; page zoom changes are covered by the same watch plus the ordinary
resize-driven syncs.
An ancestor re-layout can also translate the host **without resizing it** —
the channel sidebar's content settling, panels loading below the player.
`ResizeObserver` reports size changes only and no DOM event observes
"position changed", so before #1428 the native child window silently kept its
stale coordinates and rendered offset from the DOM stage. The session
controller therefore polls the host bounds every 500 ms while a session is
active, compares them against the last synced bounds with a half-pixel
tolerance, and schedules a re-sync only on drift — idle cost is one
`getBoundingClientRect` per tick with no IPC. The interval is registered
outside Angular's zone (a zone timer would run app-wide change detection
every tick for the whole stream) and never re-enters it, because the drift
path is rAF → `setEmbeddedMpvBounds` IPC and touches no Angular state.
Frame-copy skips the measurement entirely: its canvas is laid out by the
DOM and moves with the layout, so only the native-view child window can go
stale on a position-only shift.
### Controls ownership by engine
`EmbeddedMpvPlayerComponent` selects one control owner from
@@ -0,0 +1,257 @@
import { NgZone } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
EmbeddedMpvSession,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller';
/**
* An ancestor re-layout can translate the host element without resizing it
* (sidebar content settling, panels loading below the player). ResizeObserver
* reports size changes only and no resize/scroll event fires, so before the
* position poll the native child window silently kept its stale coordinates
* and rendered offset from the DOM stage (#1428). The controller now polls
* the host bounds at a low frequency and re-syncs only when they drift from
* the last synced bounds.
*/
describe('EmbeddedMpvSessionController position drift poll', () => {
let electron: {
platform: string;
getEmbeddedMpvSupport: jest.Mock;
prepareEmbeddedMpv: jest.Mock;
createEmbeddedMpvSession: jest.Mock;
loadEmbeddedMpvPlayback: jest.Mock;
disposeEmbeddedMpvSession: jest.Mock;
setEmbeddedMpvBounds: jest.Mock;
onEmbeddedMpvSessionUpdate: jest.Mock;
attachEmbeddedMpvFrameView?: jest.Mock;
detachEmbeddedMpvFrameView?: jest.Mock;
};
beforeEach(() => {
// See `waitFor`: the startup chain is drained on a virtual clock so a
// loaded machine cannot change the outcome.
jest.useFakeTimers();
electron = {
platform: 'linux',
getEmbeddedMpvSupport: jest
.fn()
.mockResolvedValue({ supported: true, platform: 'linux' }),
prepareEmbeddedMpv: jest
.fn()
.mockResolvedValue({ supported: true, platform: 'linux' }),
createEmbeddedMpvSession: jest
.fn()
.mockResolvedValue(createSession()),
loadEmbeddedMpvPlayback: jest.fn().mockResolvedValue(undefined),
disposeEmbeddedMpvSession: jest.fn().mockResolvedValue(undefined),
setEmbeddedMpvBounds: jest.fn().mockResolvedValue(undefined),
onEmbeddedMpvSessionUpdate: jest.fn(() => jest.fn()),
};
Object.defineProperty(window, 'electron', {
configurable: true,
value: electron,
});
Object.defineProperty(globalThis, 'ResizeObserver', {
configurable: true,
value: class MockResizeObserver {
observe = jest.fn();
disconnect = jest.fn();
},
});
Object.defineProperty(window, 'requestAnimationFrame', {
configurable: true,
value: (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(0), 0),
});
Object.defineProperty(window, 'cancelAnimationFrame', {
configurable: true,
value: (handle: number) => window.clearTimeout(handle),
});
TestBed.configureTestingModule({
providers: [EmbeddedMpvSessionController],
});
});
afterEach(() => {
TestBed.resetTestingModule();
delete (window as unknown as { electron?: unknown }).electron;
jest.useRealTimers();
jest.restoreAllMocks();
});
it('re-syncs when the host moves without resizing, and stays silent otherwise', async () => {
const rect = { left: 10, top: 20, width: 640, height: 360 };
const host = {
getBoundingClientRect: () => ({ ...rect }),
} as HTMLElement;
const controller = TestBed.inject(EmbeddedMpvSessionController);
const teardown = controller.startSession(host, createPlayback(), 0.5);
await waitFor(
() => controller.sessionId() === 'mpv-1',
'session to start'
);
// The post-create catch-up sync records the initial bounds.
await waitFor(
() => electron.setEmbeddedMpvBounds.mock.calls.length > 0,
'initial bounds sync'
);
electron.setEmbeddedMpvBounds.mockClear();
// A stationary host must not produce any IPC across several ticks.
await jest.advanceTimersByTimeAsync(2000);
expect(electron.setEmbeddedMpvBounds).not.toHaveBeenCalled();
// Position-only shift: getBoundingClientRect changes while nothing
// fires ResizeObserver, window resize, or scroll.
rect.left = 29;
await jest.advanceTimersByTimeAsync(600);
await waitFor(
() => electron.setEmbeddedMpvBounds.mock.calls.length > 0,
'bounds re-sync after position drift'
);
expect(electron.setEmbeddedMpvBounds).toHaveBeenLastCalledWith(
'mpv-1',
{ x: 29, y: 20, width: 640, height: 360 }
);
// Once synced, the settled position produces no further IPC.
electron.setEmbeddedMpvBounds.mockClear();
await jest.advanceTimersByTimeAsync(2000);
expect(electron.setEmbeddedMpvBounds).not.toHaveBeenCalled();
// Teardown stops the poll: later drift must not reach the bridge.
teardown();
rect.left = 100;
await jest.advanceTimersByTimeAsync(2000);
expect(electron.setEmbeddedMpvBounds).not.toHaveBeenCalled();
});
it('registers the poll outside the Angular zone', async () => {
// With zone change detection, a zone-registered interval would run
// app-wide change detection every 500 ms for the whole stream.
const zone = TestBed.inject(NgZone);
const runOutsideAngular = jest.spyOn(zone, 'runOutsideAngular');
const controller = TestBed.inject(EmbeddedMpvSessionController);
const teardown = controller.startSession(
createHost(),
createPlayback(),
0.5
);
await waitFor(
() => controller.sessionId() === 'mpv-1',
'session to start'
);
expect(runOutsideAngular).toHaveBeenCalled();
teardown();
});
it('skips drift measurement for the frame-copy engine', async () => {
// Frame-copy paints into a DOM canvas that moves with the layout, so
// a position-only shift needs no re-sync (size changes still arrive
// through ResizeObserver).
electron.getEmbeddedMpvSupport.mockResolvedValue({
supported: true,
platform: 'linux',
engine: 'frame-copy',
});
electron.attachEmbeddedMpvFrameView = jest
.fn()
.mockResolvedValue(true);
electron.detachEmbeddedMpvFrameView = jest.fn();
const rect = { left: 10, top: 20, width: 640, height: 360 };
const host = {
getBoundingClientRect: () => ({ ...rect }),
} as HTMLElement;
const controller = TestBed.inject(EmbeddedMpvSessionController);
await waitFor(
() => controller.support()?.engine === 'frame-copy',
'support to load'
);
const teardown = controller.startSession(host, createPlayback(), 0.5);
await waitFor(
() => controller.sessionId() === 'mpv-1',
'session to start'
);
await waitFor(
() => electron.setEmbeddedMpvBounds.mock.calls.length > 0,
'initial bounds sync'
);
electron.setEmbeddedMpvBounds.mockClear();
rect.left = 29;
await jest.advanceTimersByTimeAsync(2000);
expect(electron.setEmbeddedMpvBounds).not.toHaveBeenCalled();
teardown();
});
});
function createHost(): HTMLElement {
return {
getBoundingClientRect: () => ({
left: 10,
top: 20,
width: 640,
height: 360,
}),
} as HTMLElement;
}
function createPlayback(): ResolvedPortalPlayback {
return {
streamUrl: 'https://example.com/movie.mp4',
title: 'Example Movie',
};
}
function createSession(): EmbeddedMpvSession {
return {
id: 'mpv-1',
title: 'Example Movie',
streamUrl: 'https://example.com/movie.mp4',
status: 'playing',
positionSeconds: 0,
durationSeconds: null,
volume: 0.5,
audioTracks: [],
selectedAudioTrackId: null,
subtitleTracks: [],
selectedSubtitleTrackId: null,
playbackSpeed: 1,
aspectOverride: 'no',
recording: { active: false },
startedAt: '2026-08-23T00:00:00.000Z',
updatedAt: '2026-08-23T00:00:01.000Z',
};
}
/**
* Settle the controller's async startup chain on the fake clock, bounded by
* drain rounds rather than wall-clock time — under parallel Jest workers a
* real-timer deadline expired before the chain settled and failed at random.
*
* One millisecond per round, never zero: `waitForStartupPaint` nests rAF
* inside rAF, and a zero-delay timer scheduled from inside a timer callback is
* clamped to the next millisecond, so a 0ms advance strands the inner hop.
*/
async function waitFor(
condition: () => boolean,
description: string
): Promise<void> {
for (let round = 0; round < 100; round += 1) {
if (condition()) {
return;
}
await Promise.resolve();
await jest.advanceTimersByTimeAsync(1);
}
throw new Error(`Timed out waiting for ${description}`);
}
@@ -1,6 +1,7 @@
import {
DestroyRef,
Injectable,
NgZone,
computed,
effect,
inject,
@@ -30,6 +31,29 @@ export type EmbeddedMpvBoundsProvider = (
type ElectronBridge = Window['electron'];
/**
* Cadence of the host-position drift poll. 500 ms keeps a stuck native
* window user-invisible (it snaps back within half a second) while costing
* one getBoundingClientRect per tick and zero IPC while nothing moves.
*/
const POSITION_POLL_INTERVAL_MS = 500;
/**
* Sub-pixel measurement noise must not re-send bounds every tick: after a
* sync the next poll re-measures the same layout, so anything below half a
* CSS pixel is the same position. Real layout shifts move by whole pixels.
*/
const POSITION_POLL_EPSILON_PX = 0.5;
function boundsDiffer(a: EmbeddedMpvBounds, b: EmbeddedMpvBounds): boolean {
return (
Math.abs(a.x - b.x) > POSITION_POLL_EPSILON_PX ||
Math.abs(a.y - b.y) > POSITION_POLL_EPSILON_PX ||
Math.abs(a.width - b.width) > POSITION_POLL_EPSILON_PX ||
Math.abs(a.height - b.height) > POSITION_POLL_EPSILON_PX
);
}
@Injectable()
export class EmbeddedMpvSessionController {
readonly support = signal<EmbeddedMpvSupport | null>(null);
@@ -54,6 +78,7 @@ export class EmbeddedMpvSessionController {
);
private readonly destroyRef = inject(DestroyRef);
private readonly zone = inject(NgZone);
private readonly unsubscribeSessionUpdate?: () => void;
private boundsProvider: EmbeddedMpvBoundsProvider = (host) =>
@@ -123,16 +148,16 @@ export class EmbeddedMpvSessionController {
): () => void {
let disposed = false;
let activeSessionId: string | null = null;
let lastSyncedBounds: EmbeddedMpvBounds | null = null;
const syncBounds = () => {
if (!activeSessionId) {
return;
}
const bounds = this.boundsProvider(host);
lastSyncedBounds = bounds;
void window.electron
?.setEmbeddedMpvBounds(
activeSessionId,
this.boundsProvider(host)
)
?.setEmbeddedMpvBounds(activeSessionId, bounds)
.catch(() => undefined);
};
@@ -153,6 +178,38 @@ export class EmbeddedMpvSessionController {
window.addEventListener('resize', scheduleBoundsSync);
window.addEventListener('scroll', scheduleBoundsSync, true);
// ResizeObserver reports size changes only: an ancestor re-layout
// that translates the host without resizing it (sidebar content
// settling, panels loading below the player) moves the DOM while the
// native child window keeps its old coordinates, and no DOM event
// observes "position changed" (#1428). A low-frequency poll compares
// the measured bounds against the last synced ones and re-syncs only
// on drift, so the idle cost is one getBoundingClientRect per tick
// with no IPC.
//
// The interval runs outside Angular's zone: with zone change
// detection a zone-registered timer would run app-wide change
// detection every tick for the whole stream. It never re-enters the
// zone — the drift path is rAF → setEmbeddedMpvBounds IPC and
// touches no Angular state. Frame-copy paints into a DOM canvas
// that moves with the layout, so only native-view can go stale on a
// position-only shift; the poll skips the measurement there.
const positionPoll = this.zone.runOutsideAngular(() =>
window.setInterval(() => {
if (!activeSessionId || !lastSyncedBounds) {
return;
}
if (untracked(() => this.isFrameCopyEngine())) {
return;
}
if (
boundsDiffer(this.boundsProvider(host), lastSyncedBounds)
) {
scheduleBoundsSync();
}
}, POSITION_POLL_INTERVAL_MS)
);
// Page zoom and monitor DPI rescale the CSS→native-pixel mapping the
// backend applies to these bounds. Moving the window to a display
// with a different scale can keep the CSS layout identical (no
@@ -264,6 +321,7 @@ export class EmbeddedMpvSessionController {
resizeObserver.disconnect();
window.removeEventListener('resize', scheduleBoundsSync);
window.removeEventListener('scroll', scheduleBoundsSync, true);
window.clearInterval(positionPoll);
detachDprWatch?.();
detachDprWatch = null;