From b649726176cbc90de71f42fe3d694ec465fbdf86 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 8 Jun 2026 03:59:17 +0530 Subject: [PATCH] feat: player ui parity phase 1 --- .../player/desktop/NativePlayerBridge.kt | 8 + .../player/desktop/NativePlayerController.kt | 133 +++++ .../desktopMain/native/macos/player_bridge.mm | 39 +- .../resources/player-ui/controls.html | 561 ++++++++++++++++++ 4 files changed, 712 insertions(+), 29 deletions(-) create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt create mode 100644 composeApp/src/desktopMain/resources/player-ui/controls.html diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerBridge.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerBridge.kt index 813ef06f..9179425e 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerBridge.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerBridge.kt @@ -13,6 +13,7 @@ internal object NativePlayerBridge { sourceUrl: String, headerLines: Array, playWhenReady: Boolean, + controlsHtml: String, ): Long external fun dispose(handle: Long) @@ -25,6 +26,13 @@ internal object NativePlayerBridge { external fun isPaused(handle: Long): Boolean external fun speed(handle: Long): Float + val controlsHtml: String by lazy { + val resource = "/player-ui/controls.html" + val input = NativePlayerBridge::class.java.getResourceAsStream(resource) + ?: error("Missing native player controls resource: $resource") + input.bufferedReader().use { it.readText() } + } + private fun loadNativeLibrary() { val platform = DesktopHostOs.current require(platform == DesktopHostOs.MACOS) { 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 new file mode 100644 index 00000000..733ee69b --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt @@ -0,0 +1,133 @@ +package com.nuvio.app.features.player.desktop + +import com.nuvio.app.features.player.AudioTrack +import com.nuvio.app.features.player.PlayerEngineController +import com.nuvio.app.features.player.PlayerPlaybackSnapshot +import com.nuvio.app.features.player.SubtitleTrack +import javax.swing.SwingUtilities +import kotlin.concurrent.Volatile + +internal class NativePlayerController( + private val host: NativePlayerHost, +) : PlayerEngineController { + @Volatile + private var handle: Long = 0L + private var pendingSource: PendingSource? = null + + fun attach( + sourceUrl: String, + sourceHeaders: Map, + playWhenReady: Boolean, + onError: (String?) -> Unit, + ) { + val pending = PendingSource(sourceUrl, sourceHeaders.toHeaderLines(), playWhenReady, onError) + pendingSource = pending + host.onPeerReady = { attachPending() } + if (host.isDisplayable) { + attachPending() + } + } + + private fun attachPending() { + val pending = pendingSource ?: return + SwingUtilities.invokeLater { + if (!host.isDisplayable) return@invokeLater + dispose() + runCatching { + val hostViewPtr = AwtNativeViewResolver.resolveNativeViewPointer(host) + handle = NativePlayerBridge.create( + hostViewPtr = hostViewPtr, + sourceUrl = pending.sourceUrl, + headerLines = pending.headerLines.toTypedArray(), + playWhenReady = pending.playWhenReady, + controlsHtml = NativePlayerBridge.controlsHtml, + ) + if (handle == 0L) error("Native player did not return a handle.") + }.onFailure { error -> + pending.onError(error.message) + } + } + } + + fun snapshot(): PlayerPlaybackSnapshot { + val current = handle + if (current == 0L) return PlayerPlaybackSnapshot(isLoading = true) + return runCatching { + PlayerPlaybackSnapshot( + isLoading = false, + isPlaying = !NativePlayerBridge.isPaused(current), + isEnded = false, + durationMs = NativePlayerBridge.durationMs(current), + positionMs = NativePlayerBridge.positionMs(current), + bufferedPositionMs = NativePlayerBridge.positionMs(current), + playbackSpeed = NativePlayerBridge.speed(current), + ) + }.getOrDefault(PlayerPlaybackSnapshot(isLoading = false)) + } + + fun dispose() { + val current = handle + handle = 0L + if (current != 0L) { + runCatching { NativePlayerBridge.dispose(current) } + } + } + + override fun play() { + handle.takeIf { it != 0L }?.let { NativePlayerBridge.setPaused(it, false) } + } + + override fun pause() { + handle.takeIf { it != 0L }?.let { NativePlayerBridge.setPaused(it, true) } + } + + override fun seekTo(positionMs: Long) { + handle.takeIf { it != 0L }?.let { NativePlayerBridge.seekTo(it, positionMs) } + } + + override fun seekBy(offsetMs: Long) { + handle.takeIf { it != 0L }?.let { NativePlayerBridge.seekBy(it, offsetMs) } + } + + override fun retry() { + val pending = pendingSource ?: return + attach(pending.sourceUrl, pending.headerLines.toHeaderMap(), pending.playWhenReady, pending.onError) + } + + override fun setPlaybackSpeed(speed: Float) { + handle.takeIf { it != 0L }?.let { NativePlayerBridge.setSpeed(it, speed) } + } + + override fun getAudioTracks(): List = emptyList() + override fun getSubtitleTracks(): List = emptyList() + override fun selectAudioTrack(index: Int) = Unit + override fun selectSubtitleTrack(index: Int) = Unit + override fun setSubtitleUri(url: String) = Unit + override fun clearExternalSubtitle() = Unit + override fun clearExternalSubtitleAndSelect(trackIndex: Int) = Unit +} + +private data class PendingSource( + val sourceUrl: String, + val headerLines: List, + val playWhenReady: Boolean, + val onError: (String?) -> Unit, +) + +private fun Map.toHeaderLines(): List = + entries.mapNotNull { (key, value) -> + val cleanKey = key.trim() + val cleanValue = value.trim() + if (cleanKey.isBlank() || cleanValue.isBlank()) { + null + } else { + "$cleanKey: $cleanValue" + } + } + +private fun List.toHeaderMap(): Map = + mapNotNull { line -> + val separator = line.indexOf(':') + if (separator <= 0) return@mapNotNull null + line.substring(0, separator).trim() to line.substring(separator + 1).trim() + }.toMap() diff --git a/composeApp/src/desktopMain/native/macos/player_bridge.mm b/composeApp/src/desktopMain/native/macos/player_bridge.mm index e182ed19..e5648411 100644 --- a/composeApp/src/desktopMain/native/macos/player_bridge.mm +++ b/composeApp/src/desktopMain/native/macos/player_bridge.mm @@ -96,7 +96,8 @@ uint64_t mpv_render_context_update(mpv_render_context *ctx); - (instancetype)initWithHostView:(NSView *)hostView sourceUrl:(NSString *)sourceUrl headerLines:(NSArray *)headerLines - playWhenReady:(BOOL)playWhenReady; + playWhenReady:(BOOL)playWhenReady + controlsHtml:(NSString *)controlsHtml; - (void)shutdown; - (void)setPaused:(BOOL)paused; - (BOOL)isPaused; @@ -230,7 +231,8 @@ static void renderUpdateCallback(void *callbackContext) { - (instancetype)initWithHostView:(NSView *)hostView sourceUrl:(NSString *)sourceUrl headerLines:(NSArray *)headerLines - playWhenReady:(BOOL)playWhenReady { + playWhenReady:(BOOL)playWhenReady + controlsHtml:(NSString *)controlsHtml { self = [super init]; if (!self) { return nil; @@ -257,7 +259,7 @@ static void renderUpdateCallback(void *callbackContext) { _webView.wantsLayer = YES; [_webView setValue:@NO forKey:@"drawsBackground"]; [_hostView addSubview:_webView positioned:NSWindowAbove relativeTo:_videoView]; - [_webView loadHTMLString:[self controlsHtml] baseURL:nil]; + [_webView loadHTMLString:controlsHtml baseURL:nil]; [self startMpvWithSource:sourceUrl headerLines:headerLines playWhenReady:playWhenReady]; _timer = [NSTimer scheduledTimerWithTimeInterval:0.5 @@ -328,30 +330,6 @@ static void renderUpdateCallback(void *callbackContext) { [self setPaused:!playWhenReady]; } -- (NSString *)controlsHtml { - return @"" - "" - "" - "
Native macOS player
" - "
0:00 / 0:00
" - ""; -} - - (void)syncControls { if (!_webView || !_mpv) { return; @@ -515,7 +493,8 @@ Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_create( jlong hostViewPtr, jstring sourceUrl, jobjectArray headerLines, - jboolean playWhenReady + jboolean playWhenReady, + jstring controlsHtml ) { NSView *hostView = (__bridge NSView *)(void *)(intptr_t)hostViewPtr; if (!hostView) { @@ -524,6 +503,7 @@ Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_create( } std::string source = jstringToString(env, sourceUrl); + std::string controls = jstringToString(env, controlsHtml); NSArray *headers = jstringArrayToNSArray(env, headerLines); __block MpvWebPlayer *player = nil; __block NSString *error = nil; @@ -533,7 +513,8 @@ Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_create( initWithHostView:hostView sourceUrl:[NSString stringWithUTF8String:source.c_str()] headerLines:headers - playWhenReady:playWhenReady == JNI_TRUE]; + playWhenReady:playWhenReady == JNI_TRUE + controlsHtml:[NSString stringWithUTF8String:controls.c_str()]]; } @catch (NSException *exception) { error = exception.reason ?: exception.name; } diff --git a/composeApp/src/desktopMain/resources/player-ui/controls.html b/composeApp/src/desktopMain/resources/player-ui/controls.html new file mode 100644 index 00000000..fb190e66 --- /dev/null +++ b/composeApp/src/desktopMain/resources/player-ui/controls.html @@ -0,0 +1,561 @@ + + + + + + + + + + +
+
+
+
+
+ + +
+ +
+ + + +
+ +
+ +
+ 00:00 + 00:00 +
+
+
+ + + + + + + +
+
+
+
+
+ + + +