mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(playback): position embedded MPV native view correctly on scaled displays (#1206)
* 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> * fix(playback): keep CSS bounds unrounded until native scaling Review feedback on #1206: measureBounds() rounded the CSS edges in the renderer, before the main-process CSS-to-native conversion, so fractional layout positions could drift by a pixel per scale factor (a 10.49px edge at 200% must land on 21 physical px, not 20). The renderer now sends raw getBoundingClientRect() edges and rounding happens exactly once, after scaling. Also pins process.platform explicitly in the macOS wiring test instead of relying on the suite default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2f8aee72df
commit
b719ed23cd
11 files changed
+637
-19
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,133 @@
|
||||
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('rounds fractional CSS edges only after scaling', () => {
|
||||
// A 10.49px CSS edge at 200% renders at 21 physical pixels; edges
|
||||
// rounded before scaling would send 20 and shift the video by 1px.
|
||||
const result = toNativeViewBounds(
|
||||
{ x: 10.49, y: 0.5, width: 100.02, height: 50 },
|
||||
context({ platform: 'win32', displayScaleFactor: 2 })
|
||||
);
|
||||
|
||||
expect(result).toEqual({ x: 21, y: 1, width: 200, height: 100 });
|
||||
});
|
||||
|
||||
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,60 @@
|
||||
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).
|
||||
*
|
||||
* Bounds arrive with unrounded CSS edges and are rounded exactly once here,
|
||||
* after scaling: edges first, then width/height derived from them. Rounding
|
||||
* any earlier (or per-field) lets fractional CSS layouts drift by a pixel
|
||||
* per scale factor and 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,96 @@ 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.
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
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,37 @@ 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. The renderer sends
|
||||
unrounded CSS edges (`measureBounds` does not round) and the conversion
|
||||
rounds exactly once, after scaling — edges first, width/height derived from
|
||||
them — so fractional CSS layouts and fractional scales 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
|
||||
|
||||
@@ -59,7 +59,10 @@ describe('embedded MPV format utilities', () => {
|
||||
expect(volumeLabel(0.755)).toBe('Volume 76%');
|
||||
});
|
||||
|
||||
it('rounds host bounds and keeps minimum native view dimensions', () => {
|
||||
it('preserves fractional host edges and keeps minimum native view dimensions', () => {
|
||||
// Rounding happens once in the main process, after CSS→native
|
||||
// scaling — pre-rounded edges would drift by up to 1px per scale
|
||||
// factor on scaled displays.
|
||||
const host = {
|
||||
getBoundingClientRect: () => ({
|
||||
left: 10.4,
|
||||
@@ -70,8 +73,8 @@ describe('embedded MPV format utilities', () => {
|
||||
} as HTMLElement;
|
||||
|
||||
expect(measureBounds(host)).toEqual({
|
||||
x: 10,
|
||||
y: 21,
|
||||
x: 10.4,
|
||||
y: 20.6,
|
||||
width: 1,
|
||||
height: 1,
|
||||
});
|
||||
|
||||
@@ -120,12 +120,19 @@ export function persistVolume(value: number): void {
|
||||
localStorage.setItem('volume', String(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures the host element in CSS pixels without rounding. The main process
|
||||
* converts these bounds to native units (page zoom × display scale) and
|
||||
* rounds exactly once, after scaling — pre-rounding here would bake up to
|
||||
* ±0.5px of CSS error that the scale factor then amplifies into visible
|
||||
* off-by-one seams (e.g. a 10.49px edge at 200% renders at 21px, not 20px).
|
||||
*/
|
||||
export function measureBounds(host: HTMLElement): EmbeddedMpvBounds {
|
||||
const rect = host.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.round(rect.left),
|
||||
y: Math.round(rect.top),
|
||||
width: Math.max(1, Math.round(rect.width)),
|
||||
height: Math.max(1, Math.round(rect.height)),
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
width: Math.max(1, rect.width),
|
||||
height: Math.max(1, rect.height),
|
||||
};
|
||||
}
|
||||
+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}`);
|
||||
}
|
||||
+3
-1
@@ -141,8 +141,10 @@ describe('EmbeddedMpvSessionController', () => {
|
||||
);
|
||||
|
||||
expect(electron.prepareEmbeddedMpv).toHaveBeenCalled();
|
||||
// Fractional CSS edges stay unrounded: the main process rounds once,
|
||||
// after converting them to native units.
|
||||
expect(electron.createEmbeddedMpvSession).toHaveBeenCalledWith(
|
||||
{ x: 11, y: 21, width: 640, height: 360 },
|
||||
{ x: 10.6, y: 20.5, width: 640, height: 360 },
|
||||
'Example Movie',
|
||||
0.7
|
||||
);
|
||||
|
||||
@@ -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