Skip to content

Commit 2a85165

Browse files
larsemigclaude
andcommitted
feat(embedded-mpv): real macOS native fullscreen for the embedded player
Use native NSWindow fullscreen instead of DOM fullscreen for the embedded player. Extend the player-controls contract with a fullscreen delegate (PlayerFullscreenController + ControlsFullscreenBinding) and add window-state IPC so OS-initiated exits (green button, Ctrl+Cmd+F, ESC) reconcile with the in-app fullscreen state. Review-driven hardening: the deferred window-fullscreen request is now gated by a generation counter + destroyed flag so it becomes a no-op if fullscreen is exited/re-toggled or the component is torn down before the two requestAnimationFrame ticks run (pending rAF ids are cancelled in ngOnDestroy); the fill snap is awaited (raced with a ~150ms timeout so a hung IPC can not block fullscreen) before scheduling the window request; and the fullscreen controller now requires BOTH the setMainWindowFullScreen and setEmbeddedMpvFill bridges in canToggle (older bridges fall back to the built-in DOM fullscreen path). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 6a2c1c0 commit 2a85165

21 files changed

Lines changed: 613 additions & 56 deletions

apps/electron-backend/native/src/embedded_mpv.mm

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,21 @@ - (NSView*)hitTest:(NSPoint)point
121121
dispatch_queue_t renderQueue = nullptr;
122122
std::thread eventThread;
123123
std::atomic<bool> running{false};
124+
// When true, the container view fills the host content view via an
125+
// autoresizing mask and rides the window's resize animation (the fullscreen
126+
// grow). Per-frame bounds syncs are ignored so a snapped setFrame can't
127+
// interrupt that animation.
128+
std::atomic<bool> fillMode{false};
129+
// When true, mpv frame rendering is paused. Used during the fullscreen grow:
130+
// active rendering into the (stale-size) GL drawable mid-animation snaps the
131+
// image, but freezing it lets the last frame scale smoothly with the view
132+
// (the same reason a PAUSED video grows cleanly). Resumed when the animation
133+
// settles.
134+
std::atomic<bool> renderFrozen{false};
135+
// Bumped on every setSessionFill call so a deferred unfreeze timer can detect
136+
// it was superseded by a newer fill cycle (rapid enter/exit/enter) and skip,
137+
// instead of unfreezing mid-way through the new transition.
138+
std::atomic<uint64_t> fillGeneration{0};
124139
std::atomic<bool> renderScheduled{false};
125140
std::atomic<bool> renderNeeded{false};
126141
std::atomic<uint64_t> renderedFrameCount{0};
@@ -372,6 +387,12 @@ void setSessionFrame(const std::shared_ptr<Session>& session, double x, double y
372387
return;
373388
}
374389

390+
// In fill mode the autoresizing mask owns the frame and animates it with
391+
// the window; a snapped setFrame here would fight that, so skip the sync.
392+
if (session->fillMode.load()) {
393+
return;
394+
}
395+
375396
NSRect parentBounds = [session->parentView bounds];
376397
const CGFloat frameWidth = static_cast<CGFloat>(std::max(width, 0.0));
377398
const CGFloat frameHeight = static_cast<CGFloat>(std::max(height, 0.0));
@@ -386,14 +407,93 @@ void setSessionFrame(const std::shared_ptr<Session>& session, double x, double y
386407
fromView:session->parentView];
387408
}
388409

410+
// Snap the frame with NO implicit Core Animation. The container view is
411+
// layer-backed, so a plain setFrame: animates position+size (~0.25s) —
412+
// which made the surface visibly slide/grow on fullscreen and lag during
413+
// window resizes. Disabling actions makes it track the bounds instantly.
414+
[CATransaction begin];
415+
[CATransaction setDisableActions:YES];
389416
[session->containerView setFrame:frameRect];
417+
[CATransaction commit];
418+
if (session->openGLContext) {
419+
[session->openGLContext update];
420+
} else {
421+
[session->containerView setNeedsDisplay:YES];
422+
}
423+
});
424+
425+
updateRenderSurfaceMetrics(session);
426+
requestRender(session);
427+
}
428+
429+
// Approximate duration of the macOS fullscreen transition. Rendering is frozen
430+
// for this long during the transition so the last frame scales smoothly with the
431+
// view, then resumed at the final size.
432+
constexpr double kFillAnimationSeconds = 0.4;
433+
434+
// Toggle fullscreen "fill" mode. On enable, snap the container to cover the host
435+
// content view NOW, give it a flexible autoresizing mask so it grows in lockstep
436+
// with the window's animated resize, and FREEZE rendering for the animation so
437+
// the last frame scales smoothly (active rendering into the stale-size drawable
438+
// would snap). On disable, drop the mask and resume; the next bounds sync
439+
// restores the inline rect.
440+
void setSessionFill(const std::shared_ptr<Session>& session, bool fill)
441+
{
442+
session->fillMode.store(fill);
443+
// Freeze before the snap so no in-flight frame draws at the new size.
444+
session->renderFrozen.store(fill);
445+
// Mark this fill cycle so a pending unfreeze timer from a previous cycle
446+
// (e.g. a fast enter/exit/enter) knows it is stale and bows out.
447+
const uint64_t generation = session->fillGeneration.fetch_add(1) + 1;
448+
runOnMainSync(^{
449+
if (!session->hostView || !session->containerView) {
450+
return;
451+
}
452+
if (fill) {
453+
session->containerView.autoresizingMask =
454+
NSViewWidthSizable | NSViewHeightSizable;
455+
[CATransaction begin];
456+
[CATransaction setDisableActions:YES];
457+
[session->containerView setFrame:[session->hostView bounds]];
458+
[CATransaction commit];
459+
} else {
460+
session->containerView.autoresizingMask = NSViewNotSizable;
461+
}
390462
if (session->openGLContext) {
391463
[session->openGLContext update];
392464
} else {
393465
[session->containerView setNeedsDisplay:YES];
394466
}
395467
});
396468

469+
if (fill) {
470+
// Resume rendering once the transition has settled, re-reading the (now
471+
// final) view size so mpv draws crisply at full resolution.
472+
auto sessionCopy = session;
473+
dispatch_after(
474+
dispatch_time(
475+
DISPATCH_TIME_NOW,
476+
static_cast<int64_t>(kFillAnimationSeconds * NSEC_PER_SEC)
477+
),
478+
dispatch_get_main_queue(),
479+
^{
480+
if (!sessionCopy->fillMode.load() ||
481+
sessionCopy->fillGeneration.load() != generation) {
482+
// Exited fullscreen, or a newer fill cycle superseded this one,
483+
// before the animation settled.
484+
return;
485+
}
486+
if (sessionCopy->openGLContext) {
487+
[sessionCopy->openGLContext update];
488+
}
489+
updateRenderSurfaceMetrics(sessionCopy);
490+
sessionCopy->renderFrozen.store(false);
491+
requestRender(sessionCopy);
492+
}
493+
);
494+
return;
495+
}
496+
397497
updateRenderSurfaceMetrics(session);
398498
requestRender(session);
399499
}
@@ -406,6 +506,9 @@ void destroySession(const std::shared_ptr<Session>& session)
406506

407507
session->running.store(false);
408508
session->renderNeeded.store(false);
509+
// Defensive: never leave fill/freeze flags set on a torn-down session.
510+
session->fillMode.store(false);
511+
session->renderFrozen.store(false);
409512

410513
if (session->displayLink) {
411514
CVDisplayLinkStop(session->displayLink);
@@ -688,6 +791,12 @@ void renderSessionFrame(const std::shared_ptr<Session>& session)
688791
return;
689792
}
690793

794+
// Frozen during the fullscreen grow so the last frame scales smoothly with
795+
// the animating view instead of snapping (see Session::renderFrozen).
796+
if (session->renderFrozen.load()) {
797+
return;
798+
}
799+
691800
if (session->renderBackend == RenderBackend::OpenGL) {
692801
renderOpenGLFrame(session);
693802
return;
@@ -1957,6 +2066,20 @@ bool startDisplayLink(const std::shared_ptr<Session>& session)
19572066
return env.Undefined();
19582067
}
19592068

2069+
Napi::Value SetFill(const Napi::CallbackInfo& info)
2070+
{
2071+
Napi::Env env = info.Env();
2072+
if (info.Length() < 2 || !info[0].IsString() || !info[1].IsBoolean()) {
2073+
throw Napi::TypeError::New(env, "Expected session id and fill flag.");
2074+
}
2075+
2076+
const std::string sessionId = info[0].As<Napi::String>().Utf8Value();
2077+
const auto session = getSessionOrThrow(env, sessionId);
2078+
setSessionFill(session, info[1].As<Napi::Boolean>().Value());
2079+
2080+
return env.Undefined();
2081+
}
2082+
19602083
Napi::Value SetPaused(const Napi::CallbackInfo& info)
19612084
{
19622085
Napi::Env env = info.Env();
@@ -2524,6 +2647,7 @@ bool startDisplayLink(const std::shared_ptr<Session>& session)
25242647
exports.Set("createSession", Napi::Function::New(env, CreateSession));
25252648
exports.Set("loadPlayback", Napi::Function::New(env, LoadPlayback));
25262649
exports.Set("setBounds", Napi::Function::New(env, SetBounds));
2650+
exports.Set("setFill", Napi::Function::New(env, SetFill));
25272651
exports.Set("setPaused", Napi::Function::New(env, SetPaused));
25282652
exports.Set("seek", Napi::Function::New(env, Seek));
25292653
exports.Set("setVolume", Napi::Function::New(env, SetVolume));

apps/electron-backend/src/app/api/main.preload.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ const WINDOW_TOGGLE_MAXIMIZE = 'WINDOW:TOGGLE_MAXIMIZE';
5959
const WINDOW_CLOSE = 'WINDOW:CLOSE';
6060
const WINDOW_GET_STATE = 'WINDOW:GET_STATE';
6161
const WINDOW_STATE_CHANGED = 'WINDOW:STATE_CHANGED';
62+
const WINDOW_SET_FULLSCREEN = 'WINDOW:SET_FULLSCREEN';
6263

6364
const dbSaveContentProgressListeners = new Set<
6465
(
@@ -325,6 +326,8 @@ const electronApi: ElectronBridgeApi = {
325326
toggleMaximizeWindow: () => ipcRenderer.invoke(WINDOW_TOGGLE_MAXIMIZE),
326327
closeWindow: () => ipcRenderer.invoke(WINDOW_CLOSE),
327328
getWindowState: () => ipcRenderer.invoke(WINDOW_GET_STATE),
329+
setMainWindowFullScreen: (enabled: boolean) =>
330+
ipcRenderer.invoke(WINDOW_SET_FULLSCREEN, enabled),
328331
onWindowStateChange: (
329332
callback: (state: ElectronBridgeWindowState) => void
330333
) => {
@@ -428,6 +431,8 @@ const electronApi: ElectronBridgeApi = {
428431
bounds: EmbeddedMpvBounds
429432
): Promise<void> =>
430433
ipcRenderer.invoke('EMBEDDED_MPV_SET_BOUNDS', sessionId, bounds),
434+
setEmbeddedMpvFill: (sessionId: string, fill: boolean): Promise<void> =>
435+
ipcRenderer.invoke('EMBEDDED_MPV_SET_FILL', sessionId, fill),
431436
setEmbeddedMpvPaused: (
432437
sessionId: string,
433438
paused: boolean

apps/electron-backend/src/app/app.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -317,13 +317,6 @@ export default class App {
317317
}
318318

319319
private static attachWindowStateEvents(win: Electron.BrowserWindow): void {
320-
// Only Windows/Linux render custom window controls that subscribe
321-
// to these pushes; macOS keeps the native traffic lights, so
322-
// sending state updates there would be dead IPC traffic.
323-
if (process.platform === 'darwin') {
324-
return;
325-
}
326-
327320
const sendWindowState = () => {
328321
if (win.isDestroyed()) {
329322
return;
@@ -335,10 +328,18 @@ export default class App {
335328
});
336329
};
337330

338-
win.on('maximize', sendWindowState);
339-
win.on('unmaximize', sendWindowState);
331+
// Fullscreen state is needed on ALL platforms: the embedded-MPV player
332+
// reconciles its in-app fullscreen presentation with OS-initiated exits
333+
// (macOS green button / Ctrl+Cmd+F / ESC, which bypass the in-app button).
340334
win.on('enter-full-screen', sendWindowState);
341335
win.on('leave-full-screen', sendWindowState);
336+
337+
// The maximize pushes only drive the custom Windows/Linux title-bar
338+
// controls; macOS uses native traffic lights, so they'd be dead traffic.
339+
if (process.platform !== 'darwin') {
340+
win.on('maximize', sendWindowState);
341+
win.on('unmaximize', sendWindowState);
342+
}
342343
}
343344

344345
private static initMainWindow() {

apps/electron-backend/src/app/events/embedded-mpv.events.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
EMBEDDED_MPV_SET_ASPECT,
99
EMBEDDED_MPV_SET_AUDIO_TRACK,
1010
EMBEDDED_MPV_SET_BOUNDS,
11+
EMBEDDED_MPV_SET_FILL,
1112
EMBEDDED_MPV_SET_PAUSED,
1213
EMBEDDED_MPV_SET_SPEED,
1314
EMBEDDED_MPV_SET_SUBTITLE_TRACK,
@@ -81,6 +82,11 @@ handleEmbeddedMpv(
8182
getService().setBounds(sessionId, bounds)
8283
);
8384

85+
handleEmbeddedMpv(
86+
EMBEDDED_MPV_SET_FILL,
87+
(sessionId: string, fill: boolean) => getService().setFill(sessionId, fill)
88+
);
89+
8490
handleEmbeddedMpv(
8591
EMBEDDED_MPV_SET_PAUSED,
8692
(sessionId: string, paused: boolean) =>

apps/electron-backend/src/app/events/window.events.spec.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ jest.mock('electron', () => ({
2020
},
2121
}));
2222

23+
const mockApp = { mainWindow: null as unknown };
24+
jest.mock('../app', () => ({
25+
__esModule: true,
26+
default: mockApp,
27+
}));
28+
2329
function createFakeWindow(
2430
initial: { maximized?: boolean; fullScreen?: boolean } = {}
2531
) {
@@ -59,6 +65,7 @@ describe('WindowEvents', () => {
5965
'WINDOW:CLOSE',
6066
'WINDOW:GET_STATE',
6167
'WINDOW:MINIMIZE',
68+
'WINDOW:SET_FULLSCREEN',
6269
'WINDOW:TOGGLE_MAXIMIZE',
6370
]);
6471
});
@@ -128,6 +135,34 @@ describe('WindowEvents', () => {
128135
});
129136
});
130137

138+
it('toggles real native fullscreen on the sender window', () => {
139+
const win = createFakeWindow() as ReturnType<typeof createFakeWindow> & {
140+
setFullScreen: jest.Mock;
141+
};
142+
win.setFullScreen = jest.fn();
143+
mockFromWebContents.mockReturnValue(win);
144+
145+
mockHandlers.get('WINDOW:SET_FULLSCREEN')!(fakeEvent, true);
146+
expect(win.setFullScreen).toHaveBeenCalledWith(true);
147+
148+
mockHandlers.get('WINDOW:SET_FULLSCREEN')!(fakeEvent, false);
149+
expect(win.setFullScreen).toHaveBeenLastCalledWith(false);
150+
});
151+
152+
it('is a safe no-op when the sender window is destroyed', () => {
153+
const win = createFakeWindow() as ReturnType<typeof createFakeWindow> & {
154+
setFullScreen: jest.Mock;
155+
};
156+
win.setFullScreen = jest.fn();
157+
win.isDestroyed.mockReturnValue(true);
158+
mockFromWebContents.mockReturnValue(win);
159+
160+
expect(() =>
161+
mockHandlers.get('WINDOW:SET_FULLSCREEN')!(fakeEvent, true)
162+
).not.toThrow();
163+
expect(win.setFullScreen).not.toHaveBeenCalled();
164+
});
165+
131166
it('treats a destroyed window like a missing window', () => {
132167
const win = createFakeWindow();
133168
win.isDestroyed.mockReturnValue(true);

apps/electron-backend/src/app/events/window.events.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
WINDOW_CLOSE,
1111
WINDOW_GET_STATE,
1212
WINDOW_MINIMIZE,
13+
WINDOW_SET_FULLSCREEN,
1314
WINDOW_TOGGLE_MAXIMIZE,
1415
} from '@iptvnator/shared/interfaces';
1516

@@ -76,3 +77,11 @@ ipcMain.handle(WINDOW_CLOSE, (event) => {
7677
ipcMain.handle(WINDOW_GET_STATE, (event): WindowState => {
7778
return getWindowState(getSenderWindow(event));
7879
});
80+
81+
// Real macOS native fullscreen (a dedicated Space, menu bar auto-hides) for the
82+
// embedded-MPV player. The renderer freezes the native video render during the
83+
// transition (the autoresizing surface scales the last frame, like a paused
84+
// video) so the system's fullscreen zoom animation doesn't snap the live frame.
85+
ipcMain.handle(WINDOW_SET_FULLSCREEN, (event, enabled: boolean) => {
86+
getSenderWindow(event)?.setFullScreen(enabled);
87+
});

apps/electron-backend/src/app/services/embedded-mpv-native.service.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ interface NativeEmbeddedMpvAddon {
5151
): string;
5252
loadPlayback(sessionId: string, playback: ResolvedPortalPlayback): void;
5353
setBounds(sessionId: string, bounds: EmbeddedMpvBounds): void;
54+
setFill?(sessionId: string, fill: boolean): void;
5455
setPaused(sessionId: string, paused: boolean): void;
5556
seek(sessionId: string, seconds: number): void;
5657
setVolume(sessionId: string, volume: number): void;
@@ -330,6 +331,16 @@ export class EmbeddedMpvNativeService {
330331
this.getAddon().setBounds(sessionId, bounds);
331332
}
332333

334+
/**
335+
* Toggle fullscreen "fill" mode: the native view fills the window via an
336+
* autoresizing mask and rides the window's resize animation so the video
337+
* grows to full screen. No-op on older addons without `setFill`.
338+
*/
339+
setFill(sessionId: string, fill: boolean): void {
340+
this.assertEmbeddedMpvEnabled();
341+
this.getAddon().setFill?.(sessionId, fill);
342+
}
343+
333344
setPaused(sessionId: string, paused: boolean): EmbeddedMpvSession | null {
334345
this.assertEmbeddedMpvEnabled();
335346
this.getAddon().setPaused(sessionId, paused);

docs/architecture/embedded-mpv-native.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ covers the embedded-MPV-specific native/session internals.
150150

151151
The Angular side of the embedded MPV player is intentionally split so the player component stays a view-only orchestrator. The renderer files live under `libs/ui/playback/src/lib/embedded-mpv-player/`:
152152

153-
- `embedded-mpv-session-controller.ts` — component-scoped `Injectable` that owns the `support`, `session`, `sessionId`, `stalled`, and `retryToken` signals. Subscribes to `onEmbeddedMpvSessionUpdate`, runs the polling-driven `stalled` timer, owns the bounds-sync (resize/scroll/RAF), exposes `setBoundsProvider` / `triggerBoundsSync`, and delegates transport/track/recording commands to `EmbeddedMpvCommandRunner`.
153+
- `embedded-mpv-session-controller.ts` — component-scoped `Injectable` that owns the `support`, `session`, `sessionId`, `stalled`, and `retryToken` signals. Subscribes to `onEmbeddedMpvSessionUpdate`, runs the polling-driven `stalled` timer, owns the bounds-sync (resize/scroll/RAF), exposes `setBoundsProvider` / `triggerBoundsSync` / `setFill`, and delegates transport/track/recording commands to `EmbeddedMpvCommandRunner`.
154154
- `embedded-mpv-command-runner.ts``EmbeddedMpvCommandRunner`: the imperative IPC command surface (`togglePaused`, `seekBy`/`seekTo`, `applyVolume`, `setAudioTrack`, `setSubtitleTrack`, `setSpeed`, `setAspect`, `startRecording`, `stopRecording`) with optimistic snapshot reconciliation.
155155
- `embedded-mpv-session-factory.ts` — pure session-snapshot constructors (`createLoadingSession`, `createAttachingSession`, `createErrorSession`) and `waitForStartupPaint`.
156156
- `embedded-mpv-compositor.ts``measureBounds(host)` and bounds helpers that keep the native surface aligned with the Angular layout.
@@ -159,13 +159,13 @@ The Angular side of the embedded MPV player is intentionally split so the player
159159
- `embedded-mpv-labels.ts` — label/format helpers (`formatTime`, audio/subtitle/speed/aspect/volume labels) and the preset constants.
160160
- `embedded-mpv-stalled-tracker.ts``EmbeddedMpvStalledTracker` driving the "taking longer than expected" state.
161161
- `embedded-mpv-controls.adapter.ts``EmbeddedMpvControlsAdapter`, the `PlayerController` implementation bound by `app-player-controls`.
162-
- `embedded-mpv-player.component.ts` — view-only shell. Holds view children, derived `computed` signals, DOM event listeners (pointermove, pointerdown, fullscreenchange, dblclick), and the `effect()`s (immersive activation, bounds→backdrop rect, session lifecycle, time/ended bridging).
162+
- `embedded-mpv-player.component.ts` — view-only shell. Holds view children, derived `computed` signals, DOM event listeners (pointermove, pointerdown, dblclick), a window-state subscription that reconciles OS-initiated fullscreen exits, and the `effect()`s (immersive activation, bounds→backdrop rect, session lifecycle, time/ended bridging).
163163

164164
### Shipped path: immersive overlay
165165

166166
The shipped embedded-MPV player composites the libmpv surface **below** the WebContents (`NSWindowBelow`) and keeps it **always full-bleed** — the controller's default `measureBounds` provider; there are no docked or cutout bound shapes. Because the web layer is on top, modals, popovers, and the inline `app-player-controls` all paint and receive input normally — no off-screen "hide the surface" trick is needed. The app stays opaque via the single global backdrop with a transparent hole at the video rect (`EmbeddedMpvImmersiveService` + `embedded-mpv-immersive-backdrop`). The `setBoundsProvider` hook remains for a future docked / bottom-mini-bar mode but is unused on the current full-bleed path. The full immersive design, and the rationale for choosing it over the earlier (rejected) child-window and docked-above approaches, are documented in [player-controls-refactor.md](./player-controls-refactor.md).
167167

168-
Fullscreen runs the controls component's built-in `ControlsFullscreen` against the player root element: the root goes DOM-fullscreen, the surrounding chrome is hidden via `body.embedded-mpv-fullscreen` (backdrop off) so the transparent fullscreen surface reveals the native video filling the screen, and the bounds sync re-measures the new viewport. See `player-controls-refactor.md` for the immersive-overlay design.
168+
Fullscreen uses real macOS **native fullscreen** of the Electron window (`setMainWindowFullScreen``win.setFullScreen`) via the player-controls `PlayerFullscreenController` delegate — not DOM `requestFullscreen`. On enter the native surface is put in autoresize "fill" mode with its render frozen during macOS's snapshot transition (the last frame scales cleanly; the video briefly pauses, as the HTML5 player also does), and OS-initiated exits (green button / Ctrl+Cmd+F / ESC) are reconciled through the window-state bridge. See `player-controls-refactor.md` for the full fullscreen choreography.
169169

170170
### Reactivity rules (signals and effects)
171171

0 commit comments

Comments
 (0)