fix(playback): position embedded MPV native view correctly on scaled displays

Renderer bounds are measured in CSS pixels, but the native-view engines
position OS windows: SetWindowPos (win32) and XMoveResizeWindow (linux)
expect physical pixels, NSView setFrame (macOS) expects points. The raw
values landed the video toward the window's top-left corner at 1/scale of
its size on any display scale or page zoom other than 100%, windowed and
fullscreen alike.

The main process now converts native-view bounds (x page zoom everywhere,
x display scale factor on win32/linux) with edge-based rounding; frame-copy
bounds stay unscaled because the adapter owns its render scale. The session
controller re-syncs bounds when devicePixelRatio changes, covering moves to
a display with a different scale that keep the CSS layout identical.

Closes #1145

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-19 07:09:55 +02:00
1 parent c266eaa680
commit 5af6058134
8 files changed
+600 -11

No files matched your search

+1 -1
View File
@@ -616,7 +616,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
- Built-in web players: HTML5+hls.js, Video.js, and ArtPlayer
- 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 (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 frame-copy engine (experimental, macOS Apple Silicon + Linux
x64 + Windows; enabled via `Settings > Playback > Embedded MPV: frame-copy
engine` (restart required) or
@@ -0,0 +1,122 @@
import { EmbeddedMpvBounds } from '@iptvnator/shared/interfaces';
import {
NativeViewBoundsContext,
toNativeViewBounds,
} from './embedded-mpv-bounds.util';
const CSS_BOUNDS: EmbeddedMpvBounds = { x: 372, y: 60, width: 578, height: 330 };
function context(
overrides: Partial<NativeViewBoundsContext> = {}
): NativeViewBoundsContext {
return {
platform: 'linux',
zoomFactor: 1,
displayScaleFactor: 1,
...overrides,
};
}
describe('toNativeViewBounds', () => {
it('returns the input untouched at 100% zoom and 100% display scale', () => {
const result = toNativeViewBounds(CSS_BOUNDS, context());
expect(result).toBe(CSS_BOUNDS);
});
// Regression for #1145: CSS bounds were handed to XMoveResizeWindow as-is,
// so on a 140%-scaled Linux Mint desktop the mpv window landed at ~71% of
// the expected position and size, toward the window's top-left corner.
it('scales linux bounds by the display scale factor', () => {
const result = toNativeViewBounds(
CSS_BOUNDS,
context({ platform: 'linux', displayScaleFactor: 1.4 })
);
expect(result).toEqual({ x: 521, y: 84, width: 809, height: 462 });
});
it('scales win32 bounds by the display scale factor', () => {
const result = toNativeViewBounds(
{ x: 100, y: 50, width: 640, height: 360 },
context({ platform: 'win32', displayScaleFactor: 1.25 })
);
expect(result).toEqual({ x: 125, y: 63, width: 800, height: 450 });
});
it('combines page zoom with the display scale factor', () => {
const result = toNativeViewBounds(
{ x: 100, y: 50, width: 640, height: 360 },
context({
platform: 'win32',
zoomFactor: 1.2,
displayScaleFactor: 1.5,
})
);
expect(result).toEqual({ x: 180, y: 90, width: 1152, height: 648 });
});
it('ignores the display scale on macOS (NSView frames are in points)', () => {
const result = toNativeViewBounds(
CSS_BOUNDS,
context({ platform: 'darwin', displayScaleFactor: 2 })
);
expect(result).toBe(CSS_BOUNDS);
});
it('applies page zoom on macOS', () => {
const result = toNativeViewBounds(
{ x: 100, y: 50, width: 640, height: 360 },
context({
platform: 'darwin',
zoomFactor: 1.5,
displayScaleFactor: 2,
})
);
expect(result).toEqual({ x: 150, y: 75, width: 960, height: 540 });
});
it('keeps vertically adjacent rects seamless under fractional scales', () => {
// 42 × 1.25 and 153 × 1.25 both land on .5/.25 fractions: rounding
// x/y/width/height independently would misplace the shared edge by
// 1px, while edge-based rounding keeps the rects flush.
const scale = context({ displayScaleFactor: 1.25 });
const upper = toNativeViewBounds(
{ x: 0, y: 42, width: 500, height: 111 },
scale
);
const lower = toNativeViewBounds(
{ x: 0, y: 153, width: 500, height: 90 },
scale
);
expect(upper.y + upper.height).toBe(lower.y);
});
it('keeps hidden bounds offscreen and at least 1x1', () => {
const result = toNativeViewBounds(
{ x: -100000, y: -100000, width: 1, height: 1 },
context({ displayScaleFactor: 1.5 })
);
expect(result.x).toBeLessThanOrEqual(-100000);
expect(result.y).toBeLessThanOrEqual(-100000);
expect(result.width).toBeGreaterThanOrEqual(1);
expect(result.height).toBeGreaterThanOrEqual(1);
});
it('treats non-finite or non-positive factors as 100%', () => {
for (const zoomFactor of [Number.NaN, 0, -1, Number.POSITIVE_INFINITY]) {
expect(
toNativeViewBounds(
CSS_BOUNDS,
context({ zoomFactor, displayScaleFactor: zoomFactor })
)
).toBe(CSS_BOUNDS);
}
});
});
@@ -0,0 +1,57 @@
import { EmbeddedMpvBounds } from '@iptvnator/shared/interfaces';
export interface NativeViewBoundsContext {
platform: NodeJS.Platform;
/** Page zoom factor of the main window's webContents (1 = 100%). */
zoomFactor: number;
/** Scale factor of the display hosting the main window (1 = 96 dpi). */
displayScaleFactor: number;
}
/**
* Converts renderer-measured bounds (CSS pixels from
* getBoundingClientRect()) into the coordinate space the native-view
* engines position their OS windows in: physical pixels for the win32
* child HWND (SetWindowPos) and the linux child X11 window
* (XMoveResizeWindow), points — device-independent pixels — for the macOS
* NSView (setFrame). CSS pixels match points only at 100% page zoom and
* match physical pixels only at 100% page zoom AND 100% display scale, so
* every platform scales by the zoom factor and win32/linux additionally by
* the display scale factor (#1145).
*
* Edges are scaled before deriving width/height so rounding cannot open
* 1px seams between the native video window and the surrounding DOM UI.
*/
export function toNativeViewBounds(
bounds: EmbeddedMpvBounds,
context: NativeViewBoundsContext
): EmbeddedMpvBounds {
const scale = resolveNativeViewScale(context);
if (scale === 1) {
return bounds;
}
const left = Math.round(bounds.x * scale);
const top = Math.round(bounds.y * scale);
const right = Math.round((bounds.x + bounds.width) * scale);
const bottom = Math.round((bounds.y + bounds.height) * scale);
return {
x: left,
y: top,
width: Math.max(1, right - left),
height: Math.max(1, bottom - top),
};
}
function resolveNativeViewScale(context: NativeViewBoundsContext): number {
const zoomFactor = sanitizeFactor(context.zoomFactor);
if (context.platform === 'darwin') {
return zoomFactor;
}
return zoomFactor * sanitizeFactor(context.displayScaleFactor);
}
function sanitizeFactor(value: number): number {
return Number.isFinite(value) && value > 0 ? value : 1;
}
@@ -41,20 +41,29 @@ const appMock = {
commandLine: commandLineMock,
};
const screenGetDisplayMatchingMock = jest.fn();
jest.mock('electron', () => ({
app: appMock,
powerSaveBlocker: powerSaveBlockerMock,
screen: { getDisplayMatching: screenGetDisplayMatchingMock },
}));
const mainWindowSendMock = jest.fn();
const mainWindowWebContentsOnMock = jest.fn();
const mainWindowGetZoomFactorMock = jest.fn<number, []>();
const mainWindowGetNativeWindowHandleMock = jest.fn<Buffer, []>(() =>
Buffer.alloc(8)
);
const mainWindowMock = {
isDestroyed: () => false,
getNativeWindowHandle: mainWindowGetNativeWindowHandleMock,
webContents: { send: mainWindowSendMock, on: mainWindowWebContentsOnMock },
getBounds: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
webContents: {
send: mainWindowSendMock,
on: mainWindowWebContentsOnMock,
getZoomFactor: mainWindowGetZoomFactorMock,
},
};
jest.mock('../app', () => ({
@@ -155,6 +164,10 @@ describe('EmbeddedMpvNativeService power blocker', () => {
mainWindowGetNativeWindowHandleMock.mockReturnValue(Buffer.alloc(8));
mainWindowSendMock.mockReset();
mainWindowWebContentsOnMock.mockReset();
mainWindowGetZoomFactorMock.mockReset();
mainWindowGetZoomFactorMock.mockReturnValue(1);
screenGetDisplayMatchingMock.mockReset();
screenGetDisplayMatchingMock.mockReturnValue({ scaleFactor: 1 });
appMock.isPackaged = true;
tempDirs = [];
@@ -440,6 +453,95 @@ describe('EmbeddedMpvNativeService power blocker', () => {
});
});
describe('native view bounds scaling', () => {
afterEach(() => {
delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY;
});
it('converts CSS bounds to physical pixels for the native engine on scaled displays', () => {
// Regression for #1145: the win32/linux engines position their
// child window in physical pixels, so renderer CSS bounds must
// be multiplied by the display scale before reaching the addon.
Object.defineProperty(process, 'platform', { value: 'linux' });
screenGetDisplayMatchingMock.mockReturnValue({ scaleFactor: 1.5 });
addon.createSession.mockReturnValueOnce('s-scaled');
addon.getSessionSnapshot.mockReturnValue(snapshot('loading'));
const cssBounds = { x: 100, y: 40, width: 640, height: 360 };
service.createSession(cssBounds, '', 1);
service.setBounds('s-scaled', cssBounds);
const physicalBounds = { x: 150, y: 60, width: 960, height: 540 };
expect(addon.createSession).toHaveBeenCalledWith(
expect.any(Buffer),
physicalBounds,
'',
1
);
expect(addon.setBounds).toHaveBeenCalledWith(
's-scaled',
physicalBounds
);
});
it('applies page zoom but not the display scale on macOS', () => {
// NSView frames are in points (device-independent pixels): only
// the webContents zoom factor separates them from CSS pixels.
screenGetDisplayMatchingMock.mockReturnValue({ scaleFactor: 2 });
mainWindowGetZoomFactorMock.mockReturnValue(1.25);
addon.createSession.mockReturnValueOnce('s-zoom');
addon.getSessionSnapshot.mockReturnValue(snapshot('loading'));
service.createSession({ x: 0, y: 0, width: 100, height: 100 }, '', 1);
expect(addon.createSession).toHaveBeenCalledWith(
expect.any(Buffer),
{ x: 0, y: 0, width: 125, height: 125 },
'',
1
);
});
it('passes frame-copy bounds through unscaled', () => {
// The frame-copy engine paints into a DOM canvas laid out in CSS
// pixels; its adapter applies the display scale to the render
// size itself, so a second scaling pass here would double it.
Object.defineProperty(process, 'platform', { value: 'linux' });
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
mockIsFrameCopyRuntimeUsable.mockReturnValue(true);
mockGetFrameCopyRuntimeAvailability.mockReturnValue({
usable: true,
});
screenGetDisplayMatchingMock.mockReturnValue({ scaleFactor: 1.5 });
const frameCopyAddon = createMockAddon();
frameCopyAddon.createSession.mockReturnValueOnce('s-fc-bounds');
frameCopyAddon.getSessionSnapshot.mockReturnValue(
snapshot('loading')
);
(
service as unknown as { frameCopyAdapter: MockAddon }
).frameCopyAdapter = frameCopyAddon;
service.createSession(BOUNDS, '', 1);
service.setBounds('s-fc-bounds', BOUNDS);
expect(frameCopyAddon.createSession).toHaveBeenCalledWith(
Buffer.alloc(0),
BOUNDS,
'',
1
);
expect(frameCopyAddon.setBounds).toHaveBeenCalledWith(
's-fc-bounds',
BOUNDS
);
// Dispose while the frame-copy env is still set so teardown
// dispatches to the adapter that owns the session.
service.disposeSession('s-fc-bounds');
});
});
it('does not acquire a blocker for a loading session', () => {
startSession('s1', snapshot('loading'));
expect(powerSaveBlockerMock.start).not.toHaveBeenCalled();
@@ -27,6 +27,7 @@ import {
EMBEDDED_MPV_SESSION_UPDATE,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { toNativeViewBounds } from './embedded-mpv-bounds.util';
import { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter';
import {
getFrameCopyRuntimeAvailability,
@@ -201,6 +202,36 @@ export class EmbeddedMpvNativeService {
}
}
private getMainWindowZoomFactor(): number {
try {
if (!App.mainWindow || App.mainWindow.isDestroyed()) {
return 1;
}
return App.mainWindow.webContents.getZoomFactor();
} catch {
return 1;
}
}
/**
* Renderer bounds arrive in CSS pixels; the native-view engines position
* OS windows in physical pixels (win32/linux) or points (macOS), so at
* page zoom or display scale ≠ 100% the raw values land the video toward
* the window's top-left corner at a fraction of its size (#1145). The
* frame-copy engine must bypass this: it paints into a DOM canvas laid
* out in CSS pixels, and its adapter already applies the display scale
* to the render size itself.
*/
private scaleBoundsForNativeView(
bounds: EmbeddedMpvBounds
): EmbeddedMpvBounds {
return toNativeViewBounds(bounds, {
platform: process.platform,
zoomFactor: this.getMainWindowZoomFactor(),
displayScaleFactor: this.getMainWindowScaleFactor(),
});
}
private detectCapabilities(): EmbeddedMpvCapabilities {
if (this.isFrameCopyEngineActive()) {
return {
@@ -419,14 +450,15 @@ export class EmbeddedMpvNativeService {
// embed into the window at all. Derive the skip from the dispatched
// addon rather than re-evaluating the engine gate, so the two
// decisions cannot disagree.
const windowHandle =
this.frameCopyAdapter && addon === this.frameCopyAdapter
? Buffer.alloc(0)
: this.getMainWindowHandle();
const usesFrameCopyAddon =
this.frameCopyAdapter !== null && addon === this.frameCopyAdapter;
const windowHandle = usesFrameCopyAddon
? Buffer.alloc(0)
: this.getMainWindowHandle();
const startedAt = new Date().toISOString();
const sessionId = addon.createSession(
windowHandle,
bounds,
usesFrameCopyAddon ? bounds : this.scaleBoundsForNativeView(bounds),
title,
initialVolume
);
@@ -478,7 +510,13 @@ export class EmbeddedMpvNativeService {
setBounds(sessionId: string, bounds: EmbeddedMpvBounds): void {
this.assertEmbeddedMpvEnabled();
this.getAddon().setBounds(sessionId, bounds);
const addon = this.getAddon();
const usesFrameCopyAddon =
this.frameCopyAdapter !== null && addon === this.frameCopyAdapter;
addon.setBounds(
sessionId,
usesFrameCopyAddon ? bounds : this.scaleBoundsForNativeView(bounds)
);
}
setPaused(sessionId: string, paused: boolean): EmbeddedMpvSession | null {
+33 -3
View File
@@ -150,9 +150,10 @@ The flow is:
native-view, it starts `mpv --wid=<x11-window>` in a separate process with a
private JSON IPC socket. Frame-copy instead uses the per-session helper
described below.
9. Resize, scroll, and fullscreen changes are measured in Angular and sent
through bounds sync. Native-view uses them to align the platform host;
frame-copy uses them to resize helper rendering and the canvas frame source.
9. Resize, scroll, fullscreen, and devicePixelRatio changes are measured in
Angular and sent through bounds sync. Native-view uses them to align the
platform host; frame-copy uses them to resize helper rendering and the
canvas frame source.
10. Playback controls remain IPTVnator-owned Angular UI. Frame-copy uses the
shared `app-player-controls` overlay through
`EmbeddedMpvControlsAdapter`; native-view keeps its compositor-safe fixed
@@ -595,6 +596,35 @@ there is no `HIDDEN_BOUNDS`, popover cutout, or reserved dock height. Dialogs
and controls layer naturally over the canvas, while bounds sync still updates
the helper's render size.
### Coordinate spaces (CSS → native units)
The renderer measures bounds in CSS pixels (`getBoundingClientRect()`), but
the native-view engines position OS windows, not DOM nodes: the win32 child
`HWND` (`SetWindowPos`) and the Linux child X11 window (`XMoveResizeWindow`)
live in physical pixels, and the macOS `NSView` (`setFrame`) lives in points
(device-independent pixels). CSS values match points only at 100% page zoom
and match physical pixels only at 100% page zoom AND 100% display scale.
`EmbeddedMpvNativeService` therefore converts every native-view bounds payload
in the main process (`toNativeViewBounds` in `embedded-mpv-bounds.util.ts`):
all platforms scale by the webContents zoom factor, win32/linux additionally
by the scale factor of the display hosting the window. Edges are scaled
before deriving width/height so rounding cannot open 1px seams against the
surrounding DOM UI. Skipping this conversion is issue #1145: on scaled
displays (Windows 125%, Linux fractional scaling, HiDPI TVs) the video landed
toward the window's top-left corner at `1/scale` of its size, in windowed and
fullscreen mode alike.
Frame-copy bounds bypass the conversion: the canvas is laid out by the DOM in
CSS pixels, and the frame-copy adapter already multiplies the render size by
the display scale factor itself.
Because a monitor change can rescale this mapping without resizing the host
element (moving the window to a display with a different scale keeps the DIP
layout), the session controller also watches `devicePixelRatio` through a
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.
### Controls ownership by engine
`EmbeddedMpvPlayerComponent` selects one control owner from
@@ -0,0 +1,213 @@
import { TestBed } from '@angular/core/testing';
import {
EmbeddedMpvSession,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller';
/**
* Moving the window to a display with a different scale (or changing the
* page zoom) rescales the CSS→native mapping the backend applies to bounds,
* without necessarily resizing the host element. The controller watches
* devicePixelRatio through a re-armed matchMedia query and re-syncs bounds
* when it changes (#1145).
*/
describe('EmbeddedMpvSessionController devicePixelRatio watch', () => {
class FakeMediaQueryList {
private readonly listeners = new Set<() => void>();
constructor(readonly media: string) {}
addEventListener(_type: 'change', listener: () => void): void {
this.listeners.add(listener);
}
removeEventListener(_type: 'change', listener: () => void): void {
this.listeners.delete(listener);
}
fire(): void {
for (const listener of [...this.listeners]) {
listener();
}
}
get listenerCount(): number {
return this.listeners.size;
}
}
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;
};
let mediaQueries: FakeMediaQueryList[];
beforeEach(() => {
electron = {
platform: 'win32',
getEmbeddedMpvSupport: jest
.fn()
.mockResolvedValue({ supported: true, platform: 'win32' }),
prepareEmbeddedMpv: jest
.fn()
.mockResolvedValue({ supported: true, platform: 'win32' }),
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,
});
mediaQueries = [];
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: (media: string) => {
const query = new FakeMediaQueryList(media);
mediaQueries.push(query);
return query;
},
});
Object.defineProperty(window, 'devicePixelRatio', {
configurable: true,
value: 1,
});
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;
delete (window as unknown as { matchMedia?: unknown }).matchMedia;
jest.restoreAllMocks();
});
it('re-syncs bounds and re-arms the query when devicePixelRatio changes', async () => {
const controller = TestBed.inject(EmbeddedMpvSessionController);
const teardown = controller.startSession(
createHost(),
createPlayback(),
0.5
);
await waitFor(
() => controller.sessionId() === 'mpv-1',
'session to start'
);
expect(mediaQueries.length).toBe(1);
expect(mediaQueries[0].media).toBe('(resolution: 1dppx)');
electron.setEmbeddedMpvBounds.mockClear();
// Simulate a move to a 150%-scaled display.
Object.defineProperty(window, 'devicePixelRatio', {
configurable: true,
value: 1.5,
});
mediaQueries[0].fire();
await waitFor(
() => electron.setEmbeddedMpvBounds.mock.calls.length > 0,
'bounds re-sync after dPR change'
);
// The stale query is released and a new one tracks the new ratio.
expect(mediaQueries[0].listenerCount).toBe(0);
expect(mediaQueries.length).toBe(2);
expect(mediaQueries[1].media).toBe('(resolution: 1.5dppx)');
// A second display change must fire through the re-armed query.
electron.setEmbeddedMpvBounds.mockClear();
mediaQueries[1].fire();
await waitFor(
() => electron.setEmbeddedMpvBounds.mock.calls.length > 0,
'bounds re-sync after second dPR change'
);
teardown();
expect(mediaQueries[mediaQueries.length - 1].listenerCount).toBe(0);
});
});
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-07-19T00:00:00.000Z',
updatedAt: '2026-07-19T00:00:01.000Z',
};
}
async function waitFor(
condition: () => boolean,
description: string
): Promise<void> {
const deadline = Date.now() + 1_000;
while (Date.now() < deadline) {
if (condition()) {
return;
}
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
throw new Error(`Timed out waiting for ${description}`);
}
@@ -151,6 +151,31 @@ export class EmbeddedMpvSessionController {
window.addEventListener('resize', scheduleBoundsSync);
window.addEventListener('scroll', scheduleBoundsSync, true);
// 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
// resize, no ResizeObserver), so watch devicePixelRatio through a
// re-armed matchMedia query and re-sync when it changes.
let detachDprWatch: (() => void) | null = null;
const watchDevicePixelRatio = () => {
detachDprWatch?.();
detachDprWatch = null;
const query = window.matchMedia?.(
`(resolution: ${window.devicePixelRatio}dppx)`
);
if (!query) {
return;
}
const onChange = () => {
watchDevicePixelRatio();
scheduleBoundsSync();
};
query.addEventListener('change', onChange);
detachDprWatch = () =>
query.removeEventListener('change', onChange);
};
watchDevicePixelRatio();
const create = async () => {
this.session.set(createLoadingSession(playback, initialVolume));
await waitForStartupPaint();
@@ -237,6 +262,8 @@ export class EmbeddedMpvSessionController {
resizeObserver.disconnect();
window.removeEventListener('resize', scheduleBoundsSync);
window.removeEventListener('scroll', scheduleBoundsSync, true);
detachDprWatch?.();
detachDprWatch = null;
if (this.activeBoundsSync === scheduleBoundsSync) {
this.activeBoundsSync = null;