From 5e2b601a65efb1ad9f77852d8da574cbd38e8291 Mon Sep 17 00:00:00 2001 From: neerajlovecyber Date: Thu, 3 Sep 2026 11:02:16 +0530 Subject: [PATCH] feat(trailer): support background hero trailer playback on Windows desktop --- composeApp/build.gradle.kts | 8 +- .../core/build/AppFeaturePolicy.desktop.kt | 5 +- .../TrailerExtractionPlatform.desktop.kt | 46 +- .../native/windows/player_bridge.cpp | 410 ++++++++++++++++++ .../desktop/NativeMpvSurfacePlayerTest.kt | 51 +++ .../HeroTrailerPlayerSurface.desktop.kt | 9 + .../HeroTrailerPlayerSurface.desktop.kt | 116 ++++- .../trailer/desktop/NativeMpvSurfaceBridge.kt | 38 ++ .../trailer/desktop/NativeMpvSurfacePlayer.kt | 203 +++++++++ 9 files changed, 851 insertions(+), 35 deletions(-) create mode 100644 composeApp/src/desktopTest/kotlin/com/nuvio/app/features/trailer/desktop/NativeMpvSurfacePlayerTest.kt create mode 100644 composeApp/src/windowsDesktopMain/kotlin/com/nuvio/app/features/trailer/desktop/NativeMpvSurfaceBridge.kt create mode 100644 composeApp/src/windowsDesktopMain/kotlin/com/nuvio/app/features/trailer/desktop/NativeMpvSurfacePlayer.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index cab524abb..135e08859 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -913,8 +913,12 @@ val buildWindowsPlayerBridge = tasks.register("buildWindowsPlayerBridge") } outputs.file(windowsPlayerBridgeOutput) outputs.file(windowsPlayerBridgeImportLib) - outputs.file(windowsPlayerBridgePdb) - onlyIf { !windowsPlayerBridgeOutput.get().asFile.exists() } + val rebuildBridgeRequested = providers.gradleProperty("rebuildWindowsPlayerBridge").isPresent + onlyIf { + !windowsPlayerBridgeOutput.get().asFile.exists() || + rebuildBridgeRequested || + windowsPlayerBridgeSource.asFile.lastModified() > windowsPlayerBridgeOutput.get().asFile.lastModified() + } commandLine(windowsPlayerBridgeCommand) } diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt index 156d60fa8..332de2ce8 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt @@ -15,9 +15,8 @@ actual object AppFeaturePolicy { actual val personalMediaAddonCopyEnabled: Boolean = false actual val p2pEnabled: Boolean = true actual val externalPlayerSupported: Boolean = false - actual val trailerPlaybackMode: TrailerPlaybackMode = - if (isWindowsDesktop) TrailerPlaybackMode.EXTERNAL else TrailerPlaybackMode.IN_APP - actual val heroTrailerPlaybackSupported: Boolean = !isWindowsDesktop + actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.IN_APP + actual val heroTrailerPlaybackSupported: Boolean = true actual val inAppUpdaterEnabled: Boolean = true actual val imdbRatingLogoEnabled: Boolean = true actual val mediaPlaybackForegroundServiceEnabled: Boolean = false diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trailer/TrailerExtractionPlatform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trailer/TrailerExtractionPlatform.desktop.kt index 368c79c0f..cf1c06e68 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trailer/TrailerExtractionPlatform.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trailer/TrailerExtractionPlatform.desktop.kt @@ -42,9 +42,11 @@ internal object TrailerExtractionPlatform { .followSslRedirects(true) .build() - fun supportsSeparateVideo(candidate: StreamCandidate): Boolean = candidate.ext == "mp4" + fun supportsSeparateVideo(candidate: StreamCandidate): Boolean = + if (com.nuvio.app.isWindows) true else candidate.ext == "mp4" - fun supportsSeparateAudio(candidate: StreamCandidate): Boolean = candidate.ext == "m4a" + fun supportsSeparateAudio(candidate: StreamCandidate): Boolean = + if (com.nuvio.app.isWindows) true else candidate.ext == "m4a" fun diagnostic(message: String) { if (diagnosticsEnabled) { @@ -101,7 +103,7 @@ internal object TrailerExtractionPlatform { val bestCombinedIsManifest = bestManifest != null && (bestProgressive == null || bestManifest.height > bestProgressive.height) val preferManifestPlayback = bestManifest != null && - (bestVideo == null || bestManifest.height >= bestVideo.height) + (bestVideo == null || (bestManifest.height >= bestVideo.height && !com.nuvio.app.isWindows)) val combinedUrl = if (bestCombinedIsManifest) { bestManifest.manifestUrl } else { @@ -206,7 +208,7 @@ internal object TrailerExtractionPlatform { } return try { - val selected = withTimeoutOrNull(4_000L) { result.await() } + val selected = withTimeoutOrNull(2_000L) { result.await() } diagnostic( "probe ${if (selected != null) "ok" else "failed"} ${describeUrl(url)} candidates=${candidates.size}" + selected?.let { " selectedHost=${it.toHttpUrlOrNull()?.host ?: "unknown"}" }.orEmpty(), @@ -218,33 +220,19 @@ internal object TrailerExtractionPlatform { } private fun isUrlReachable(url: String): Boolean = runCatching { - val parsedUrl = url.toHttpUrlOrNull() - val sourceSize = parsedUrl?.queryParameter("clen")?.toLongOrNull()?.takeIf { it > 0L } - val ranges = sourceSize?.let { size -> - listOf( - 0L to 65_535L.coerceAtMost(size - 1L), - (size - 65_536L).coerceAtLeast(0L) to size - 1L, - ).distinct() - } ?: listOf(0L to 0L) - - ranges.all { (rangeStart, rangeEnd) -> - val request = Request.Builder() - .url(url) - .headers(buildHeaders(defaultHeaders)) - .header("Range", "bytes=$rangeStart-$rangeEnd") - .get() - .build() + val request = Request.Builder() + .url(url) + .headers(buildHeaders(defaultHeaders)) + .header("Range", "bytes=0-0") + .get() + .build() - probeClient.newCall(request).execute().use { response -> - val reachable = response.code == 206 || - (sourceSize == null && rangeStart == 0L && response.code in 200..299) - if (!reachable) { - diagnostic( - "probe range rejected ${describeUrl(url)} requested=$rangeStart-$rangeEnd status=${response.code}", - ) - } - reachable + probeClient.newCall(request).execute().use { response -> + val reachable = response.isSuccessful || response.code == 206 + if (!reachable) { + diagnostic("probe range rejected ${describeUrl(url)} status=${response.code}") } + reachable } }.getOrDefault(false) diff --git a/composeApp/src/desktopMain/native/windows/player_bridge.cpp b/composeApp/src/desktopMain/native/windows/player_bridge.cpp index 0ee85c27d..aa27f8c49 100644 --- a/composeApp/src/desktopMain/native/windows/player_bridge.cpp +++ b/composeApp/src/desktopMain/native/windows/player_bridge.cpp @@ -46,14 +46,49 @@ typedef enum mpv_format { typedef enum mpv_event_id { MPV_EVENT_NONE = 0, MPV_EVENT_SHUTDOWN = 1, + MPV_EVENT_END_FILE = 7, } mpv_event_id; +typedef enum mpv_end_file_reason { + MPV_END_FILE_REASON_EOF = 0, + MPV_END_FILE_REASON_STOP = 2, + MPV_END_FILE_REASON_QUIT = 3, + MPV_END_FILE_REASON_ERROR = 4, + MPV_END_FILE_REASON_REDIRECT = 5, +} mpv_end_file_reason; + +typedef struct mpv_event_end_file { + mpv_end_file_reason reason; + int error; + int64_t playlist_entry_id; + int64_t playlist_insert_id; + int playlist_insert_num_entries; +} mpv_event_end_file; + typedef struct mpv_event { mpv_event_id event_id; int error; uint64_t reply_userdata; void *data; } mpv_event; + +typedef struct mpv_render_context mpv_render_context; + +typedef enum mpv_render_param_type { + MPV_RENDER_PARAM_INVALID = 0, + MPV_RENDER_PARAM_API_TYPE = 1, + MPV_RENDER_PARAM_SW_SIZE = 17, + MPV_RENDER_PARAM_SW_FORMAT = 18, + MPV_RENDER_PARAM_SW_STRIDE = 19, + MPV_RENDER_PARAM_SW_POINTER = 20, +} mpv_render_param_type; + +typedef struct mpv_render_param { + mpv_render_param_type type; + void *data; +} mpv_render_param; + +typedef void (*mpv_render_update_fn)(void *cb_ctx); } namespace { @@ -497,6 +532,11 @@ struct MpvApi { using mpv_free_fn = void (*)(void *); using mpv_wait_event_fn = mpv_event *(*)(mpv_handle *, double); using mpv_wakeup_fn = void (*)(mpv_handle *); + using mpv_render_context_create_fn = int (*)(mpv_render_context **, mpv_handle *, mpv_render_param *); + using mpv_render_context_set_update_callback_fn = void (*)(mpv_render_context *, mpv_render_update_fn, void *); + using mpv_render_context_update_fn = uint64_t (*)(mpv_render_context *); + using mpv_render_context_render_fn = int (*)(mpv_render_context *, mpv_render_param *); + using mpv_render_context_free_fn = void (*)(mpv_render_context *); HMODULE library = nullptr; std::once_flag loadOnce; @@ -515,6 +555,11 @@ struct MpvApi { mpv_free_fn freeValue = nullptr; mpv_wait_event_fn waitEvent = nullptr; mpv_wakeup_fn wakeup = nullptr; + mpv_render_context_create_fn renderContextCreate = nullptr; + mpv_render_context_set_update_callback_fn renderContextSetUpdateCallback = nullptr; + mpv_render_context_update_fn renderContextUpdate = nullptr; + mpv_render_context_render_fn renderContextRender = nullptr; + mpv_render_context_free_fn renderContextFree = nullptr; void ensureLoaded() { std::call_once(loadOnce, [this]() { load(); }); @@ -574,6 +619,11 @@ struct MpvApi { freeValue = loadSymbol("mpv_free"); waitEvent = loadSymbol("mpv_wait_event"); wakeup = loadSymbol("mpv_wakeup"); + renderContextCreate = loadSymbol("mpv_render_context_create"); + renderContextSetUpdateCallback = loadSymbol("mpv_render_context_set_update_callback"); + renderContextUpdate = loadSymbol("mpv_render_context_update"); + renderContextRender = loadSymbol("mpv_render_context_render"); + renderContextFree = loadSymbol("mpv_render_context_free"); } template @@ -2206,6 +2256,233 @@ std::shared_ptr playerFromHandle(jlong handle) { return holder ? *holder : nullptr; } +class WindowsMpvSurfacePlayer : public std::enable_shared_from_this { +public: + WindowsMpvSurfacePlayer( + std::string videoUrl, + std::string audioUrl, + int64_t startPositionMs, + bool playWhenReady, + bool muted, + bool fillFrame + ) : videoUrl(std::move(videoUrl)), + audioUrl(std::move(audioUrl)), + startPositionMs(startPositionMs), + playWhenReady(playWhenReady), + muted(muted), + fillFrame(fillFrame) { + } + + ~WindowsMpvSurfacePlayer() { + dispose(); + } + + void initialize() { + MpvApi &api = mpvApi(); + mpv = api.create(); + if (!mpv) { + throw std::runtime_error("mpv_create failed for surface player"); + } + + api.setOptionString(mpv, "vo", "libmpv"); + api.setOptionString(mpv, "loop-file", "inf"); + api.setOptionString(mpv, "keep-open", "yes"); + api.setOptionString(mpv, "hwdec", "auto"); + api.setOptionString(mpv, "vd-lavc-threads", "4"); + api.setOptionString(mpv, "audio-pitch-correction", "yes"); + api.setOptionString(mpv, "volume", "100"); + api.setOptionString(mpv, "mute", muted ? "yes" : "no"); + api.setOptionString(mpv, "pause", playWhenReady ? "no" : "yes"); + api.setOptionString(mpv, "panscan", fillFrame ? "1.0" : "0.0"); + api.setOptionString(mpv, "user-agent", "Mozilla/5.0 (Linux; Android 13; Android TV) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); + api.setOptionString(mpv, "referrer", "https://www.youtube.com/"); + api.setOptionString(mpv, "cache", "yes"); + api.setOptionString(mpv, "demuxer-max-bytes", "64MiB"); + api.setOptionString(mpv, "demuxer-readahead-secs", "2"); + api.setOptionString(mpv, "demuxer-lavf-probesize", "500000"); + api.setOptionString(mpv, "demuxer-lavf-analyzeduration", "0.5"); + api.setOptionString(mpv, "demuxer-lavf-buffersize", "32768"); + + int initRes = api.initialize(mpv); + if (initRes < 0) { + api.terminateDestroy(mpv); + mpv = nullptr; + throw std::runtime_error(std::string("mpv_initialize failed for surface player: ") + api.errorText(initRes)); + } + + mpv_render_param params[] = { + {MPV_RENDER_PARAM_API_TYPE, (void *)"sw"}, + {MPV_RENDER_PARAM_INVALID, nullptr} + }; + + int renderRes = api.renderContextCreate(&renderCtx, mpv, params); + if (renderRes < 0 || !renderCtx) { + api.terminateDestroy(mpv); + mpv = nullptr; + throw std::runtime_error(std::string("mpv_render_context_create failed: ") + api.errorText(renderRes)); + } + + std::vector cmd; + cmd.push_back("loadfile"); + cmd.push_back(videoUrl.c_str()); + cmd.push_back("replace"); + cmd.push_back("-1"); + + std::string options; + if (!audioUrl.empty()) { + options += "audio-file="; + for (char c : audioUrl) { + if (c == ',') options += "\\,"; + else options += c; + } + } + if (startPositionMs > 0) { + char startBuf[64]; + std::snprintf(startBuf, sizeof(startBuf), "start=%.3f", (double)startPositionMs / 1000.0); + if (!options.empty()) options += ","; + options += startBuf; + } + if (!options.empty()) { + cmd.push_back(options.c_str()); + } + cmd.push_back(nullptr); + + int cmdRes = api.command(mpv, cmd.data()); + if (cmdRes < 0) { + hadError.store(true); + } + + auto self = shared_from_this(); + eventThread = std::thread([self]() { + self->drainEvents(); + }); + } + + void drainEvents() { + MpvApi &api = mpvApi(); + while (!stopping.load()) { + mpv_handle *current = nullptr; + { + std::lock_guard lock(playerMutex); + current = mpv; + } + if (!current) break; + + mpv_event *ev = api.waitEvent(current, 0.1); + if (!ev) continue; + if (ev->event_id == MPV_EVENT_SHUTDOWN) { + break; + } + if (ev->event_id == MPV_EVENT_END_FILE) { + if (ev->data) { + auto *endFile = reinterpret_cast(ev->data); + if (endFile->reason == MPV_END_FILE_REASON_ERROR || endFile->error < 0) { + hadError.store(true); + } + } + } + } + } + + bool renderFrame(void *dstPixels, int width, int height) { + if (!dstPixels || width <= 0 || height <= 0) return false; + std::lock_guard lock(playerMutex); + if (!renderCtx) return false; + + uint64_t flags = mpvApi().renderContextUpdate(renderCtx); + if (flags & 1) { + int size[2] = {width, height}; + char format[] = "bgr0"; + size_t stride = (size_t)width * 4; + + mpv_render_param renderParams[] = { + {MPV_RENDER_PARAM_SW_SIZE, size}, + {MPV_RENDER_PARAM_SW_FORMAT, format}, + {MPV_RENDER_PARAM_SW_STRIDE, &stride}, + {MPV_RENDER_PARAM_SW_POINTER, dstPixels}, + {MPV_RENDER_PARAM_INVALID, nullptr} + }; + + int err = mpvApi().renderContextRender(renderCtx, renderParams); + if (err == 0) { + hasRenderedFirstFrame.store(true); + size_t totalPixels = (size_t)width * (size_t)height; + uint32_t *pixels = reinterpret_cast(dstPixels); + for (size_t i = 0; i < totalPixels; i++) { + pixels[i] |= 0xFF000000U; + } + return true; + } + } + return false; + } + + void setMuted(bool isMuted) { + std::lock_guard lock(playerMutex); + if (mpv) { + mpvApi().setPropertyString(mpv, "mute", isMuted ? "yes" : "no"); + } + } + + void setPaused(bool isPaused) { + std::lock_guard lock(playerMutex); + if (mpv) { + mpvApi().setPropertyString(mpv, "pause", isPaused ? "yes" : "no"); + } + } + + bool isReady() { + return hasRenderedFirstFrame.load(); + } + + bool isEnded() { + std::lock_guard lock(playerMutex); + if (!mpv) return true; + int flag = 0; + int res = mpvApi().getProperty(mpv, "eof-reached", MPV_FORMAT_FLAG, &flag); + return res >= 0 && flag != 0; + } + + bool hasError() { + return hadError.load(); + } + + void dispose() { + stopping.store(true); + if (eventThread.joinable()) { + if (mpv) mpvApi().wakeup(mpv); + eventThread.join(); + } + + std::lock_guard lock(playerMutex); + MpvApi &api = mpvApi(); + if (renderCtx) { + api.renderContextFree(renderCtx); + renderCtx = nullptr; + } + if (mpv) { + api.terminateDestroy(mpv); + mpv = nullptr; + } + } + +private: + std::string videoUrl; + std::string audioUrl; + int64_t startPositionMs = 0; + bool playWhenReady = true; + bool muted = true; + bool fillFrame = true; + + std::mutex playerMutex; + mpv_handle *mpv = nullptr; + mpv_render_context *renderCtx = nullptr; + std::atomic stopping{false}; + std::atomic hasRenderedFirstFrame{false}; + std::atomic hadError{false}; + std::thread eventThread; +}; + } // namespace BOOL APIENTRY DllMain(HMODULE module, DWORD reason, LPVOID) { @@ -2530,3 +2807,136 @@ Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_applySubtitleStyle stripSdh == JNI_TRUE ); } + +extern "C" { + +JNIEXPORT jlong JNICALL +Java_com_nuvio_app_features_trailer_desktop_NativeMpvSurfaceBridge_nativeCreate( + JNIEnv *env, + jobject, + jstring videoUrl, + jstring audioUrl, + jlong startPositionMs, + jboolean playWhenReady, + jboolean muted, + jboolean fillFrame +) { + std::string videoUrlText = jstringToUtf8(env, videoUrl); + std::string audioUrlText = audioUrl ? jstringToUtf8(env, audioUrl) : std::string(); + + auto player = std::make_shared( + videoUrlText, + audioUrlText, + (int64_t)startPositionMs, + playWhenReady == JNI_TRUE, + muted == JNI_TRUE, + fillFrame == JNI_TRUE + ); + + try { + player->initialize(); + } catch (const std::exception &e) { + player->dispose(); + throwJavaError(env, e.what()); + return 0; + } + + auto *holder = new std::shared_ptr(player); + return (jlong)(intptr_t)holder; +} + +JNIEXPORT jboolean JNICALL +Java_com_nuvio_app_features_trailer_desktop_NativeMpvSurfaceBridge_nativeRenderFrame( + JNIEnv *, + jobject, + jlong handle, + jlong pixelsAddr, + jint width, + jint height +) { + if (handle == 0 || pixelsAddr == 0 || width <= 0 || height <= 0) return JNI_FALSE; + auto *holder = reinterpret_cast *>(handle); + if (!holder || !(*holder)) return JNI_FALSE; + return (*holder)->renderFrame((void *)(intptr_t)pixelsAddr, (int)width, (int)height) ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT void JNICALL +Java_com_nuvio_app_features_trailer_desktop_NativeMpvSurfaceBridge_nativeSetMuted( + JNIEnv *, + jobject, + jlong handle, + jboolean muted +) { + if (handle == 0) return; + auto *holder = reinterpret_cast *>(handle); + if (holder && *holder) { + (*holder)->setMuted(muted == JNI_TRUE); + } +} + +JNIEXPORT void JNICALL +Java_com_nuvio_app_features_trailer_desktop_NativeMpvSurfaceBridge_nativeSetPaused( + JNIEnv *, + jobject, + jlong handle, + jboolean paused +) { + if (handle == 0) return; + auto *holder = reinterpret_cast *>(handle); + if (holder && *holder) { + (*holder)->setPaused(paused == JNI_TRUE); + } +} + +JNIEXPORT jboolean JNICALL +Java_com_nuvio_app_features_trailer_desktop_NativeMpvSurfaceBridge_nativeIsReady( + JNIEnv *, + jobject, + jlong handle +) { + if (handle == 0) return JNI_FALSE; + auto *holder = reinterpret_cast *>(handle); + if (!holder || !(*holder)) return JNI_FALSE; + return (*holder)->isReady() ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT jboolean JNICALL +Java_com_nuvio_app_features_trailer_desktop_NativeMpvSurfaceBridge_nativeIsEnded( + JNIEnv *, + jobject, + jlong handle +) { + if (handle == 0) return JNI_TRUE; + auto *holder = reinterpret_cast *>(handle); + if (!holder || !(*holder)) return JNI_TRUE; + return (*holder)->isEnded() ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT jboolean JNICALL +Java_com_nuvio_app_features_trailer_desktop_NativeMpvSurfaceBridge_nativeHasError( + JNIEnv *, + jobject, + jlong handle +) { + if (handle == 0) return JNI_TRUE; + auto *holder = reinterpret_cast *>(handle); + if (!holder || !(*holder)) return JNI_TRUE; + return (*holder)->hasError() ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT void JNICALL +Java_com_nuvio_app_features_trailer_desktop_NativeMpvSurfaceBridge_nativeDispose( + JNIEnv *, + jobject, + jlong handle +) { + if (handle == 0) return; + auto *holder = reinterpret_cast *>(handle); + std::shared_ptr player = *holder; + delete holder; + if (player) { + player->dispose(); + } +} + +} // extern "C" diff --git a/composeApp/src/desktopTest/kotlin/com/nuvio/app/features/trailer/desktop/NativeMpvSurfacePlayerTest.kt b/composeApp/src/desktopTest/kotlin/com/nuvio/app/features/trailer/desktop/NativeMpvSurfacePlayerTest.kt new file mode 100644 index 000000000..095fd900f --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/com/nuvio/app/features/trailer/desktop/NativeMpvSurfacePlayerTest.kt @@ -0,0 +1,51 @@ +package com.nuvio.app.features.trailer.desktop + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class NativeMpvSurfacePlayerTest { + @Test + fun testNativeBridgeHandleSafety() { + // Accessing NativeMpvSurfaceBridge ensures player_bridge.dll loads successfully + val isReady = NativeMpvSurfaceBridge.nativeIsReady(0L) + assertFalse(isReady) + + val isEnded = NativeMpvSurfaceBridge.nativeIsEnded(0L) + assertTrue(isEnded) + + val hasError = NativeMpvSurfaceBridge.nativeHasError(0L) + assertTrue(hasError) + + val rendered = NativeMpvSurfaceBridge.nativeRenderFrame(0L, 0L, 0, 0) + assertFalse(rendered) + + // Verify dispose with null handle does not crash + NativeMpvSurfaceBridge.nativeDispose(0L) + } + + @Test + fun testNativeMpvSurfacePlayerLifecycle() { + val testScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + val player = NativeMpvSurfacePlayer( + videoUrl = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + audioUrl = null, + startPositionMillis = 0L, + playWhenReady = false, + initialMuted = true, + scope = testScope, + onReady = {}, + onEnded = {}, + onError = {}, + ) + + player.setSize(640, 360) + player.setMuted(false) + player.setPaused(true) + player.dispose() + } +} diff --git a/composeApp/src/nonWindowsDesktopMain/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.desktop.kt b/composeApp/src/nonWindowsDesktopMain/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.desktop.kt index 0e8d8cf7e..8671262d0 100644 --- a/composeApp/src/nonWindowsDesktopMain/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.desktop.kt +++ b/composeApp/src/nonWindowsDesktopMain/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.desktop.kt @@ -1,5 +1,7 @@ package com.nuvio.app.features.details.components +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable @@ -170,12 +172,19 @@ private fun DesktopTrailerPlayerSession( } } + val surfaceAlpha by animateFloatAsState( + targetValue = if (playWhenReady) 1f else 0f, + animationSpec = tween(durationMillis = 300), + label = "hero_surface_alpha", + ) + Box(modifier = modifier.clipToBounds()) { VideoPlayerSurface( playerState = player, modifier = Modifier .fillMaxSize() .graphicsLayer { + alpha = surfaceAlpha if (fillFrame) { scaleX = TrailerFillFrameScale scaleY = TrailerFillFrameScale diff --git a/composeApp/src/windowsDesktopMain/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.desktop.kt b/composeApp/src/windowsDesktopMain/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.desktop.kt index bb37d5b84..9ca242eee 100644 --- a/composeApp/src/windowsDesktopMain/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.desktop.kt +++ b/composeApp/src/windowsDesktopMain/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.desktop.kt @@ -1,7 +1,24 @@ package com.nuvio.app.features.details.components +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntSize +import com.nuvio.app.features.trailer.desktop.NativeMpvSurfacePlayer +import kotlin.math.roundToInt @Composable actual fun HeroTrailerPlayerSurface( @@ -15,4 +32,101 @@ actual fun HeroTrailerPlayerSurface( onReady: () -> Unit, onEnded: () -> Unit, onError: () -> Unit, -) = Unit +) { + key(sourceUrl, sourceAudioUrl, startPositionMillis) { + WindowsMpvTrailerPlayerSession( + sourceUrl = sourceUrl, + sourceAudioUrl = sourceAudioUrl, + playWhenReady = playWhenReady, + muted = muted, + startPositionMillis = startPositionMillis, + fillFrame = fillFrame, + modifier = modifier, + onReady = onReady, + onEnded = onEnded, + onError = onError, + ) + } +} + +@Composable +private fun WindowsMpvTrailerPlayerSession( + sourceUrl: String, + sourceAudioUrl: String?, + playWhenReady: Boolean, + muted: Boolean, + startPositionMillis: Long, + fillFrame: Boolean, + modifier: Modifier, + onReady: () -> Unit, + onEnded: () -> Unit, + onError: () -> Unit, +) { + val coroutineScope = rememberCoroutineScope() + val latestOnReady = rememberUpdatedState(onReady) + val latestOnEnded = rememberUpdatedState(onEnded) + val latestOnError = rememberUpdatedState(onError) + + val player = remember(sourceUrl, sourceAudioUrl, startPositionMillis, fillFrame) { + NativeMpvSurfacePlayer( + videoUrl = sourceUrl, + audioUrl = sourceAudioUrl, + startPositionMillis = startPositionMillis, + playWhenReady = playWhenReady, + initialMuted = muted, + fillFrame = fillFrame, + scope = coroutineScope, + onReady = { latestOnReady.value() }, + onEnded = { latestOnEnded.value() }, + onError = { latestOnError.value() }, + ) + } + + DisposableEffect(player) { + onDispose { + player.dispose() + } + } + + LaunchedEffect(player, playWhenReady) { + player.setPaused(!playWhenReady) + } + + LaunchedEffect(player, muted) { + player.setMuted(muted) + } + + val surfaceAlpha by animateFloatAsState( + targetValue = if (playWhenReady) 1f else 0f, + animationSpec = tween(durationMillis = 300), + label = "hero_surface_alpha", + ) + + BoxWithConstraints(modifier = modifier.clipToBounds()) { + val density = LocalDensity.current + val widthPx = with(density) { maxWidth.toPx().roundToInt() } + val heightPx = with(density) { maxHeight.toPx().roundToInt() } + + LaunchedEffect(player, widthPx, heightPx) { + if (widthPx > 0 && heightPx > 0) { + player.setSize(widthPx, heightPx) + } + } + + val currentFrame by player.currentFrame + + Canvas( + modifier = Modifier.fillMaxSize(), + ) { + if (surfaceAlpha > 0.001f) { + currentFrame?.let { frame -> + drawImage( + image = frame, + dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt()), + alpha = surfaceAlpha, + ) + } + } + } + } +} diff --git a/composeApp/src/windowsDesktopMain/kotlin/com/nuvio/app/features/trailer/desktop/NativeMpvSurfaceBridge.kt b/composeApp/src/windowsDesktopMain/kotlin/com/nuvio/app/features/trailer/desktop/NativeMpvSurfaceBridge.kt new file mode 100644 index 000000000..4d6f33764 --- /dev/null +++ b/composeApp/src/windowsDesktopMain/kotlin/com/nuvio/app/features/trailer/desktop/NativeMpvSurfaceBridge.kt @@ -0,0 +1,38 @@ +package com.nuvio.app.features.trailer.desktop + +import com.nuvio.app.features.player.desktop.NativePlayerBridge + +internal object NativeMpvSurfaceBridge { + init { + // Triggers static initialization of NativePlayerBridge, ensuring player_bridge.dll and dependencies are loaded + checkNotNull(NativePlayerBridge) + } + + external fun nativeCreate( + videoUrl: String, + audioUrl: String?, + startPositionMs: Long, + playWhenReady: Boolean, + muted: Boolean, + fillFrame: Boolean, + ): Long + + external fun nativeRenderFrame( + handle: Long, + pixelsAddr: Long, + width: Int, + height: Int, + ): Boolean + + external fun nativeSetMuted(handle: Long, muted: Boolean) + + external fun nativeSetPaused(handle: Long, paused: Boolean) + + external fun nativeIsReady(handle: Long): Boolean + + external fun nativeIsEnded(handle: Long): Boolean + + external fun nativeHasError(handle: Long): Boolean + + external fun nativeDispose(handle: Long) +} diff --git a/composeApp/src/windowsDesktopMain/kotlin/com/nuvio/app/features/trailer/desktop/NativeMpvSurfacePlayer.kt b/composeApp/src/windowsDesktopMain/kotlin/com/nuvio/app/features/trailer/desktop/NativeMpvSurfacePlayer.kt new file mode 100644 index 000000000..3516afc11 --- /dev/null +++ b/composeApp/src/windowsDesktopMain/kotlin/com/nuvio/app/features/trailer/desktop/NativeMpvSurfacePlayer.kt @@ -0,0 +1,203 @@ +package com.nuvio.app.features.trailer.desktop + +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asComposeImageBitmap +import com.nuvio.app.features.trailer.TrailerExtractionPlatform +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.ColorInfo +import org.jetbrains.skia.ColorSpace +import org.jetbrains.skia.ColorType +import org.jetbrains.skia.ImageInfo +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.locks.ReentrantReadWriteLock +import kotlin.concurrent.write + +internal class NativeMpvSurfacePlayer( + val videoUrl: String, + val audioUrl: String? = null, + val startPositionMillis: Long = 0L, + val playWhenReady: Boolean = true, + initialMuted: Boolean = true, + val fillFrame: Boolean = true, + private val scope: CoroutineScope, + private val onReady: () -> Unit, + private val onEnded: () -> Unit, + private val onError: () -> Unit, +) { + private var handle: Long = 0L + private val isDisposed = AtomicBoolean(false) + private val lock = ReentrantReadWriteLock() + + private val _currentFrame = mutableStateOf(null) + val currentFrame: State = _currentFrame + + private val _isReady = mutableStateOf(false) + val isReady: State = _isReady + + private var terminalReported = false + private var renderJob: Job? = null + + // Double buffering for Skia bitmaps + private val skiaBitmaps = arrayOfNulls(2) + private var currentBitmapWidth = 0 + private var currentBitmapHeight = 0 + private var nextBitmapIndex = 0 + + init { + TrailerExtractionPlatform.diagnostic( + "NativeMpvSurfacePlayer init video=${TrailerExtractionPlatform.describeUrl(videoUrl)} " + + "separateAudio=${!audioUrl.isNullOrBlank()} startMs=$startPositionMillis fillFrame=$fillFrame" + ) + try { + handle = NativeMpvSurfaceBridge.nativeCreate( + videoUrl = videoUrl, + audioUrl = audioUrl, + startPositionMs = startPositionMillis, + playWhenReady = playWhenReady, + muted = initialMuted, + fillFrame = fillFrame, + ) + if (handle == 0L) { + TrailerExtractionPlatform.diagnostic("NativeMpvSurfacePlayer failed to create native handle") + onError() + } else { + startRenderLoop() + } + } catch (e: Throwable) { + TrailerExtractionPlatform.diagnostic("NativeMpvSurfacePlayer create error: ${e.message}") + onError() + } + } + + private fun startRenderLoop() { + renderJob = scope.launch(Dispatchers.Default) { + while (isActive && !isDisposed.get()) { + val currentHandle = handle + if (currentHandle == 0L) break + + if (NativeMpvSurfaceBridge.nativeHasError(currentHandle)) { + if (!terminalReported) { + terminalReported = true + TrailerExtractionPlatform.diagnostic("NativeMpvSurfacePlayer reported native error") + onError() + } + break + } + + renderCurrentFrame(currentHandle) + + if (!_isReady.value && _currentFrame.value != null && NativeMpvSurfaceBridge.nativeIsReady(currentHandle)) { + _isReady.value = true + TrailerExtractionPlatform.diagnostic("NativeMpvSurfacePlayer is ready with first frame") + onReady() + } + + if (NativeMpvSurfaceBridge.nativeIsEnded(currentHandle)) { + if (!terminalReported) { + terminalReported = true + TrailerExtractionPlatform.diagnostic("NativeMpvSurfacePlayer ended") + onEnded() + } + } + + delay(16) // ~60fps cadence + } + } + } + + fun setSize(width: Int, height: Int) { + if (width <= 0 || height <= 0 || isDisposed.get()) return + // Clamp to 1080p maximum to prevent excessive buffer allocation on high DPI displays + val targetWidth = width.coerceIn(64, 1920) + val targetHeight = height.coerceIn(64, 1080) + + lock.write { + if (currentBitmapWidth != targetWidth || currentBitmapHeight != targetHeight) { + currentBitmapWidth = targetWidth + currentBitmapHeight = targetHeight + val imageInfo = ImageInfo( + ColorInfo(ColorType.BGRA_8888, ColorAlphaType.OPAQUE, ColorSpace.sRGB), + targetWidth, + targetHeight, + ) + for (i in skiaBitmaps.indices) { + skiaBitmaps[i] = Bitmap().apply { allocPixels(imageInfo) } + } + nextBitmapIndex = 0 + } + } + } + + private fun renderCurrentFrame(currentHandle: Long) { + if (isDisposed.get() || currentHandle == 0L) return + var targetBitmap: Bitmap? = null + var pixelsAddr = 0L + var width = 0 + var height = 0 + + lock.write { + width = currentBitmapWidth + height = currentBitmapHeight + if (width > 0 && height > 0 && skiaBitmaps[0] != null) { + val bmp = skiaBitmaps[nextBitmapIndex] + val pixmap = bmp?.peekPixels() + if (pixmap != null && pixmap.addr != 0L) { + targetBitmap = bmp + pixelsAddr = pixmap.addr + nextBitmapIndex = (nextBitmapIndex + 1) % skiaBitmaps.size + } + } + } + + if (pixelsAddr != 0L && targetBitmap != null) { + val hasNewFrame = NativeMpvSurfaceBridge.nativeRenderFrame(currentHandle, pixelsAddr, width, height) + if (hasNewFrame) { + if (!_isReady.value) { + _isReady.value = true + onReady() + } + _currentFrame.value = targetBitmap.asComposeImageBitmap() + } + } + } + + fun setMuted(muted: Boolean) { + val currentHandle = handle + if (!isDisposed.get() && currentHandle != 0L) { + NativeMpvSurfaceBridge.nativeSetMuted(currentHandle, muted) + } + } + + fun setPaused(paused: Boolean) { + val currentHandle = handle + if (!isDisposed.get() && currentHandle != 0L) { + NativeMpvSurfaceBridge.nativeSetPaused(currentHandle, paused) + } + } + + fun dispose() { + if (isDisposed.compareAndSet(false, true)) { + renderJob?.cancel() + val currentHandle = handle + handle = 0L + if (currentHandle != 0L) { + NativeMpvSurfaceBridge.nativeDispose(currentHandle) + } + lock.write { + _currentFrame.value = null + for (i in skiaBitmaps.indices) { + skiaBitmaps[i] = null + } + } + } + } +}