diff --git a/CLAUDE.md b/CLAUDE.md index 42cd4b7fe..16fc9a0e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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=` 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=` 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 diff --git a/apps/electron-backend/src/app/services/embedded-mpv-bounds.util.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-bounds.util.spec.ts new file mode 100644 index 000000000..03d3ee95a --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-bounds.util.spec.ts @@ -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 { + 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); + } + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-bounds.util.ts b/apps/electron-backend/src/app/services/embedded-mpv-bounds.util.ts new file mode 100644 index 000000000..7c7015dd1 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-bounds.util.ts @@ -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; +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts index 1f85402b2..0568a9f0c 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts @@ -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(); const mainWindowGetNativeWindowHandleMock = jest.fn(() => 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(); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts index d30ad596f..8142fb66b 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts @@ -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 { diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index 19feed60d..458368467 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -150,9 +150,10 @@ The flow is: native-view, it starts `mpv --wid=` 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 diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-format.utils.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-format.utils.spec.ts index 9b80e2dfb..0c250d405 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-format.utils.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-format.utils.spec.ts @@ -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, }); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-format.utils.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-format.utils.ts index 6678c291b..2e8635b6d 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-format.utils.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-format.utils.ts @@ -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), }; } diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.dpr.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.dpr.spec.ts new file mode 100644 index 000000000..79cf74df6 --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.dpr.spec.ts @@ -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 { + 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}`); +} diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.spec.ts index d2a60a690..b70512184 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.spec.ts @@ -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 ); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts index 36e6e11cb..f760bec97 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts @@ -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;