mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
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:
1 parent
c266eaa680
commit
5af6058134
8 files changed
+600
-11
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
+213
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user