From 22ae97f892e4ff111ba639341fd1270e40b8ea1e Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:51:08 +0530 Subject: [PATCH] feat: player controls with track list signature and update logic --- composeApp/build.gradle.kts | 2 + .../player/desktop/NativePlayerController.kt | 18 +- .../desktopMain/native/macos/player_bridge.mm | 214 +++++++++++++++--- .../resources/player-ui/controls.html | 43 +++- 4 files changed, 234 insertions(+), 43 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 53a082cf4..db281342c 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -252,6 +252,8 @@ val buildMacosPlayerBridge = tasks.register("buildMacosPlayerBridge") { "-framework", "QuartzCore", "-framework", + "CoreVideo", + "-framework", "OpenGL", ) } diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt index b59fc8ddb..bd63736fa 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt @@ -37,6 +37,7 @@ internal class NativePlayerController( private var handle: Long = 0L private var pendingSource: PendingSource? = null private var controlsState = PlayerControlsState() + private var lastSentControlsStructureKey: PlayerControlsState? = null private var onAction: (PlayerControlsAction) -> Boolean = { false } private var onEvent: (String, Double) -> Boolean = { _, _ -> false } private var onScrubChange: (Long) -> Boolean = { false } @@ -100,9 +101,11 @@ internal class NativePlayerController( fun updateControls(state: PlayerControlsState) { controlsState = state - handle.takeIf { it != 0L }?.let { current -> - NativePlayerBridge.updateControls(current, state.toControlsJson()) - } + val current = handle.takeIf { it != 0L } ?: return + val structureKey = state.nativeControlsStructureKey() + if (structureKey == lastSentControlsStructureKey) return + lastSentControlsStructureKey = structureKey + NativePlayerBridge.updateControls(current, state.toControlsJson()) } fun setResizeMode(mode: PlayerResizeMode) { @@ -191,6 +194,7 @@ internal class NativePlayerController( fun dispose() { val current = handle handle = 0L + lastSentControlsStructureKey = null if (current != 0L) { runCatching { NativePlayerBridge.dispose(current) } } @@ -631,6 +635,14 @@ private fun PlayerControlsState.toControlsJson(): String = append('}') } +private fun PlayerControlsState.nativeControlsStructureKey(): PlayerControlsState = + copy( + isPlaying = false, + isLoading = false, + durationMs = 0L, + positionMs = 0L, + ) + private fun StringBuilder.appendJsonField(name: String, value: String) { append('"').append(name).append("\":") append(value.toJsonString()) diff --git a/composeApp/src/desktopMain/native/macos/player_bridge.mm b/composeApp/src/desktopMain/native/macos/player_bridge.mm index 4afdb763b..b12fa0683 100644 --- a/composeApp/src/desktopMain/native/macos/player_bridge.mm +++ b/composeApp/src/desktopMain/native/macos/player_bridge.mm @@ -1,10 +1,13 @@ #import +#import +#import #import #import #import #include +#include #include #include #include @@ -88,6 +91,8 @@ uint64_t mpv_render_context_update(mpv_render_context *ctx); @interface PlayerOpenGLView : NSOpenGLView @property(nonatomic, assign) mpv_render_context *renderContext; - (void)requestRender; +- (void)stopRendering; +- (void)clearRenderContext; @end @interface PlayerScriptHandler : NSObject @@ -143,14 +148,29 @@ static void *getOpenGLProcAddress(void * /* ctx */, const char *name) { return openGlHandle ? dlsym(openGlHandle, name) : nullptr; } +static CVReturn displayLinkCallback( + CVDisplayLinkRef /* displayLink */, + const CVTimeStamp * /* now */, + const CVTimeStamp * /* outputTime */, + CVOptionFlags /* flagsIn */, + CVOptionFlags * /* flagsOut */, + void *displayLinkContext +); + static void renderUpdateCallback(void *callbackContext) { PlayerOpenGLView *view = (__bridge PlayerOpenGLView *)callbackContext; - dispatch_async(dispatch_get_main_queue(), ^{ - [view requestRender]; - }); + [view requestRender]; } -@implementation PlayerOpenGLView +@implementation PlayerOpenGLView { + CVDisplayLinkRef _displayLink; + CGLContextObj _cglContext; + std::atomic_bool _renderRequested; + std::atomic_bool _drawableReady; + std::atomic_int _backingWidth; + std::atomic_int _backingHeight; + mpv_render_context *_renderContext; +} + (NSOpenGLPixelFormat *)defaultPixelFormat { NSOpenGLPixelFormatAttribute attributes[] = { @@ -172,6 +192,10 @@ static void renderUpdateCallback(void *callbackContext) { } self.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; self.wantsBestResolutionOpenGLSurface = YES; + _renderRequested.store(false); + _drawableReady.store(false); + _backingWidth.store(0); + _backingHeight.store(0); return self; } @@ -184,56 +208,184 @@ static void renderUpdateCallback(void *callbackContext) { [[self openGLContext] makeCurrentContext]; GLint swapInterval = 1; [[self openGLContext] setValues:&swapInterval forParameter:NSOpenGLContextParameterSwapInterval]; + _cglContext = [[self openGLContext] CGLContextObj]; glClearColor(0.0f, 0.0f, 0.0f, 1.0f); + [self updateBackingSize]; + [self startDisplayLinkIfNeeded]; } - (void)reshape { [super reshape]; - [[self openGLContext] update]; + CGLContextObj context = _cglContext ?: [[self openGLContext] CGLContextObj]; + if (context) { + CGLLockContext(context); + [[self openGLContext] update]; + CGLUnlockContext(context); + } else { + [[self openGLContext] update]; + } + [self updateBackingSize]; [self requestRender]; } -- (void)requestRender { - if (self.window) { - self.needsDisplay = YES; +- (void)viewDidMoveToWindow { + [super viewDidMoveToWindow]; + [self updateBackingSize]; + [self requestRender]; +} + +- (void)setRenderContext:(mpv_render_context *)renderContext { + @synchronized (self) { + _renderContext = renderContext; } + [self requestRender]; +} + +- (mpv_render_context *)renderContext { + @synchronized (self) { + return _renderContext; + } +} + +- (void)requestRender { + _renderRequested.store(true); +} + +- (void)startDisplayLinkIfNeeded { + if (_displayLink || !_cglContext) { + return; + } + + CVDisplayLinkRef displayLink = NULL; + CVReturn createResult = CVDisplayLinkCreateWithActiveCGDisplays(&displayLink); + if (createResult != kCVReturnSuccess || !displayLink) { + return; + } + + CVDisplayLinkSetOutputCallback(displayLink, displayLinkCallback, (__bridge void *)self); + CVDisplayLinkSetCurrentCGDisplayFromOpenGLContext( + displayLink, + _cglContext, + [[self pixelFormat] CGLPixelFormatObj] + ); + CVReturn startResult = CVDisplayLinkStart(displayLink); + if (startResult != kCVReturnSuccess) { + CVDisplayLinkRelease(displayLink); + return; + } + _displayLink = displayLink; +} + +- (void)stopRendering { + CVDisplayLinkRef displayLink = _displayLink; + _displayLink = NULL; + if (displayLink) { + CVDisplayLinkStop(displayLink); + CVDisplayLinkRelease(displayLink); + } + [self clearRenderContext]; +} + +- (void)clearRenderContext { + @synchronized (self) { + _renderContext = nullptr; + } + _renderRequested.store(false); +} + +- (void)updateBackingSize { + NSRect backingBounds = [self convertRectToBacking:self.bounds]; + int width = (int)llround(backingBounds.size.width); + int height = (int)llround(backingBounds.size.height); + _backingWidth.store(MAX(width, 0)); + _backingHeight.store(MAX(height, 0)); + _drawableReady.store(self.window != nil && width > 0 && height > 0); } - (void)drawRect:(NSRect)dirtyRect { (void)dirtyRect; - [[self openGLContext] makeCurrentContext]; - + [self requestRender]; if (!self.renderContext) { + CGLContextObj context = _cglContext ?: [[self openGLContext] CGLContextObj]; + if (!context) { + return; + } + CGLLockContext(context); + CGLSetCurrentContext(context); glClear(GL_COLOR_BUFFER_BIT); - [[self openGLContext] flushBuffer]; + CGLFlushDrawable(context); + CGLUnlockContext(context); + } +} + +- (void)renderFrameFromDisplayLink { + if (!_renderRequested.exchange(false)) { + return; + } + if (!_drawableReady.load()) { return; } - NSRect backingBounds = [self convertRectToBacking:self.bounds]; - GLint currentFbo = 0; - glGetIntegerv(GL_FRAMEBUFFER_BINDING, ¤tFbo); + @synchronized (self) { + mpv_render_context *context = _renderContext; + CGLContextObj glContext = _cglContext; + int width = _backingWidth.load(); + int height = _backingHeight.load(); + if (!context || !glContext || width <= 0 || height <= 0) { + return; + } - mpv_opengl_fbo fbo = { - (int)currentFbo, - (int)backingBounds.size.width, - (int)backingBounds.size.height, - GL_RGBA8 - }; - int flipY = 1; - mpv_render_param params[] = { - {MPV_RENDER_PARAM_OPENGL_FBO, &fbo}, - {MPV_RENDER_PARAM_FLIP_Y, &flipY}, - {MPV_RENDER_PARAM_INVALID, nullptr}, - }; + CGLLockContext(glContext); + CGLSetCurrentContext(glContext); - mpv_render_context_update(self.renderContext); - mpv_render_context_render(self.renderContext, params); - [[self openGLContext] flushBuffer]; - mpv_render_context_report_swap(self.renderContext); + GLint currentFbo = 0; + glGetIntegerv(GL_FRAMEBUFFER_BINDING, ¤tFbo); + + mpv_opengl_fbo fbo = { + (int)currentFbo, + width, + height, + GL_RGBA8 + }; + int flipY = 1; + mpv_render_param params[] = { + {MPV_RENDER_PARAM_OPENGL_FBO, &fbo}, + {MPV_RENDER_PARAM_FLIP_Y, &flipY}, + {MPV_RENDER_PARAM_INVALID, nullptr}, + }; + + // MPV frame rendering is intentionally display-linked and off AppKit's + // main thread so WKWebView controls stay responsive during playback. + mpv_render_context_update(context); + mpv_render_context_render(context, params); + CGLFlushDrawable(glContext); + mpv_render_context_report_swap(context); + + CGLUnlockContext(glContext); + } +} + +- (void)dealloc { + [self stopRendering]; } @end +static CVReturn displayLinkCallback( + CVDisplayLinkRef /* displayLink */, + const CVTimeStamp * /* now */, + const CVTimeStamp * /* outputTime */, + CVOptionFlags /* flagsIn */, + CVOptionFlags * /* flagsOut */, + void *displayLinkContext +) { + @autoreleasepool { + PlayerOpenGLView *view = (__bridge PlayerOpenGLView *)displayLinkContext; + [view renderFrameFromDisplayLink]; + } + return kCVReturnSuccess; +} + @implementation PlayerScriptHandler - (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message { @@ -462,7 +614,7 @@ static NSString *javaScriptStringLiteral(NSString *value) { _timer = nil; if (_renderContext) { mpv_render_context_set_update_callback(_renderContext, nullptr, nullptr); - _videoView.renderContext = nullptr; + [_videoView stopRendering]; mpv_render_context_free(_renderContext); _renderContext = nullptr; } diff --git a/composeApp/src/desktopMain/resources/player-ui/controls.html b/composeApp/src/desktopMain/resources/player-ui/controls.html index 4a5cb3dc7..138c0f7f8 100644 --- a/composeApp/src/desktopMain/resources/player-ui/controls.html +++ b/composeApp/src/desktopMain/resources/player-ui/controls.html @@ -2130,7 +2130,18 @@ if (activeModal === "p2pConsent") renderP2pConsentModal(); }; - const render = () => { + const trackListSignature = tracks => + normalizeTracks(tracks) + .map(track => [ + track.id == null ? "" : String(track.id), + track.index == null ? "" : String(track.index), + track.label == null ? "" : String(track.label), + track.language == null ? "" : String(track.language), + Boolean(track.selected) ? "1" : "0", + ].join(":")) + .join("|"); + + const renderChrome = () => { const durationMs = Math.max(0, Number(state.durationMs) || 0); const positionMs = isScrubbing ? scrubPositionMs : Math.max(0, Number(state.positionMs) || 0); root.classList.toggle("locked", Boolean(state.isLocked)); @@ -2167,6 +2178,10 @@ videoSettingsButton.setAttribute("aria-label", state.videoSettingsLabel || "Video settings"); seek.disabled = Boolean(state.isLocked); setProgress(positionMs, durationMs); + }; + + const render = () => { + renderChrome(); renderActiveModal(); }; @@ -2419,16 +2434,26 @@ render(); }); - window.playerUpdate = state => { - const durationMs = Math.round((Number(state.duration) || 0) * 1000); - const positionMs = Math.round((Number(state.position) || 0) * 1000); - window.playerControls({ + window.playerUpdate = update => { + const durationMs = Math.round((Number(update.duration) || 0) * 1000); + const positionMs = Math.round((Number(update.position) || 0) * 1000); + const audioTracks = normalizeTracks(update.audioTracks); + const subtitleTracks = normalizeTracks(update.subtitleTracks); + const audioTracksChanged = trackListSignature(audioTracks) !== trackListSignature(state.audioTracks); + const subtitleTracksChanged = trackListSignature(subtitleTracks) !== trackListSignature(state.subtitleTracks); + state = { + ...state, durationMs, positionMs, - isPlaying: !Boolean(state.paused), - audioTracks: normalizeTracks(state.audioTracks), - subtitleTracks: normalizeTracks(state.subtitleTracks), - }); + isPlaying: !Boolean(update.paused), + audioTracks, + subtitleTracks, + }; + renderChrome(); + if ((audioTracksChanged && activeModal === "audio") || + (subtitleTracksChanged && activeModal === "subtitles")) { + renderActiveModal(); + } }; window.playerControls = nextState => {