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..1996b1567 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-bounds.util.spec.ts @@ -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 { + 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); + } + }); +}); 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..1c539bf70 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-bounds.util.ts @@ -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; +} 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..2b89e1888 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,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(); 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..2edaa58e7 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,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 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.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;