From 024af35bf5dd1d2fad8da99b6d8be7c12452f56c Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 1 May 2026 05:47:13 +0200 Subject: [PATCH] chore: add native formatting checks --- .clang-format | 9 + .editorconfig | 28 + .github/workflows/ci.yml | 23 + .swift-format | 10 + CONTRIBUTING.md | 8 +- android/app/build.gradle.kts | 259 +- android/app/src/main/cpp/dovi_bridge.cpp | 167 +- .../kotlin/com/edde746/plezy/MainActivity.kt | 608 +-- .../exoplayer/CuelessSeekExtractorWrapper.kt | 262 +- .../com/edde746/plezy/exoplayer/DoviBridge.kt | 121 +- .../exoplayer/DoviConvertingTrackOutput.kt | 761 ++-- .../plezy/exoplayer/DoviExtractorWrapper.kt | 49 +- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 3498 +++++++++-------- .../plezy/exoplayer/ExoPlayerPlugin.kt | 1633 ++++---- .../plezy/exoplayer/PlezyRenderersFactory.kt | 472 ++- .../exoplayer/ZlibInflatingTrackOutput.kt | 183 +- .../plezy/exoplayer/ZlibMatroskaExtractor.kt | 172 +- .../com/edde746/plezy/mpv/MpvPlayerCore.kt | 1538 ++++---- .../com/edde746/plezy/mpv/MpvPlayerPlugin.kt | 671 ++-- .../edde746/plezy/shared/AudioFocusManager.kt | 170 +- .../plezy/shared/FlutterOverlayHelper.kt | 146 +- .../edde746/plezy/shared/FrameRateManager.kt | 502 +-- .../edde746/plezy/shared/PlayerDelegate.kt | 4 +- .../com/edde746/plezy/shared/ThemeHelper.kt | 12 +- .../plezy/watchnext/WatchNextPlugin.kt | 316 +- .../plezy/watchnext/WatchNextProvider.kt | 271 +- ios/Podfile.lock | 4 +- ios/Runner/MpvPlayer/MpvPipController.swift | 452 +-- ios/Runner/MpvPlayer/MpvPlayerCore.swift | 316 +- ios/Runner/MpvPlayer/MpvPlayerPlugin.swift | 738 ++-- linux/runner/mpv/mpv_player.cc | 55 +- linux/runner/mpv/mpv_player.h | 9 +- linux/runner/mpv/mpv_plugin.cc | 164 +- linux/runner/mpv/mpv_texture.cc | 44 +- linux/runner/mpv/mpv_texture.h | 4 +- linux/runner/my_application.cc | 27 +- linux/runner/my_application.h | 3 +- macos/Runner/MainFlutterWindow.swift | 6 +- macos/Runner/MpvPlayer/MpvPipController.swift | 218 +- macos/Runner/MpvPlayer/MpvPlayerCore.swift | 448 +-- macos/Runner/MpvPlayer/MpvPlayerPlugin.swift | 626 +-- macos/Runner/Runner-Bridging-Header.h | 26 +- macos/Runner/WindowDelegate.swift | 103 +- macos/Runner/WindowUtilsPlugin.swift | 382 +- scripts/ci_checks.sh | 18 +- scripts/format_native.sh | 163 + .../apple/MpvPlayer/MpvPlayerCoreBase.swift | 887 ++--- .../MpvPlayer/MpvPlayerPluginShared.swift | 170 +- shared/cpp/sanitize_utf8.h | 3 +- windows/runner/flutter_window.cpp | 111 +- windows/runner/flutter_window.h | 6 +- windows/runner/main.cpp | 10 +- windows/runner/mpv/display_mode_manager.cpp | 116 +- windows/runner/mpv/display_mode_manager.h | 17 +- windows/runner/mpv/mpv_container.cpp | 29 +- windows/runner/mpv/mpv_container.h | 3 +- windows/runner/mpv/mpv_core.cpp | 68 +- windows/runner/mpv/mpv_core.h | 3 +- windows/runner/mpv/mpv_player.cpp | 49 +- windows/runner/mpv/mpv_player.h | 16 +- windows/runner/mpv/mpv_plugin.cpp | 116 +- windows/runner/mpv/mpv_plugin.h | 9 +- windows/runner/mpv/utils.cpp | 15 +- windows/runner/mpv/utils.h | 3 +- windows/runner/pch.h | 36 +- windows/runner/resource.h | 10 +- windows/runner/utils.cpp | 9 +- windows/runner/win32_window.cpp | 82 +- windows/runner/win32_window.h | 14 +- 69 files changed, 8848 insertions(+), 8633 deletions(-) create mode 100644 .clang-format create mode 100644 .editorconfig create mode 100644 .swift-format create mode 100755 scripts/format_native.sh diff --git a/.clang-format b/.clang-format new file mode 100644 index 00000000..bfe823f1 --- /dev/null +++ b/.clang-format @@ -0,0 +1,9 @@ +BasedOnStyle: Google +AlignAfterOpenBracket: AlwaysBreak +ColumnLimit: 120 +IndentWidth: 2 +ContinuationIndentWidth: 4 +DerivePointerAlignment: false +LineEnding: LF +PointerAlignment: Left +SortIncludes: true diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..5a331d94 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,28 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{c,cc,cpp,h,hpp,m,mm}] +indent_style = space +indent_size = 2 + +[*.dart] +indent_style = space +indent_size = 2 + +[*.{kt,kts,java}] +indent_style = space +indent_size = 2 +ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL +ktlint_code_style = android_studio +ktlint_standard_max-line-length = disabled +ktlint_standard_no-wildcard-imports = disabled +ktlint_standard_property-naming = disabled + +[*.swift] +indent_style = space +indent_size = 2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e035d5ff..c5c9dc27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,6 +130,29 @@ jobs: echo "No tests found, skipping test execution" fi + native-format: + name: Native Formatting + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Swift + uses: swift-actions/setup-swift@v3 + with: + swift-version: "6.2" + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + + - name: Verify native formatting + run: scripts/format_native.sh --check + dependency-check: name: Dependency Validation runs-on: ubuntu-latest diff --git a/.swift-format b/.swift-format new file mode 100644 index 00000000..8c36f463 --- /dev/null +++ b/.swift-format @@ -0,0 +1,10 @@ +{ + "indentation" : { + "spaces" : 2 + }, + "lineLength" : 120, + "maximumBlankLines" : 1, + "respectsExistingLineBreaks" : true, + "rules" : {}, + "version" : 1 +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 471b52f9..93cdc540 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,8 @@ ## Development - Follow Dart/Flutter conventions -- Run `dart format .` to format your code (note: generated files like `*.g.dart` are excluded from CI checks) +- Run `dart format .` to format Dart code (note: generated files like `*.g.dart` are excluded from CI checks) +- Run `scripts/format_native.sh --fix` to format Kotlin, Swift, C++, C, Objective-C, and native headers - Run `flutter analyze` before submitting to check for issues - Run `flutter test` if tests are available - Test your changes thoroughly @@ -19,8 +20,9 @@ The project includes automated CI checks that run on all pull requests: -1. **Code Formatting**: Ensures code follows Dart formatting standards - - Run locally: `dart format .` to format all files +1. **Code Formatting**: Ensures code follows Dart and native formatting standards + - Run locally: `dart format .` to format Dart files + - Run locally: `scripts/format_native.sh --fix` to format native files - Note: CI only checks non-generated files (excludes `.g.dart`, `.freezed.dart`) - Generated files are reformatted automatically by build tools diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index a54f192d..b752d62b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,11 +1,11 @@ -import java.util.Properties import java.io.FileInputStream +import java.util.Properties plugins { - id("com.android.application") - id("kotlin-android") - // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. - id("dev.flutter.flutter-gradle-plugin") + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") } val mpvVersion = "v1.0.7" @@ -13,14 +13,14 @@ val mpvDir = layout.buildDirectory.dir("libmpv").get().asFile val mpvAar = "libmpv-release.aar" val downloadLibmpv by tasks.registering { - val stamp = File(mpvDir, ".version") - outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == mpvVersion } - doLast { - mpvDir.mkdirs() - val url = "https://github.com/edde746/libmpv-android/releases/download/$mpvVersion/$mpvAar" - exec { commandLine("curl", "-sfL", url, "-o", File(mpvDir, mpvAar).absolutePath) } - stamp.writeText(mpvVersion) - } + val stamp = File(mpvDir, ".version") + outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == mpvVersion } + doLast { + mpvDir.mkdirs() + val url = "https://github.com/edde746/libmpv-android/releases/download/$mpvVersion/$mpvAar" + exec { commandLine("curl", "-sfL", url, "-o", File(mpvDir, mpvAar).absolutePath) } + stamp.writeText(mpvVersion) + } } val assVersion = "fp-3" @@ -28,165 +28,164 @@ val assDir = layout.buildDirectory.dir("libass").get().asFile val assAars = listOf("lib_ass-release.aar", "lib_ass_kt-release.aar", "lib_ass_media-release.aar") val downloadLibass by tasks.registering { - val stamp = File(assDir, ".version") - outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == assVersion } - doLast { - assDir.mkdirs() - val baseUrl = "https://github.com/edde746/libass-android/releases/download/$assVersion" - assAars.forEach { name -> - val dest = File(assDir, name) - exec { commandLine("curl", "-sfL", "$baseUrl/$name", "-o", dest.absolutePath) } - } - stamp.writeText(assVersion) + val stamp = File(assDir, ".version") + outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == assVersion } + doLast { + assDir.mkdirs() + val baseUrl = "https://github.com/edde746/libass-android/releases/download/$assVersion" + assAars.forEach { name -> + val dest = File(assDir, name) + exec { commandLine("curl", "-sfL", "$baseUrl/$name", "-o", dest.absolutePath) } } + stamp.writeText(assVersion) + } } val doviVersion = "2.3.1" val doviDir = layout.buildDirectory.dir("libdovi").get().asFile val doviAbis = mapOf( - "arm64-v8a" to "aarch64-linux-android", - "armeabi-v7a" to "armv7-linux-androideabi", - "x86" to "i686-linux-android", - "x86_64" to "x86_64-linux-android", + "arm64-v8a" to "aarch64-linux-android", + "armeabi-v7a" to "armv7-linux-androideabi", + "x86" to "i686-linux-android", + "x86_64" to "x86_64-linux-android" ) val downloadLibdovi by tasks.registering { - val stamp = File(doviDir, ".version") - outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == doviVersion } - doLast { - doviDir.mkdirs() - val baseUrl = "https://github.com/edde746/libdovi-builds/releases/download/v$doviVersion" - doviAbis.forEach { (abi, triple) -> - val archive = File(doviDir, "$triple.tar.gz") - exec { commandLine("curl", "-sfL", "$baseUrl/libdovi-$triple.tar.gz", "-o", archive.absolutePath) } - val outDir = File(doviDir, "$abi/lib") - outDir.mkdirs() - exec { commandLine("tar", "-xzf", archive.absolutePath, "-C", outDir.absolutePath) } - archive.delete() - } - stamp.writeText(doviVersion) + val stamp = File(doviDir, ".version") + outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == doviVersion } + doLast { + doviDir.mkdirs() + val baseUrl = "https://github.com/edde746/libdovi-builds/releases/download/v$doviVersion" + doviAbis.forEach { (abi, triple) -> + val archive = File(doviDir, "$triple.tar.gz") + exec { commandLine("curl", "-sfL", "$baseUrl/libdovi-$triple.tar.gz", "-o", archive.absolutePath) } + val outDir = File(doviDir, "$abi/lib") + outDir.mkdirs() + exec { commandLine("tar", "-xzf", archive.absolutePath, "-C", outDir.absolutePath) } + archive.delete() } + stamp.writeText(doviVersion) + } } android { - namespace = "com.edde746.plezy" - compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion + namespace = "com.edde746.plezy" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_11.toString() - } + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } - defaultConfig { - applicationId = "com.edde746.plezy" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = 25 // Fire OS 6.x (API 25); overrides libmpv-android's minSdk=26 - targetSdk = flutter.targetSdkVersion - versionCode = flutter.versionCode - versionName = flutter.versionName - - externalNativeBuild { - cmake { - arguments += listOf( - "-DDOVI_ENABLE_LIBDOVI=ON", - "-DDOVI_LIBDOVI_PREBUILT_ROOT=${doviDir.absolutePath}" - ) - } - } - - if (System.getenv("AMAZON") != null) { - versionCode = (flutter.versionCode ?: 0) + 3000 - ndk { - abiFilters += listOf("armeabi-v7a", "arm64-v8a") - } - } - } + defaultConfig { + applicationId = "com.edde746.plezy" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = 25 // Fire OS 6.x (API 25); overrides libmpv-android's minSdk=26 + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName externalNativeBuild { - cmake { - path = file("src/main/cpp/CMakeLists.txt") - } + cmake { + arguments += listOf( + "-DDOVI_ENABLE_LIBDOVI=ON", + "-DDOVI_LIBDOVI_PREBUILT_ROOT=${doviDir.absolutePath}" + ) + } } - signingConfigs { - create("release") { - val keystorePropertiesFile = rootProject.file("key.properties") - if (keystorePropertiesFile.exists()) { - val keystoreProperties = Properties() - keystoreProperties.load(FileInputStream(keystorePropertiesFile)) - - keyAlias = keystoreProperties["keyAlias"] as String - keyPassword = keystoreProperties["keyPassword"] as String - storeFile = file(keystoreProperties["storeFile"] as String) - storePassword = keystoreProperties["storePassword"] as String - } - } + if (System.getenv("AMAZON") != null) { + versionCode = (flutter.versionCode ?: 0) + 3000 + ndk { + abiFilters += listOf("armeabi-v7a", "arm64-v8a") + } } + } - buildTypes { - release { - // Only use release signing if key.properties exists (not in CI/CD) - val keystorePropertiesFile = rootProject.file("key.properties") - if (keystorePropertiesFile.exists()) { - signingConfig = signingConfigs.getByName("release") - } - // If key.properties doesn't exist, it will use debug signing for CI builds - ndk { - debugSymbolLevel = "FULL" - } - } + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") } + } - packaging { - jniLibs { - // Resolve conflict between libass-android and libmpv native libraries - pickFirsts.add("lib/*/libc++_shared.so") - } + signingConfigs { + create("release") { + val keystorePropertiesFile = rootProject.file("key.properties") + if (keystorePropertiesFile.exists()) { + val keystoreProperties = Properties() + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) + + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + storeFile = file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["storePassword"] as String + } } + } + + buildTypes { + release { + // Only use release signing if key.properties exists (not in CI/CD) + val keystorePropertiesFile = rootProject.file("key.properties") + if (keystorePropertiesFile.exists()) { + signingConfig = signingConfigs.getByName("release") + } + // If key.properties doesn't exist, it will use debug signing for CI builds + ndk { + debugSymbolLevel = "FULL" + } + } + } + + packaging { + jniLibs { + // Resolve conflict between libass-android and libmpv native libraries + pickFirsts.add("lib/*/libc++_shared.so") + } + } } flutter { - source = "../.." + source = "../.." } // Download libdovi before any CMake/native build task tasks.matching { it.name.contains("CMake") || it.name.contains("externalNative") }.configureEach { - dependsOn(downloadLibdovi) + dependsOn(downloadLibdovi) } // Download libmpv and libass AARs before compilation tasks.matching { it.name.startsWith("pre") && it.name.endsWith("Build") }.configureEach { - dependsOn(downloadLibmpv) - dependsOn(downloadLibass) + dependsOn(downloadLibmpv) + dependsOn(downloadLibass) } - dependencies { - implementation(files(File(mpvDir, mpvAar))) - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") + implementation(files(File(mpvDir, mpvAar))) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") - // Android TV Watch Next integration - implementation("androidx.tvprovider:tvprovider:1.0.0") + // Android TV Watch Next integration + implementation("androidx.tvprovider:tvprovider:1.0.0") - // Media3 ExoPlayer for Android - implementation("androidx.media3:media3-exoplayer:1.9.2") - implementation("androidx.media3:media3-exoplayer-hls:1.9.2") - implementation("androidx.media3:media3-ui:1.9.2") - implementation("androidx.media3:media3-common:1.9.2") + // Media3 ExoPlayer for Android + implementation("androidx.media3:media3-exoplayer:1.9.2") + implementation("androidx.media3:media3-exoplayer-hls:1.9.2") + implementation("androidx.media3:media3-ui:1.9.2") + implementation("androidx.media3:media3-common:1.9.2") - // Cronet for HTTP/2 multiplexing + better connection management - implementation("androidx.media3:media3-datasource-cronet:1.9.2") - implementation("org.chromium.net:cronet-embedded:143.7445.0") + // Cronet for HTTP/2 multiplexing + better connection management + implementation("androidx.media3:media3-datasource-cronet:1.9.2") + implementation("org.chromium.net:cronet-embedded:143.7445.0") - // FFmpeg audio decoder for unsupported codecs (ALAC, DTS, TrueHD, etc.) - implementation("org.jellyfin.media3:media3-ffmpeg-decoder:1.9.0+1") + // FFmpeg audio decoder for unsupported codecs (ALAC, DTS, TrueHD, etc.) + implementation("org.jellyfin.media3:media3-ffmpeg-decoder:1.9.0+1") - // libass-android for ASS/SSA subtitle rendering - assAars.forEach { implementation(files(File(assDir, it))) } + // libass-android for ASS/SSA subtitle rendering + assAars.forEach { implementation(files(File(assDir, it))) } } diff --git a/android/app/src/main/cpp/dovi_bridge.cpp b/android/app/src/main/cpp/dovi_bridge.cpp index 4d18cdfe..c485d95e 100644 --- a/android/app/src/main/cpp/dovi_bridge.cpp +++ b/android/app/src/main/cpp/dovi_bridge.cpp @@ -1,5 +1,6 @@ -#include #include +#include + #include #include @@ -13,114 +14,110 @@ static const char* BRIDGE_VERSION = "1.0.0"; -extern "C" JNIEXPORT jbyteArray JNICALL -Java_com_edde746_plezy_exoplayer_DoviBridge_nativeConvertDv7RpuToDv81( - JNIEnv *env, jclass, jbyteArray payload, jint mode) { +extern "C" JNIEXPORT jbyteArray JNICALL Java_com_edde746_plezy_exoplayer_DoviBridge_nativeConvertDv7RpuToDv81( + JNIEnv* env, jclass, jbyteArray payload, jint mode) { #if !DOVI_REAL_LINKED - return nullptr; + return nullptr; #else - if (payload == nullptr) return nullptr; + if (payload == nullptr) return nullptr; - jsize len = env->GetArrayLength(payload); - if (len <= 0) return nullptr; + jsize len = env->GetArrayLength(payload); + if (len <= 0) return nullptr; - // Valid RPU NALs are typically <2 KiB; reject unreasonable sizes - if (len > 8192) { - LOGW("RPU payload too large (%d bytes), skipping", len); - return nullptr; - } + // Valid RPU NALs are typically <2 KiB; reject unreasonable sizes + if (len > 8192) { + LOGW("RPU payload too large (%d bytes), skipping", len); + return nullptr; + } - // Copy to native heap so libdovi never touches JVM heap memory. - // GetByteArrayElements on ART may return a direct heap pointer; any - // out-of-bounds access by libdovi would corrupt adjacent JVM objects. - auto *buf = new (std::nothrow) uint8_t[static_cast(len)]; - if (buf == nullptr) return nullptr; - - env->GetByteArrayRegion(payload, 0, len, reinterpret_cast(buf)); - if (env->ExceptionCheck()) { - delete[] buf; - return nullptr; - } - - // Try dovi_parse_unspec62_nalu first (handles escaped NALs), fallback to dovi_parse_rpu - DoviRpuOpaque *rpu = dovi_parse_unspec62_nalu(buf, static_cast(len)); - - if (rpu == nullptr) { - delete[] buf; - return nullptr; - } - - const char *err = dovi_rpu_get_error(rpu); - if (err != nullptr) { - // Fallback: try dovi_parse_rpu (raw RPU without NAL framing) - dovi_rpu_free(rpu); - rpu = dovi_parse_rpu(buf, static_cast(len)); - if (rpu == nullptr) { - delete[] buf; - return nullptr; - } - err = dovi_rpu_get_error(rpu); - if (err != nullptr) { - LOGW("RPU parse failed: %s", err); - dovi_rpu_free(rpu); - delete[] buf; - return nullptr; - } - } + // Copy to native heap so libdovi never touches JVM heap memory. + // GetByteArrayElements on ART may return a direct heap pointer; any + // out-of-bounds access by libdovi would corrupt adjacent JVM objects. + auto* buf = new (std::nothrow) uint8_t[static_cast(len)]; + if (buf == nullptr) return nullptr; + env->GetByteArrayRegion(payload, 0, len, reinterpret_cast(buf)); + if (env->ExceptionCheck()) { delete[] buf; + return nullptr; + } - // Convert to target profile (mode 2 = P8.1 with no-op curves) - int32_t ret = dovi_convert_rpu_with_mode(rpu, static_cast(mode)); - if (ret != 0) { - err = dovi_rpu_get_error(rpu); - LOGW("RPU conversion failed (mode %d): %s", mode, err ? err : "unknown"); - dovi_rpu_free(rpu); - return nullptr; + // Try dovi_parse_unspec62_nalu first (handles escaped NALs), fallback to dovi_parse_rpu + DoviRpuOpaque* rpu = dovi_parse_unspec62_nalu(buf, static_cast(len)); + + if (rpu == nullptr) { + delete[] buf; + return nullptr; + } + + const char* err = dovi_rpu_get_error(rpu); + if (err != nullptr) { + // Fallback: try dovi_parse_rpu (raw RPU without NAL framing) + dovi_rpu_free(rpu); + rpu = dovi_parse_rpu(buf, static_cast(len)); + if (rpu == nullptr) { + delete[] buf; + return nullptr; } - - // Write back as UNSPEC62 NAL - const DoviData *out = dovi_write_unspec62_nalu(rpu); - if (out == nullptr || out->data == nullptr || out->len == 0) { - err = dovi_rpu_get_error(rpu); - LOGW("RPU write failed: %s", err ? err : "unknown"); - if (out != nullptr) dovi_data_free(out); - dovi_rpu_free(rpu); - return nullptr; + err = dovi_rpu_get_error(rpu); + if (err != nullptr) { + LOGW("RPU parse failed: %s", err); + dovi_rpu_free(rpu); + delete[] buf; + return nullptr; } + } - if (out->len > 16384) { - LOGW("RPU output unexpectedly large (%zu bytes), discarding", out->len); - dovi_data_free(out); - dovi_rpu_free(rpu); - return nullptr; - } + delete[] buf; - jbyteArray result = env->NewByteArray(static_cast(out->len)); - if (result != nullptr) { - env->SetByteArrayRegion(result, 0, static_cast(out->len), - reinterpret_cast(out->data)); - } + // Convert to target profile (mode 2 = P8.1 with no-op curves) + int32_t ret = dovi_convert_rpu_with_mode(rpu, static_cast(mode)); + if (ret != 0) { + err = dovi_rpu_get_error(rpu); + LOGW("RPU conversion failed (mode %d): %s", mode, err ? err : "unknown"); + dovi_rpu_free(rpu); + return nullptr; + } + // Write back as UNSPEC62 NAL + const DoviData* out = dovi_write_unspec62_nalu(rpu); + if (out == nullptr || out->data == nullptr || out->len == 0) { + err = dovi_rpu_get_error(rpu); + LOGW("RPU write failed: %s", err ? err : "unknown"); + if (out != nullptr) dovi_data_free(out); + dovi_rpu_free(rpu); + return nullptr; + } + + if (out->len > 16384) { + LOGW("RPU output unexpectedly large (%zu bytes), discarding", out->len); dovi_data_free(out); dovi_rpu_free(rpu); + return nullptr; + } - return result; + jbyteArray result = env->NewByteArray(static_cast(out->len)); + if (result != nullptr) { + env->SetByteArrayRegion(result, 0, static_cast(out->len), reinterpret_cast(out->data)); + } + + dovi_data_free(out); + dovi_rpu_free(rpu); + + return result; #endif } extern "C" JNIEXPORT jboolean JNICALL -Java_com_edde746_plezy_exoplayer_DoviBridge_nativeIsConversionPathReady( - JNIEnv *, jclass) { +Java_com_edde746_plezy_exoplayer_DoviBridge_nativeIsConversionPathReady(JNIEnv*, jclass) { #if DOVI_REAL_LINKED - return JNI_TRUE; + return JNI_TRUE; #else - return JNI_FALSE; + return JNI_FALSE; #endif } extern "C" JNIEXPORT jstring JNICALL -Java_com_edde746_plezy_exoplayer_DoviBridge_nativeGetBridgeVersion( - JNIEnv *env, jclass) { - return env->NewStringUTF(BRIDGE_VERSION); +Java_com_edde746_plezy_exoplayer_DoviBridge_nativeGetBridgeVersion(JNIEnv* env, jclass) { + return env->NewStringUTF(BRIDGE_VERSION); } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt index 9acc2030..79909f5c 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt @@ -1,13 +1,13 @@ package com.edde746.plezy -import android.content.Intent -import android.net.Uri -import android.os.Build -import android.os.Bundle import android.app.AppOpsManager import android.app.PictureInPictureParams import android.content.Context +import android.content.Intent import android.content.res.Configuration +import android.net.Uri +import android.os.Build +import android.os.Bundle import android.util.Log import android.util.Rational import android.view.KeyEvent @@ -15,347 +15,357 @@ import android.view.ViewGroup import android.view.inputmethod.InputMethodManager import android.widget.FrameLayout import androidx.core.content.FileProvider -import io.flutter.embedding.android.FlutterActivity -import io.flutter.embedding.engine.FlutterShellArgs -import io.flutter.embedding.android.RenderMode -import io.flutter.embedding.android.TransparencyMode -import io.flutter.embedding.engine.FlutterEngine -import io.flutter.plugin.common.MethodChannel import com.edde746.plezy.exoplayer.ExoPlayerPlugin import com.edde746.plezy.mpv.MpvPlayerPlugin import com.edde746.plezy.shared.ThemeHelper import com.edde746.plezy.watchnext.WatchNextPlugin +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.android.RenderMode +import io.flutter.embedding.android.TransparencyMode +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.FlutterShellArgs +import io.flutter.plugin.common.MethodChannel import java.io.File class MainActivity : FlutterActivity() { - companion object { - private const val TAG = "MainActivity" - var usingSkia = false + companion object { + private const val TAG = "MainActivity" + var usingSkia = false + } + + private val PIP_CHANNEL = "com.plezy/pip" + private val EXTERNAL_PLAYER_CHANNEL = "com.plezy/external_player" + private val THEME_CHANNEL = "com.plezy/theme" + private var watchNextPlugin: WatchNextPlugin? = null + + // Auto PiP state + private var autoPipReady = false + private var autoPipWidth: Int = 16 + private var autoPipHeight: Int = 9 + + private fun isAndroidTvDevice(): Boolean = packageManager.hasSystemFeature("android.software.leanback") + + override fun onCreate(savedInstanceState: Bundle?) { + // Apply persisted theme color to the window background before anything + // else renders. This prevents a white flash between the native splash + // screen and Flutter's first frame for non-default themes (e.g. OLED). + val prefs = getSharedPreferences("plezy_prefs", Context.MODE_PRIVATE) + val savedTheme = prefs.getString("splash_theme", null) + ThemeHelper.themeColor(savedTheme)?.let { window.decorView.setBackgroundColor(it) } + + super.onCreate(savedInstanceState) + + // Disable the Android splash screen fade-out animation to avoid + // a flicker before Flutter draws its first frame. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + splashScreen.setOnExitAnimationListener { splashScreenView -> splashScreenView.remove() } } - private val PIP_CHANNEL = "com.plezy/pip" - private val EXTERNAL_PLAYER_CHANNEL = "com.plezy/external_player" - private val THEME_CHANNEL = "com.plezy/theme" - private var watchNextPlugin: WatchNextPlugin? = null - - // Auto PiP state - private var autoPipReady = false - private var autoPipWidth: Int = 16 - private var autoPipHeight: Int = 9 - - private fun isAndroidTvDevice(): Boolean { - return packageManager.hasSystemFeature("android.software.leanback") + // Disable Android's default focus highlight ring that appears when using + // D-pad navigation so the Flutter UI can render its own focus state. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + window.decorView.defaultFocusHighlightEnabled = false } - override fun onCreate(savedInstanceState: Bundle?) { - // Apply persisted theme color to the window background before anything - // else renders. This prevents a white flash between the native splash - // screen and Flutter's first frame for non-default themes (e.g. OLED). - val prefs = getSharedPreferences("plezy_prefs", Context.MODE_PRIVATE) - val savedTheme = prefs.getString("splash_theme", null) - ThemeHelper.themeColor(savedTheme)?.let { window.decorView.setBackgroundColor(it) } - - super.onCreate(savedInstanceState) - - // Disable the Android splash screen fade-out animation to avoid - // a flicker before Flutter draws its first frame. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - splashScreen.setOnExitAnimationListener { splashScreenView -> splashScreenView.remove() } - } - - // Disable Android's default focus highlight ring that appears when using - // D-pad navigation so the Flutter UI can render its own focus state. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - window.decorView.defaultFocusHighlightEnabled = false - } - - // Wrap the content view in a layout that intercepts DPAD key events - // before the IME input stage, which can consume DPAD direction events - // from virtual remotes before they reach Flutter's key handler. - val content = findViewById(android.R.id.content) - val wrapper = object : FrameLayout(this) { - override fun dispatchKeyEventPreIme(event: KeyEvent): Boolean { - when (event.keyCode) { - KeyEvent.KEYCODE_DPAD_UP, - KeyEvent.KEYCODE_DPAD_DOWN, - KeyEvent.KEYCODE_DPAD_LEFT, - KeyEvent.KEYCODE_DPAD_RIGHT, - KeyEvent.KEYCODE_DPAD_CENTER -> { - val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - if (!imm.isAcceptingText) { - super.dispatchKeyEvent(event) - return true - } - } - } - return super.dispatchKeyEventPreIme(event) + // Wrap the content view in a layout that intercepts DPAD key events + // before the IME input stage, which can consume DPAD direction events + // from virtual remotes before they reach Flutter's key handler. + val content = findViewById(android.R.id.content) + val wrapper = object : FrameLayout(this) { + override fun dispatchKeyEventPreIme(event: KeyEvent): Boolean { + when (event.keyCode) { + KeyEvent.KEYCODE_DPAD_UP, + KeyEvent.KEYCODE_DPAD_DOWN, + KeyEvent.KEYCODE_DPAD_LEFT, + KeyEvent.KEYCODE_DPAD_RIGHT, + KeyEvent.KEYCODE_DPAD_CENTER -> { + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + if (!imm.isAcceptingText) { + super.dispatchKeyEvent(event) + return true } + } } - while (content.childCount > 0) { - val child = content.getChildAt(0) - content.removeViewAt(0) - wrapper.addView(child) - } - content.addView(wrapper, ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT)) - - // Handle Watch Next deep link from initial launch - handleWatchNextIntent(intent) + return super.dispatchKeyEventPreIme(event) + } } - - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - // Handle Watch Next deep link when app is already running - handleWatchNextIntent(intent) + while (content.childCount > 0) { + val child = content.getChildAt(0) + content.removeViewAt(0) + wrapper.addView(child) } + content.addView( + wrapper, + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + ) - private fun handleWatchNextIntent(intent: Intent?) { - val contentId = WatchNextPlugin.handleIntent(intent) - if (contentId != null) { - // Notify the plugin to send event to Flutter - watchNextPlugin?.notifyDeepLink(contentId) - } + // Handle Watch Next deep link from initial launch + handleWatchNextIntent(intent) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + // Handle Watch Next deep link when app is already running + handleWatchNextIntent(intent) + } + + private fun handleWatchNextIntent(intent: Intent?) { + val contentId = WatchNextPlugin.handleIntent(intent) + if (contentId != null) { + // Notify the plugin to send event to Flutter + watchNextPlugin?.notifyDeepLink(contentId) } + } - override fun getFlutterShellArgs(): FlutterShellArgs { - val args = super.getFlutterShellArgs() - usingSkia = shouldDisableImpeller() - if (usingSkia) args.add("--enable-impeller=false") - return args + override fun getFlutterShellArgs(): FlutterShellArgs { + val args = super.getFlutterShellArgs() + usingSkia = shouldDisableImpeller() + if (usingSkia) args.add("--enable-impeller=false") + return args + } + + private fun shouldDisableImpeller(): Boolean { + // Android TV devices — weaker GPUs, less Impeller testing + if (packageManager.hasSystemFeature("android.software.leanback")) return true + // Google Tensor SoC (Mali GPU) — Pixel 6+ + // SOC_MODEL may return marketing name ("Tensor G2") or internal ID ("GS201") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val soc = Build.SOC_MODEL + if (soc.startsWith("Tensor", ignoreCase = true) || + soc.startsWith("GS", ignoreCase = true) + ) { + return true + } } - - private fun shouldDisableImpeller(): Boolean { - // Android TV devices — weaker GPUs, less Impeller testing - if (packageManager.hasSystemFeature("android.software.leanback")) return true - // Google Tensor SoC (Mali GPU) — Pixel 6+ - // SOC_MODEL may return marketing name ("Tensor G2") or internal ID ("GS201") - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val soc = Build.SOC_MODEL - if (soc.startsWith("Tensor", ignoreCase = true) || - soc.startsWith("GS", ignoreCase = true)) return true - } - // NVIDIA Tegra (Shield TV) - if (Build.MANUFACTURER.equals("NVIDIA", ignoreCase = true)) return true - // Huawei/HONOR Kirin SoCs use Mali GPUs - if (Build.MANUFACTURER.equals("Huawei", ignoreCase = true) || - Build.MANUFACTURER.equals("HONOR", ignoreCase = true)) return true - return false + // NVIDIA Tegra (Shield TV) + if (Build.MANUFACTURER.equals("NVIDIA", ignoreCase = true)) return true + // Huawei/HONOR Kirin SoCs use Mali GPUs + if (Build.MANUFACTURER.equals("Huawei", ignoreCase = true) || + Build.MANUFACTURER.equals("HONOR", ignoreCase = true) + ) { + return true } + return false + } - override fun getRenderMode(): RenderMode = RenderMode.surface + override fun getRenderMode(): RenderMode = RenderMode.surface - override fun getTransparencyMode(): TransparencyMode { - // Keep Flutter transparent so video/subtitles are visible below. - return TransparencyMode.transparent - } + override fun getTransparencyMode(): TransparencyMode { + // Keep Flutter transparent so video/subtitles are visible below. + return TransparencyMode.transparent + } - override fun configureFlutterEngine(flutterEngine: FlutterEngine) { - super.configureFlutterEngine(flutterEngine) - flutterEngine.plugins.add(MpvPlayerPlugin()) - flutterEngine.plugins.add(ExoPlayerPlugin()) + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + flutterEngine.plugins.add(MpvPlayerPlugin()) + flutterEngine.plugins.add(ExoPlayerPlugin()) - // External player: open local video files with proper content:// URIs - MethodChannel(flutterEngine.dartExecutor.binaryMessenger, EXTERNAL_PLAYER_CHANNEL).setMethodCallHandler { call, result -> - when (call.method) { - "openVideo" -> { - val filePath = call.argument("filePath") - val packageName = call.argument("package") + // External player: open local video files with proper content:// URIs + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, EXTERNAL_PLAYER_CHANNEL).setMethodCallHandler { call, result -> + when (call.method) { + "openVideo" -> { + val filePath = call.argument("filePath") + val packageName = call.argument("package") - if (filePath == null) { - result.error("INVALID_ARGUMENT", "filePath is required", null) - return@setMethodCallHandler - } + if (filePath == null) { + result.error("INVALID_ARGUMENT", "filePath is required", null) + return@setMethodCallHandler + } - try { - val uri: Uri - val grantRead: Boolean + try { + val uri: Uri + val grantRead: Boolean - if (filePath.startsWith("http://") || filePath.startsWith("https://")) { - uri = Uri.parse(filePath) - grantRead = false - } else if (filePath.startsWith("content://")) { - uri = Uri.parse(filePath) - grantRead = true - } else { - val path = if (filePath.startsWith("file://")) filePath.removePrefix("file://") else filePath - uri = FileProvider.getUriForFile(this, "com.edde746.plezy.fileprovider", File(path)) - grantRead = true - } - - val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(uri, "video/*") - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - if (grantRead) { - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - if (packageName != null) { - setPackage(packageName) - } - } - startActivity(intent) - result.success(true) - } catch (e: android.content.ActivityNotFoundException) { - result.error("APP_NOT_FOUND", "No app found for package: $packageName", null) - } catch (e: Exception) { - result.error("LAUNCH_FAILED", e.message ?: e.javaClass.simpleName, null) - } - } - else -> result.notImplemented() + if (filePath.startsWith("http://") || filePath.startsWith("https://")) { + uri = Uri.parse(filePath) + grantRead = false + } else if (filePath.startsWith("content://")) { + uri = Uri.parse(filePath) + grantRead = true + } else { + val path = if (filePath.startsWith("file://")) filePath.removePrefix("file://") else filePath + uri = FileProvider.getUriForFile(this, "com.edde746.plezy.fileprovider", File(path)) + grantRead = true } - } - // Splash screen theme: persist user's chosen theme for next launch (API 31+) - MethodChannel(flutterEngine.dartExecutor.binaryMessenger, THEME_CHANNEL).setMethodCallHandler { call, result -> - when (call.method) { - "getRenderer" -> result.success(if (usingSkia) "Skia" else "Impeller") - "setSplashTheme" -> { - val mode = call.argument("mode") - - // Persist for next cold start & update window background now - getSharedPreferences("plezy_prefs", Context.MODE_PRIVATE) - .edit().putString("splash_theme", mode).apply() - ThemeHelper.themeColor(mode)?.let { window.decorView.setBackgroundColor(it) } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val themeId = when (mode) { - "dark" -> R.style.SplashTheme_Dark - "oled" -> R.style.SplashTheme_Oled - "light" -> R.style.SplashTheme_Light - "system" -> android.content.res.Resources.ID_NULL - else -> android.content.res.Resources.ID_NULL - } - splashScreen.setSplashScreenTheme(themeId) - } - result.success(true) - } - else -> result.notImplemented() - } - } - - // Register Watch Next plugin and keep reference for deep link handling - watchNextPlugin = WatchNextPlugin() - flutterEngine.plugins.add(watchNextPlugin!!) - - MethodChannel( flutterEngine.dartExecutor.binaryMessenger, PIP_CHANNEL ).setMethodCallHandler { call, result -> - when (call.method) { - "isSupported" -> { - result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !isAndroidTvDevice()) - } - "enter" -> { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - result.success(mapOf("success" to false, "errorCode" to "android_version")) - return@setMethodCallHandler - } - - if (isAndroidTvDevice()) { - result.success(mapOf("success" to false, "errorCode" to "not_supported")) - return@setMethodCallHandler - } - - if (!isPipPermissionGranted()) { - result.success(mapOf("success" to false, "errorCode" to "permission_disabled")) - return@setMethodCallHandler - } - - try { - val width = call.argument("width") ?: 16 - val height = call.argument("height") ?: 9 - val params = buildPipParams(width, height) - val success = enterPictureInPictureMode(params) - if (success) { - result.success(mapOf("success" to true)) - } else { - result.success(mapOf("success" to false, "errorCode" to "failed")) - } - } catch (e: IllegalStateException) { - result.success(mapOf("success" to false, "errorCode" to "not_supported")) - } catch (e: Exception) { - result.success(mapOf("success" to false, "errorCode" to "unknown", "errorMessage" to (e.message ?: "Unknown error"))) - } - } - "setAutoPipReady" -> { - if (isAndroidTvDevice()) { - autoPipReady = false - result.success(true) - return@setMethodCallHandler - } - - autoPipReady = call.argument("ready") ?: false - autoPipWidth = call.argument("width") ?: 16 - autoPipHeight = call.argument("height") ?: 9 - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - try { - val params = buildPipParams(autoPipWidth, autoPipHeight, autoEnterEnabled = autoPipReady) - setPictureInPictureParams(params) - } catch (e: Exception) { - Log.w(TAG, "Failed to set auto-PiP params", e) - } - } - result.success(true) - } - else -> result.notImplemented() + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "video/*") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (grantRead) { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + if (packageName != null) { + setPackage(packageName) + } } + startActivity(intent) + result.success(true) + } catch (e: android.content.ActivityNotFoundException) { + result.error("APP_NOT_FOUND", "No app found for package: $packageName", null) + } catch (e: Exception) { + result.error("LAUNCH_FAILED", e.message ?: e.javaClass.simpleName, null) + } } + else -> result.notImplemented() + } } - override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean,newConfig: Configuration) { - super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) - flutterEngine?.let { engine -> - MethodChannel(engine.dartExecutor.binaryMessenger, PIP_CHANNEL).invokeMethod("onPipChanged", isInPictureInPictureMode) - engine.plugins.get(ExoPlayerPlugin::class.java)?.let { plugin -> - (plugin as? ExoPlayerPlugin)?.onPipModeChanged(isInPictureInPictureMode) + // Splash screen theme: persist user's chosen theme for next launch (API 31+) + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, THEME_CHANNEL).setMethodCallHandler { call, result -> + when (call.method) { + "getRenderer" -> result.success(if (usingSkia) "Skia" else "Impeller") + "setSplashTheme" -> { + val mode = call.argument("mode") + + // Persist for next cold start & update window background now + getSharedPreferences("plezy_prefs", Context.MODE_PRIVATE) + .edit().putString("splash_theme", mode).apply() + ThemeHelper.themeColor(mode)?.let { window.decorView.setBackgroundColor(it) } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val themeId = when (mode) { + "dark" -> R.style.SplashTheme_Dark + "oled" -> R.style.SplashTheme_Oled + "light" -> R.style.SplashTheme_Light + "system" -> android.content.res.Resources.ID_NULL + else -> android.content.res.Resources.ID_NULL } + splashScreen.setSplashScreenTheme(themeId) + } + result.success(true) } + else -> result.notImplemented() + } } - override fun onUserLeaveHint() { - super.onUserLeaveHint() - // Auto PiP for API 26-30 (API 31+ uses setAutoEnterEnabled) - if (!isAndroidTvDevice() && - Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && - Build.VERSION.SDK_INT < Build.VERSION_CODES.S && - autoPipReady && isPipPermissionGranted()) { + // Register Watch Next plugin and keep reference for deep link handling + watchNextPlugin = WatchNextPlugin() + flutterEngine.plugins.add(watchNextPlugin!!) + + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, PIP_CHANNEL).setMethodCallHandler { call, result -> + when (call.method) { + "isSupported" -> { + result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !isAndroidTvDevice()) + } + "enter" -> { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + result.success(mapOf("success" to false, "errorCode" to "android_version")) + return@setMethodCallHandler + } + + if (isAndroidTvDevice()) { + result.success(mapOf("success" to false, "errorCode" to "not_supported")) + return@setMethodCallHandler + } + + if (!isPipPermissionGranted()) { + result.success(mapOf("success" to false, "errorCode" to "permission_disabled")) + return@setMethodCallHandler + } + + try { + val width = call.argument("width") ?: 16 + val height = call.argument("height") ?: 9 + val params = buildPipParams(width, height) + val success = enterPictureInPictureMode(params) + if (success) { + result.success(mapOf("success" to true)) + } else { + result.success(mapOf("success" to false, "errorCode" to "failed")) + } + } catch (e: IllegalStateException) { + result.success(mapOf("success" to false, "errorCode" to "not_supported")) + } catch (e: Exception) { + result.success(mapOf("success" to false, "errorCode" to "unknown", "errorMessage" to (e.message ?: "Unknown error"))) + } + } + "setAutoPipReady" -> { + if (isAndroidTvDevice()) { + autoPipReady = false + result.success(true) + return@setMethodCallHandler + } + + autoPipReady = call.argument("ready") ?: false + autoPipWidth = call.argument("width") ?: 16 + autoPipHeight = call.argument("height") ?: 9 + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { try { - // Notify Flutter to prepare video filter before PiP - flutterEngine?.dartExecutor?.binaryMessenger?.let { messenger -> - MethodChannel(messenger, PIP_CHANNEL).invokeMethod("onAutoPipEntering", null) - } - val params = buildPipParams(autoPipWidth, autoPipHeight) - enterPictureInPictureMode(params) + val params = buildPipParams(autoPipWidth, autoPipHeight, autoEnterEnabled = autoPipReady) + setPictureInPictureParams(params) } catch (e: Exception) { - Log.w(TAG, "Failed to enter auto-PiP", e) + Log.w(TAG, "Failed to set auto-PiP params", e) } + } + result.success(true) } + else -> result.notImplemented() + } } + } - private fun isPipPermissionGranted(): Boolean { - val appOpsManager = getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager - return appOpsManager.checkOpNoThrow( - AppOpsManager.OPSTR_PICTURE_IN_PICTURE, - applicationInfo.uid, - packageName - ) == AppOpsManager.MODE_ALLOWED + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + flutterEngine?.let { engine -> + MethodChannel(engine.dartExecutor.binaryMessenger, PIP_CHANNEL).invokeMethod("onPipChanged", isInPictureInPictureMode) + engine.plugins.get(ExoPlayerPlugin::class.java)?.let { plugin -> + (plugin as? ExoPlayerPlugin)?.onPipModeChanged(isInPictureInPictureMode) + } } + } - private fun buildPipParams(width: Int, height: Int, autoEnterEnabled: Boolean? = null): PictureInPictureParams { - val (w, h) = if (width <= 0 || height <= 0) { - Pair(16, 9) - } else { - val ratio = width.toFloat() / height.toFloat() - when { - ratio < 1f / 2.39f -> Pair(100, 239) - ratio > 2.39f -> Pair(239, 100) - else -> Pair(width, height) - } + override fun onUserLeaveHint() { + super.onUserLeaveHint() + // Auto PiP for API 26-30 (API 31+ uses setAutoEnterEnabled) + if (!isAndroidTvDevice() && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + Build.VERSION.SDK_INT < Build.VERSION_CODES.S && + autoPipReady && + isPipPermissionGranted() + ) { + try { + // Notify Flutter to prepare video filter before PiP + flutterEngine?.dartExecutor?.binaryMessenger?.let { messenger -> + MethodChannel(messenger, PIP_CHANNEL).invokeMethod("onAutoPipEntering", null) } - val builder = PictureInPictureParams.Builder() - .setAspectRatio(Rational(w, h)) - if (autoEnterEnabled != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - builder.setAutoEnterEnabled(autoEnterEnabled) - } - return builder.build() + val params = buildPipParams(autoPipWidth, autoPipHeight) + enterPictureInPictureMode(params) + } catch (e: Exception) { + Log.w(TAG, "Failed to enter auto-PiP", e) + } } + } + + private fun isPipPermissionGranted(): Boolean { + val appOpsManager = getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager + return appOpsManager.checkOpNoThrow( + AppOpsManager.OPSTR_PICTURE_IN_PICTURE, + applicationInfo.uid, + packageName + ) == AppOpsManager.MODE_ALLOWED + } + + private fun buildPipParams(width: Int, height: Int, autoEnterEnabled: Boolean? = null): PictureInPictureParams { + val (w, h) = if (width <= 0 || height <= 0) { + Pair(16, 9) + } else { + val ratio = width.toFloat() / height.toFloat() + when { + ratio < 1f / 2.39f -> Pair(100, 239) + ratio > 2.39f -> Pair(239, 100) + else -> Pair(width, height) + } + } + val builder = PictureInPictureParams.Builder() + .setAspectRatio(Rational(w, h)) + if (autoEnterEnabled != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + builder.setAutoEnterEnabled(autoEnterEnabled) + } + return builder.build() + } } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/CuelessSeekExtractorWrapper.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/CuelessSeekExtractorWrapper.kt index a99f1940..751928b7 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/CuelessSeekExtractorWrapper.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/CuelessSeekExtractorWrapper.kt @@ -19,149 +19,151 @@ import androidx.media3.extractor.TrackOutput */ @androidx.media3.common.util.UnstableApi class CuelessSeekExtractorWrapper( - private val delegate: Extractor, + private val delegate: Extractor ) : Extractor { - companion object { - private const val TAG = "CuelessSeek" - // MKV Cluster element ID: 0x1F43B675 (4-byte EBML Class-D ID) - private val CLUSTER_ID = byteArrayOf(0x1F, 0x43, 0xB6.toByte(), 0x75) - private const val SCAN_BUFFER_SIZE = 8192 - // Max bytes to scan for a Cluster boundary before giving up - private const val MAX_SCAN_BYTES = 1024 * 1024 // 1 MB + companion object { + private const val TAG = "CuelessSeek" + + // MKV Cluster element ID: 0x1F43B675 (4-byte EBML Class-D ID) + private val CLUSTER_ID = byteArrayOf(0x1F, 0x43, 0xB6.toByte(), 0x75) + private const val SCAN_BUFFER_SIZE = 8192 + + // Max bytes to scan for a Cluster boundary before giving up + private const val MAX_SCAN_BYTES = 1024 * 1024 // 1 MB + } + + private var inputLength: Long = C.LENGTH_UNSET.toLong() + private var needsClusterResync = false + private var isApproximateSeeking = false + private var pendingSeekTimeUs: Long = C.TIME_UNSET + + override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input) + + override fun init(output: ExtractorOutput) { + delegate.init(SeekInterceptingOutput(output)) + } + + override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int { + if (inputLength == C.LENGTH_UNSET.toLong()) { + inputLength = input.length } - - private var inputLength: Long = C.LENGTH_UNSET.toLong() - private var needsClusterResync = false - private var isApproximateSeeking = false - private var pendingSeekTimeUs: Long = C.TIME_UNSET - - override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input) - - override fun init(output: ExtractorOutput) { - delegate.init(SeekInterceptingOutput(output)) + if (needsClusterResync) { + needsClusterResync = false + return scanForCluster(input, seekPosition) } + return delegate.read(input, seekPosition) + } - override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int { - if (inputLength == C.LENGTH_UNSET.toLong()) { - inputLength = input.length + override fun seek(position: Long, timeUs: Long) { + if (isApproximateSeeking && position > 0) { + needsClusterResync = true + pendingSeekTimeUs = timeUs + } + delegate.seek(position, timeUs) + } + + override fun release() = delegate.release() + + /** + * Scan forward from the current input position to find the next MKV Cluster + * element ID (0x1F43B675). Returns [Extractor.RESULT_SEEK] with the Cluster's + * byte position so ExoPlayer repositions the DataSource there. + */ + private fun scanForCluster(input: ExtractorInput, seekPosition: PositionHolder): Int { + val buffer = ByteArray(SCAN_BUFFER_SIZE) + var totalScanned = 0L + // Carry over last 3 bytes across buffer boundaries to detect split IDs + var carry = ByteArray(0) + + while (totalScanned < MAX_SCAN_BYTES) { + val toRead = minOf(SCAN_BUFFER_SIZE, (MAX_SCAN_BYTES - totalScanned).toInt()) + val bytesRead: Int + try { + bytesRead = input.read(buffer, 0, toRead) + } catch (_: Exception) { + break + } + if (bytesRead == C.RESULT_END_OF_INPUT) break + + // Combine carry + new data for scanning + val scanData = if (carry.isNotEmpty()) carry + buffer.copyOf(bytesRead) else buffer.copyOf(bytesRead) + + for (i in 0..scanData.size - 4) { + if (scanData[i] == CLUSTER_ID[0] && + scanData[i + 1] == CLUSTER_ID[1] && + scanData[i + 2] == CLUSTER_ID[2] && + scanData[i + 3] == CLUSTER_ID[3] + ) { + // Compute the absolute byte position of this Cluster + val clusterPosition = input.position - bytesRead - carry.size + i + Log.d(TAG, "Found Cluster at byte $clusterPosition (scanned ${totalScanned + i} bytes)") + seekPosition.position = clusterPosition + delegate.seek(clusterPosition, pendingSeekTimeUs) + pendingSeekTimeUs = C.TIME_UNSET + return Extractor.RESULT_SEEK } - if (needsClusterResync) { - needsClusterResync = false - return scanForCluster(input, seekPosition) - } - return delegate.read(input, seekPosition) + } + + // Keep last 3 bytes as carry for next iteration + carry = if (scanData.size >= 3) scanData.copyOfRange(scanData.size - 3, scanData.size) else scanData.copyOf() + totalScanned += bytesRead } - override fun seek(position: Long, timeUs: Long) { - if (isApproximateSeeking && position > 0) { - needsClusterResync = true - pendingSeekTimeUs = timeUs + // Failed to find a Cluster — fall back to position 0 + Log.w(TAG, "No Cluster found after scanning $totalScanned bytes, resetting to start") + seekPosition.position = 0 + delegate.seek(0, 0) + return Extractor.RESULT_SEEK + } + + /** + * ExtractorOutput wrapper that intercepts [seekMap] calls to replace + * [SeekMap.Unseekable] with an approximate proportional SeekMap. + */ + private inner class SeekInterceptingOutput( + private val delegate: ExtractorOutput + ) : ExtractorOutput { + + override fun track(id: Int, type: Int): TrackOutput = delegate.track(id, type) + override fun endTracks() = delegate.endTracks() + + override fun seekMap(seekMap: SeekMap) { + if (seekMap is SeekMap.Unseekable) { + val durationUs = seekMap.durationUs + if (durationUs != C.TIME_UNSET && durationUs > 0) { + Log.i(TAG, "Replacing Unseekable with approximate SeekMap (duration=${durationUs / 1_000_000}s)") + isApproximateSeeking = true + delegate.seekMap(ApproximateSeekMap(durationUs)) + return } - delegate.seek(position, timeUs) + } + // File has real Cues or unknown duration — pass through + isApproximateSeeking = false + delegate.seekMap(seekMap) } + } - override fun release() = delegate.release() + /** + * Approximate SeekMap that estimates byte positions proportionally. + * Used when the MKV has no Cues but has a known duration. + */ + private inner class ApproximateSeekMap( + private val durationUs: Long + ) : SeekMap { - /** - * Scan forward from the current input position to find the next MKV Cluster - * element ID (0x1F43B675). Returns [Extractor.RESULT_SEEK] with the Cluster's - * byte position so ExoPlayer repositions the DataSource there. - */ - private fun scanForCluster(input: ExtractorInput, seekPosition: PositionHolder): Int { - val buffer = ByteArray(SCAN_BUFFER_SIZE) - var totalScanned = 0L - // Carry over last 3 bytes across buffer boundaries to detect split IDs - var carry = ByteArray(0) + override fun isSeekable(): Boolean = true - while (totalScanned < MAX_SCAN_BYTES) { - val toRead = minOf(SCAN_BUFFER_SIZE, (MAX_SCAN_BYTES - totalScanned).toInt()) - val bytesRead: Int - try { - bytesRead = input.read(buffer, 0, toRead) - } catch (_: Exception) { - break - } - if (bytesRead == C.RESULT_END_OF_INPUT) break + override fun getDurationUs(): Long = durationUs - // Combine carry + new data for scanning - val scanData = if (carry.isNotEmpty()) carry + buffer.copyOf(bytesRead) else buffer.copyOf(bytesRead) - - for (i in 0..scanData.size - 4) { - if (scanData[i] == CLUSTER_ID[0] && - scanData[i + 1] == CLUSTER_ID[1] && - scanData[i + 2] == CLUSTER_ID[2] && - scanData[i + 3] == CLUSTER_ID[3] - ) { - // Compute the absolute byte position of this Cluster - val clusterPosition = input.position - bytesRead - carry.size + i - Log.d(TAG, "Found Cluster at byte $clusterPosition (scanned ${totalScanned + i} bytes)") - seekPosition.position = clusterPosition - delegate.seek(clusterPosition, pendingSeekTimeUs) - pendingSeekTimeUs = C.TIME_UNSET - return Extractor.RESULT_SEEK - } - } - - // Keep last 3 bytes as carry for next iteration - carry = if (scanData.size >= 3) scanData.copyOfRange(scanData.size - 3, scanData.size) else scanData.copyOf() - totalScanned += bytesRead - } - - // Failed to find a Cluster — fall back to position 0 - Log.w(TAG, "No Cluster found after scanning $totalScanned bytes, resetting to start") - seekPosition.position = 0 - delegate.seek(0, 0) - return Extractor.RESULT_SEEK - } - - /** - * ExtractorOutput wrapper that intercepts [seekMap] calls to replace - * [SeekMap.Unseekable] with an approximate proportional SeekMap. - */ - private inner class SeekInterceptingOutput( - private val delegate: ExtractorOutput, - ) : ExtractorOutput { - - override fun track(id: Int, type: Int): TrackOutput = delegate.track(id, type) - override fun endTracks() = delegate.endTracks() - - override fun seekMap(seekMap: SeekMap) { - if (seekMap is SeekMap.Unseekable) { - val durationUs = seekMap.durationUs - if (durationUs != C.TIME_UNSET && durationUs > 0) { - Log.i(TAG, "Replacing Unseekable with approximate SeekMap (duration=${durationUs / 1_000_000}s)") - isApproximateSeeking = true - delegate.seekMap(ApproximateSeekMap(durationUs)) - return - } - } - // File has real Cues or unknown duration — pass through - isApproximateSeeking = false - delegate.seekMap(seekMap) - } - } - - /** - * Approximate SeekMap that estimates byte positions proportionally. - * Used when the MKV has no Cues but has a known duration. - */ - private inner class ApproximateSeekMap( - private val durationUs: Long, - ) : SeekMap { - - override fun isSeekable(): Boolean = true - - override fun getDurationUs(): Long = durationUs - - override fun getSeekPoints(timeUs: Long): SeekMap.SeekPoints { - val length = inputLength - if (length == C.LENGTH_UNSET.toLong() || durationUs <= 0) { - return SeekMap.SeekPoints(SeekPoint(0, 0)) - } - val clampedTimeUs = timeUs.coerceIn(0, durationUs) - val position = (clampedTimeUs.toDouble() / durationUs * length).toLong().coerceIn(0, length) - return SeekMap.SeekPoints(SeekPoint(clampedTimeUs, position)) - } + override fun getSeekPoints(timeUs: Long): SeekMap.SeekPoints { + val length = inputLength + if (length == C.LENGTH_UNSET.toLong() || durationUs <= 0) { + return SeekMap.SeekPoints(SeekPoint(0, 0)) + } + val clampedTimeUs = timeUs.coerceIn(0, durationUs) + val position = (clampedTimeUs.toDouble() / durationUs * length).toLong().coerceIn(0, length) + return SeekMap.SeekPoints(SeekPoint(clampedTimeUs, position)) } + } } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviBridge.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviBridge.kt index 64423770..b2f3cf55 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviBridge.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviBridge.kt @@ -8,78 +8,79 @@ import android.util.Log enum class DvConversionMode { DISABLED, DV81, HEVC_STRIP } object DoviBridge { - private const val TAG = "DoviBridge" + private const val TAG = "DoviBridge" - private val nativeLoaded: Boolean by lazy { - try { - System.loadLibrary("dovi_bridge") - true - } catch (_: UnsatisfiedLinkError) { - Log.w(TAG, "Native lib not found") - false - } + private val nativeLoaded: Boolean by lazy { + try { + System.loadLibrary("dovi_bridge") + true + } catch (_: UnsatisfiedLinkError) { + Log.w(TAG, "Native lib not found") + false } + } - fun isAvailable(): Boolean = nativeLoaded && - runCatching { nativeIsConversionPathReady() }.getOrDefault(false) + fun isAvailable(): Boolean = nativeLoaded && + runCatching { nativeIsConversionPathReady() }.getOrDefault(false) - private fun deviceSupportsDvProfile(profile: Int, minApi: Int = 0): Boolean { - try { - if (Build.VERSION.SDK_INT < minApi) return false - val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS) - return codecList.codecInfos.any { info -> - !info.isEncoder && info.supportedTypes.any { type -> - type.equals("video/dolby-vision", ignoreCase = true) && - info.getCapabilitiesForType(type).profileLevels.any { it.profile == profile } - } - } - } catch (e: Exception) { - Log.w(TAG, "Failed to query DV profile $profile support", e) - return false - } + private fun deviceSupportsDvProfile(profile: Int, minApi: Int = 0): Boolean { + try { + if (Build.VERSION.SDK_INT < minApi) return false + val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS) + return codecList.codecInfos.any { info -> + !info.isEncoder && + info.supportedTypes.any { type -> + type.equals("video/dolby-vision", ignoreCase = true) && + info.getCapabilitiesForType(type).profileLevels.any { it.profile == profile } + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to query DV profile $profile support", e) + return false } + } - val deviceSupportsDvProfile7: Boolean by lazy { - deviceSupportsDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDtr) - .also { Log.i(TAG, "Device DV Profile 7 support: $it") } - } + val deviceSupportsDvProfile7: Boolean by lazy { + deviceSupportsDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDtr) + .also { Log.i(TAG, "Device DV Profile 7 support: $it") } + } - val deviceSupportsDvProfile8: Boolean by lazy { - deviceSupportsDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheSt, minApi = 27) - .also { Log.i(TAG, "Device DV Profile 8 support: $it") } - } + val deviceSupportsDvProfile8: Boolean by lazy { + deviceSupportsDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheSt, minApi = 27) + .also { Log.i(TAG, "Device DV Profile 8 support: $it") } + } - fun getConversionMode(): DvConversionMode = when { - !isAvailable() -> DvConversionMode.DISABLED - deviceSupportsDvProfile7 -> DvConversionMode.DISABLED // try native first; ExoPlayerCore retries with conversion on failure - deviceSupportsDvProfile8 -> DvConversionMode.DV81 - else -> DvConversionMode.HEVC_STRIP - } + fun getConversionMode(): DvConversionMode = when { + !isAvailable() -> DvConversionMode.DISABLED + deviceSupportsDvProfile7 -> DvConversionMode.DISABLED // try native first; ExoPlayerCore retries with conversion on failure + deviceSupportsDvProfile8 -> DvConversionMode.DV81 + else -> DvConversionMode.HEVC_STRIP + } - /** Get the fallback mode when native DV7 decoding fails. */ - fun getDv7FallbackMode(): DvConversionMode = when { - deviceSupportsDvProfile8 -> DvConversionMode.DV81 - else -> DvConversionMode.HEVC_STRIP - } + /** Get the fallback mode when native DV7 decoding fails. */ + fun getDv7FallbackMode(): DvConversionMode = when { + deviceSupportsDvProfile8 -> DvConversionMode.DV81 + else -> DvConversionMode.HEVC_STRIP + } - fun convertRpuNalu(payload: ByteArray, mode: Int = 2): ByteArray? { - if (!isAvailable() || payload.isEmpty()) return null - return runCatching { nativeConvertDv7RpuToDv81(payload, mode) } - .onFailure { Log.w(TAG, "RPU conversion failed: ${it.message}") } - .getOrNull() - } + fun convertRpuNalu(payload: ByteArray, mode: Int = 2): ByteArray? { + if (!isAvailable() || payload.isEmpty()) return null + return runCatching { nativeConvertDv7RpuToDv81(payload, mode) } + .onFailure { Log.w(TAG, "RPU conversion failed: ${it.message}") } + .getOrNull() + } - fun getVersion(): String? { - if (!nativeLoaded) return null - return runCatching { nativeGetBridgeVersion() }.getOrNull() - } + fun getVersion(): String? { + if (!nativeLoaded) return null + return runCatching { nativeGetBridgeVersion() }.getOrNull() + } - @JvmStatic - private external fun nativeConvertDv7RpuToDv81(payload: ByteArray, mode: Int): ByteArray? + @JvmStatic + private external fun nativeConvertDv7RpuToDv81(payload: ByteArray, mode: Int): ByteArray? - @JvmStatic - private external fun nativeIsConversionPathReady(): Boolean + @JvmStatic + private external fun nativeIsConversionPathReady(): Boolean - @JvmStatic - private external fun nativeGetBridgeVersion(): String + @JvmStatic + private external fun nativeGetBridgeVersion(): String } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviConvertingTrackOutput.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviConvertingTrackOutput.kt index 7843f5ec..7903553f 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviConvertingTrackOutput.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviConvertingTrackOutput.kt @@ -27,411 +27,446 @@ import androidx.media3.extractor.TrackOutput * All buffers are reused across samples to minimize GC pressure on the hot path. */ class DoviConvertingTrackOutput( - private val delegate: TrackOutput, - private val dvMode: DvConversionMode = DvConversionMode.HEVC_STRIP, + private val delegate: TrackOutput, + private val dvMode: DvConversionMode = DvConversionMode.HEVC_STRIP ) : TrackOutput { - companion object { - private const val TAG = "DoviConvertTrack" - private const val NAL_TYPE_UNSPEC62 = 62 - private const val NAL_TYPE_UNSPEC63 = 63 - private const val LIBDOVI_MODE_TO_81 = 2 - private const val INITIAL_BUFFER_SIZE = 256 * 1024 - private const val READ_CHUNK = 64 * 1024 - private val ANNEX_B_START_CODE = byteArrayOf(0, 0, 0, 1) - } + companion object { + private const val TAG = "DoviConvertTrack" + private const val NAL_TYPE_UNSPEC62 = 62 + private const val NAL_TYPE_UNSPEC63 = 63 + private const val LIBDOVI_MODE_TO_81 = 2 + private const val INITIAL_BUFFER_SIZE = 256 * 1024 + private const val READ_CHUNK = 64 * 1024 + private val ANNEX_B_START_CODE = byteArrayOf(0, 0, 0, 1) + } - var conversionActive = false - private set - var strippedNalCount = 0L - private set - var convertedRpuCount = 0L - private set + var conversionActive = false + private set + var strippedNalCount = 0L + private set + var convertedRpuCount = 0L + private set - // Reusable buffers — grown as needed, never shrunk - private var sampleBuf = ByteArray(INITIAL_BUFFER_SIZE) - private var sampleLen = 0 - private var outputBuf = ByteArray(INITIAL_BUFFER_SIZE) - private var outputLen = 0 - private var readBuf = ByteArray(READ_CHUNK) - private val outputParsable = ParsableByteArray() - private var buffering = false + // Reusable buffers — grown as needed, never shrunk + private var sampleBuf = ByteArray(INITIAL_BUFFER_SIZE) + private var sampleLen = 0 + private var outputBuf = ByteArray(INITIAL_BUFFER_SIZE) + private var outputLen = 0 + private var readBuf = ByteArray(READ_CHUNK) + private val outputParsable = ParsableByteArray() + private var buffering = false - // Sample counter for periodic logging - private var sampleCount = 0L + // Sample counter for periodic logging + private var sampleCount = 0L - override fun format(format: Format) { - if (!conversionActive) { - val codecs = format.codecs - if (codecs != null && codecs.startsWith("dvhe.07")) { - conversionActive = true - Log.i(TAG, "DV Profile 7 detected ($codecs), mode=$dvMode") - Log.i(TAG, "Original format: mime=${format.sampleMimeType}, codecs=$codecs, " + - "initData=${format.initializationData.size} entries " + - "(${format.initializationData.mapIndexed { i, d -> "$i:${d.size}B" }.joinToString()})") + override fun format(format: Format) { + if (!conversionActive) { + val codecs = format.codecs + if (codecs != null && codecs.startsWith("dvhe.07")) { + conversionActive = true + Log.i(TAG, "DV Profile 7 detected ($codecs), mode=$dvMode") + Log.i( + TAG, + "Original format: mime=${format.sampleMimeType}, codecs=$codecs, " + + "initData=${format.initializationData.size} entries " + + "(${format.initializationData.mapIndexed { i, d -> "$i:${d.size}B" }.joinToString()})" + ) - val newFormat = when (dvMode) { - DvConversionMode.DV81 -> { - // Parse DV level from codec string: "dvhe.07.06" → 6 - val level = codecs.split('.').getOrNull(2)?.toIntOrNull() ?: 6 - val newCodecs = "dvhe.08.%02d".format(level) - val dvConfigRecord = buildDv81ConfigRecord(level) - Log.i(TAG, "DV81: rewriting to $newCodecs, config=${dvConfigRecord.size}B") + val newFormat = when (dvMode) { + DvConversionMode.DV81 -> { + // Parse DV level from codec string: "dvhe.07.06" → 6 + val level = codecs.split('.').getOrNull(2)?.toIntOrNull() ?: 6 + val newCodecs = "dvhe.08.%02d".format(level) + val dvConfigRecord = buildDv81ConfigRecord(level) + Log.i(TAG, "DV81: rewriting to $newCodecs, config=${dvConfigRecord.size}B") - format.buildUpon() - .setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION) - .setCodecs(newCodecs) - .setInitializationData( - if (format.initializationData.isNotEmpty()) - listOf(format.initializationData[0], dvConfigRecord) - else - listOf(ByteArray(0), dvConfigRecord) - ) - .build() - } - else -> { - // HEVC_STRIP: present as plain HEVC - Log.i(TAG, "HEVC_STRIP: rewriting to video/hevc") - format.buildUpon() - .setSampleMimeType(MimeTypes.VIDEO_H265) - .setCodecs(null) - .setInitializationData( - if (format.initializationData.isNotEmpty()) - listOf(format.initializationData[0]) - else - emptyList() - ) - .build() - } + format.buildUpon() + .setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION) + .setCodecs(newCodecs) + .setInitializationData( + if (format.initializationData.isNotEmpty()) { + listOf(format.initializationData[0], dvConfigRecord) + } else { + listOf(ByteArray(0), dvConfigRecord) } - - Log.i(TAG, "Rewritten format: mime=${newFormat.sampleMimeType}, " + - "codecs=${newFormat.codecs}, initData=${newFormat.initializationData.size} entries") - delegate.format(newFormat) - return - } + ) + .build() + } + else -> { + // HEVC_STRIP: present as plain HEVC + Log.i(TAG, "HEVC_STRIP: rewriting to video/hevc") + format.buildUpon() + .setSampleMimeType(MimeTypes.VIDEO_H265) + .setCodecs(null) + .setInitializationData( + if (format.initializationData.isNotEmpty()) { + listOf(format.initializationData[0]) + } else { + emptyList() + } + ) + .build() + } } - delegate.format(format) + + Log.i( + TAG, + "Rewritten format: mime=${newFormat.sampleMimeType}, " + + "codecs=${newFormat.codecs}, initData=${newFormat.initializationData.size} entries" + ) + delegate.format(newFormat) + return + } + } + delegate.format(format) + } + + override fun sampleData( + input: DataReader, + length: Int, + allowEndOfInput: Boolean, + sampleDataPart: Int + ): Int { + if (!conversionActive) { + return delegate.sampleData(input, length, allowEndOfInput, sampleDataPart) } - override fun sampleData( - input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int - ): Int { - if (!conversionActive) { - return delegate.sampleData(input, length, allowEndOfInput, sampleDataPart) - } + buffering = true + if (readBuf.size < length) readBuf = ByteArray(length) + val bytesRead = input.read(readBuf, 0, length) + if (bytesRead > 0) { + ensureSampleCapacity(sampleLen + bytesRead) + System.arraycopy(readBuf, 0, sampleBuf, sampleLen, bytesRead) + sampleLen += bytesRead + } + return bytesRead + } - buffering = true - if (readBuf.size < length) readBuf = ByteArray(length) - val bytesRead = input.read(readBuf, 0, length) - if (bytesRead > 0) { - ensureSampleCapacity(sampleLen + bytesRead) - System.arraycopy(readBuf, 0, sampleBuf, sampleLen, bytesRead) - sampleLen += bytesRead - } - return bytesRead + override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) { + if (!conversionActive) { + delegate.sampleData(data, length, sampleDataPart) + return } - override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) { - if (!conversionActive) { - delegate.sampleData(data, length, sampleDataPart) - return - } + buffering = true + ensureSampleCapacity(sampleLen + length) + data.readBytes(sampleBuf, sampleLen, length) + sampleLen += length + } - buffering = true - ensureSampleCapacity(sampleLen + length) - data.readBytes(sampleBuf, sampleLen, length) - sampleLen += length + override fun sampleMetadata( + timeUs: Long, + flags: Int, + size: Int, + offset: Int, + cryptoData: TrackOutput.CryptoData? + ) { + if (!conversionActive || !buffering) { + delegate.sampleMetadata(timeUs, flags, size, offset, cryptoData) + return } - override fun sampleMetadata( - timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData? - ) { - if (!conversionActive || !buffering) { - delegate.sampleMetadata(timeUs, flags, size, offset, cryptoData) - return - } + buffering = false + val srcLen = sampleLen + sampleLen = 0 - buffering = false - val srcLen = sampleLen - sampleLen = 0 + val outLen: Int + val outBuf: ByteArray + val success = try { + processNalUnits(srcLen) + true + } catch (e: Exception) { + Log.e(TAG, "NAL processing failed, passing raw sample", e) + false + } + if (success) { + outLen = outputLen + outBuf = outputBuf + } else { + outLen = srcLen + outBuf = sampleBuf + } - val outLen: Int - val outBuf: ByteArray - val success = try { - processNalUnits(srcLen) - true - } catch (e: Exception) { - Log.e(TAG, "NAL processing failed, passing raw sample", e) - false + // Skip empty samples (all NALs were DV layers) — don't confuse the decoder + if (outLen == 0) return + + outputParsable.reset(outBuf, outLen) + delegate.sampleData(outputParsable, outLen, TrackOutput.SAMPLE_DATA_PART_MAIN) + delegate.sampleMetadata(timeUs, flags, outLen, 0, cryptoData) + } + + /** + * Process NAL units in sampleBuf[0..dataLen). Auto-detects format: + * - Annex B (00 00 00 01 / 00 00 01 start codes) — used by MatroskaExtractor + * - Length-prefixed (4-byte big-endian length) — used by Mp4Extractor + * + * Result is written to outputBuf[0..outputLen). + */ + private fun processNalUnits(dataLen: Int) { + outputLen = 0 + if (dataLen < 4) { + ensureOutputCapacity(dataLen) + System.arraycopy(sampleBuf, 0, outputBuf, 0, dataLen) + outputLen = dataLen + return + } + + // Auto-detect: Annex B starts with 00 00 00 01 or 00 00 01 + val isAnnexB = ( + dataLen >= 4 && + sampleBuf[0] == 0.toByte() && + sampleBuf[1] == 0.toByte() && + sampleBuf[2] == 0.toByte() && + sampleBuf[3] == 1.toByte() + ) || + ( + dataLen >= 3 && + sampleBuf[0] == 0.toByte() && + sampleBuf[1] == 0.toByte() && + sampleBuf[2] == 1.toByte() + ) + + if (sampleCount == 0L) { + Log.d( + TAG, + "NAL format detected: ${if (isAnnexB) "Annex B" else "length-prefixed"}, " + + "first bytes: ${sampleBuf.take(8).joinToString(" ") { "%02X".format(it) }}" + ) + } + + if (isAnnexB) processAnnexBNals(dataLen) else processLengthPrefixedNals(dataLen) + } + + /** Process Annex B formatted NAL units (MKV path). Scans inline, no list allocation. */ + private fun processAnnexBNals(dataLen: Int) { + ensureOutputCapacity(dataLen) + var kept = 0 + var stripped = 0 + + // Find first start code + var scEnd = -1 + var i = 0 + while (i < dataLen - 2) { + if (sampleBuf[i] == 0.toByte() && sampleBuf[i + 1] == 0.toByte()) { + if (i + 3 < dataLen && sampleBuf[i + 2] == 0.toByte() && sampleBuf[i + 3] == 1.toByte()) { + scEnd = i + 4 + break + } else if (sampleBuf[i + 2] == 1.toByte()) { + scEnd = i + 3 + break } - if (success) { - outLen = outputLen - outBuf = outputBuf + } + i++ + } + + if (scEnd < 0) { + // No start codes found — pass through + System.arraycopy(sampleBuf, 0, outputBuf, 0, dataLen) + outputLen = dataLen + sampleCount++ + return + } + + var nalStart = scEnd + + while (nalStart < dataLen) { + // Find next start code to determine end of current NAL + var nalEnd = dataLen + i = nalStart + while (i < dataLen - 2) { + if (sampleBuf[i] == 0.toByte() && sampleBuf[i + 1] == 0.toByte()) { + if (i + 3 < dataLen && sampleBuf[i + 2] == 0.toByte() && sampleBuf[i + 3] == 1.toByte()) { + nalEnd = i + break + } else if (sampleBuf[i + 2] == 1.toByte()) { + nalEnd = i + break + } + } + i++ + } + + val nalLen = nalEnd - nalStart + if (nalLen > 0) { + val action = processNalInline(nalStart, nalLen) + if (action == NalAction.KEEP) { + ensureOutputCapacity(outputLen + 4 + nalLen) + System.arraycopy(ANNEX_B_START_CODE, 0, outputBuf, outputLen, 4) + outputLen += 4 + System.arraycopy(sampleBuf, nalStart, outputBuf, outputLen, nalLen) + normalizeLayerId(outputBuf, outputLen) + outputLen += nalLen + kept++ + } else if (action == NalAction.CONVERT) { + val converted = DoviBridge.convertRpuNalu( + sampleBuf.copyOfRange(nalStart, nalStart + nalLen), + LIBDOVI_MODE_TO_81 + ) + if (converted != null) { + normalizeLayerId(converted, 0) + ensureOutputCapacity(outputLen + 4 + converted.size) + System.arraycopy(ANNEX_B_START_CODE, 0, outputBuf, outputLen, 4) + outputLen += 4 + System.arraycopy(converted, 0, outputBuf, outputLen, converted.size) + outputLen += converted.size + convertedRpuCount++ + kept++ + } else { + strippedNalCount++ + stripped++ + } } else { - outLen = srcLen - outBuf = sampleBuf + strippedNalCount++ + stripped++ } + } - // Skip empty samples (all NALs were DV layers) — don't confuse the decoder - if (outLen == 0) return - - outputParsable.reset(outBuf, outLen) - delegate.sampleData(outputParsable, outLen, TrackOutput.SAMPLE_DATA_PART_MAIN) - delegate.sampleMetadata(timeUs, flags, outLen, 0, cryptoData) + // Advance past the next start code + if (nalEnd >= dataLen) break + nalStart = if (nalEnd + 3 < dataLen && sampleBuf[nalEnd + 2] == 0.toByte() && sampleBuf[nalEnd + 3] == 1.toByte()) { + nalEnd + 4 + } else { + nalEnd + 3 + } } - /** - * Process NAL units in sampleBuf[0..dataLen). Auto-detects format: - * - Annex B (00 00 00 01 / 00 00 01 start codes) — used by MatroskaExtractor - * - Length-prefixed (4-byte big-endian length) — used by Mp4Extractor - * - * Result is written to outputBuf[0..outputLen). - */ - private fun processNalUnits(dataLen: Int) { - outputLen = 0 - if (dataLen < 4) { - ensureOutputCapacity(dataLen) - System.arraycopy(sampleBuf, 0, outputBuf, 0, dataLen) - outputLen = dataLen - return + sampleCount++ + if (sampleCount <= 3 || (sampleCount % 500 == 0L)) { + Log.d( + TAG, + "Sample #$sampleCount (AnnexB): ${dataLen}B -> ${outputLen}B, " + + "kept=$kept stripped=$stripped NALs" + ) + } + } + + /** Process length-prefixed NAL units (MP4 path). */ + private fun processLengthPrefixedNals(dataLen: Int) { + ensureOutputCapacity(dataLen) + var pos = 0 + var kept = 0 + var stripped = 0 + + while (pos + 4 <= dataLen) { + val nalLen = ((sampleBuf[pos].toInt() and 0xFF) shl 24) or + ((sampleBuf[pos + 1].toInt() and 0xFF) shl 16) or + ((sampleBuf[pos + 2].toInt() and 0xFF) shl 8) or + (sampleBuf[pos + 3].toInt() and 0xFF) + + if (nalLen <= 0 || pos + 4 + nalLen > dataLen) { + if (sampleCount < 5) { + Log.w(TAG, "Bad NAL length $nalLen at pos $pos (data.size=$dataLen)") } + break + } - // Auto-detect: Annex B starts with 00 00 00 01 or 00 00 01 - val isAnnexB = (dataLen >= 4 && sampleBuf[0] == 0.toByte() && sampleBuf[1] == 0.toByte() && - sampleBuf[2] == 0.toByte() && sampleBuf[3] == 1.toByte()) || - (dataLen >= 3 && sampleBuf[0] == 0.toByte() && sampleBuf[1] == 0.toByte() && - sampleBuf[2] == 1.toByte()) - - if (sampleCount == 0L) { - Log.d(TAG, "NAL format detected: ${if (isAnnexB) "Annex B" else "length-prefixed"}, " + - "first bytes: ${sampleBuf.take(8).joinToString(" ") { "%02X".format(it) }}") + val nalStart = pos + 4 + val action = processNalInline(nalStart, nalLen) + if (action == NalAction.KEEP) { + ensureOutputCapacity(outputLen + 4 + nalLen) + writeInt32BE(outputBuf, outputLen, nalLen) + outputLen += 4 + System.arraycopy(sampleBuf, nalStart, outputBuf, outputLen, nalLen) + normalizeLayerId(outputBuf, outputLen) + outputLen += nalLen + kept++ + } else if (action == NalAction.CONVERT) { + val converted = DoviBridge.convertRpuNalu( + sampleBuf.copyOfRange(nalStart, nalStart + nalLen), + LIBDOVI_MODE_TO_81 + ) + if (converted != null) { + normalizeLayerId(converted, 0) + ensureOutputCapacity(outputLen + 4 + converted.size) + writeInt32BE(outputBuf, outputLen, converted.size) + outputLen += 4 + System.arraycopy(converted, 0, outputBuf, outputLen, converted.size) + outputLen += converted.size + convertedRpuCount++ + kept++ + } else { + strippedNalCount++ + stripped++ } + } else { + strippedNalCount++ + stripped++ + } - if (isAnnexB) processAnnexBNals(dataLen) else processLengthPrefixedNals(dataLen) + pos += 4 + nalLen } - /** Process Annex B formatted NAL units (MKV path). Scans inline, no list allocation. */ - private fun processAnnexBNals(dataLen: Int) { - ensureOutputCapacity(dataLen) - var kept = 0 - var stripped = 0 - - // Find first start code - var scEnd = -1 - var i = 0 - while (i < dataLen - 2) { - if (sampleBuf[i] == 0.toByte() && sampleBuf[i + 1] == 0.toByte()) { - if (i + 3 < dataLen && sampleBuf[i + 2] == 0.toByte() && sampleBuf[i + 3] == 1.toByte()) { - scEnd = i + 4 - break - } else if (sampleBuf[i + 2] == 1.toByte()) { - scEnd = i + 3 - break - } - } - i++ - } - - if (scEnd < 0) { - // No start codes found — pass through - System.arraycopy(sampleBuf, 0, outputBuf, 0, dataLen) - outputLen = dataLen - sampleCount++ - return - } - - var nalStart = scEnd - - while (nalStart < dataLen) { - // Find next start code to determine end of current NAL - var nalEnd = dataLen - i = nalStart - while (i < dataLen - 2) { - if (sampleBuf[i] == 0.toByte() && sampleBuf[i + 1] == 0.toByte()) { - if (i + 3 < dataLen && sampleBuf[i + 2] == 0.toByte() && sampleBuf[i + 3] == 1.toByte()) { - nalEnd = i - break - } else if (sampleBuf[i + 2] == 1.toByte()) { - nalEnd = i - break - } - } - i++ - } - - val nalLen = nalEnd - nalStart - if (nalLen > 0) { - val action = processNalInline(nalStart, nalLen) - if (action == NalAction.KEEP) { - ensureOutputCapacity(outputLen + 4 + nalLen) - System.arraycopy(ANNEX_B_START_CODE, 0, outputBuf, outputLen, 4) - outputLen += 4 - System.arraycopy(sampleBuf, nalStart, outputBuf, outputLen, nalLen) - normalizeLayerId(outputBuf, outputLen) - outputLen += nalLen - kept++ - } else if (action == NalAction.CONVERT) { - val converted = DoviBridge.convertRpuNalu( - sampleBuf.copyOfRange(nalStart, nalStart + nalLen), LIBDOVI_MODE_TO_81 - ) - if (converted != null) { - normalizeLayerId(converted, 0) - ensureOutputCapacity(outputLen + 4 + converted.size) - System.arraycopy(ANNEX_B_START_CODE, 0, outputBuf, outputLen, 4) - outputLen += 4 - System.arraycopy(converted, 0, outputBuf, outputLen, converted.size) - outputLen += converted.size - convertedRpuCount++ - kept++ - } else { - strippedNalCount++ - stripped++ - } - } else { - strippedNalCount++ - stripped++ - } - } - - // Advance past the next start code - if (nalEnd >= dataLen) break - nalStart = if (nalEnd + 3 < dataLen && sampleBuf[nalEnd + 2] == 0.toByte() && sampleBuf[nalEnd + 3] == 1.toByte()) { - nalEnd + 4 - } else { - nalEnd + 3 - } - } - - sampleCount++ - if (sampleCount <= 3 || (sampleCount % 500 == 0L)) { - Log.d(TAG, "Sample #$sampleCount (AnnexB): ${dataLen}B -> ${outputLen}B, " + - "kept=$kept stripped=$stripped NALs") - } + sampleCount++ + if (sampleCount <= 3 || (sampleCount % 500 == 0L)) { + Log.d( + TAG, + "Sample #$sampleCount (LenPrefix): ${dataLen}B -> ${outputLen}B, " + + "kept=$kept stripped=$stripped NALs" + ) } + } - /** Process length-prefixed NAL units (MP4 path). */ - private fun processLengthPrefixedNals(dataLen: Int) { - ensureOutputCapacity(dataLen) - var pos = 0 - var kept = 0 - var stripped = 0 + private enum class NalAction { KEEP, STRIP, CONVERT } - while (pos + 4 <= dataLen) { - val nalLen = ((sampleBuf[pos].toInt() and 0xFF) shl 24) or - ((sampleBuf[pos + 1].toInt() and 0xFF) shl 16) or - ((sampleBuf[pos + 2].toInt() and 0xFF) shl 8) or - (sampleBuf[pos + 3].toInt() and 0xFF) - - if (nalLen <= 0 || pos + 4 + nalLen > dataLen) { - if (sampleCount < 5) { - Log.w(TAG, "Bad NAL length $nalLen at pos $pos (data.size=$dataLen)") - } - break - } - - val nalStart = pos + 4 - val action = processNalInline(nalStart, nalLen) - if (action == NalAction.KEEP) { - ensureOutputCapacity(outputLen + 4 + nalLen) - writeInt32BE(outputBuf, outputLen, nalLen) - outputLen += 4 - System.arraycopy(sampleBuf, nalStart, outputBuf, outputLen, nalLen) - normalizeLayerId(outputBuf, outputLen) - outputLen += nalLen - kept++ - } else if (action == NalAction.CONVERT) { - val converted = DoviBridge.convertRpuNalu( - sampleBuf.copyOfRange(nalStart, nalStart + nalLen), LIBDOVI_MODE_TO_81 - ) - if (converted != null) { - normalizeLayerId(converted, 0) - ensureOutputCapacity(outputLen + 4 + converted.size) - writeInt32BE(outputBuf, outputLen, converted.size) - outputLen += 4 - System.arraycopy(converted, 0, outputBuf, outputLen, converted.size) - outputLen += converted.size - convertedRpuCount++ - kept++ - } else { - strippedNalCount++ - stripped++ - } - } else { - strippedNalCount++ - stripped++ - } - - pos += 4 + nalLen - } - - sampleCount++ - if (sampleCount <= 3 || (sampleCount % 500 == 0L)) { - Log.d(TAG, "Sample #$sampleCount (LenPrefix): ${dataLen}B -> ${outputLen}B, " + - "kept=$kept stripped=$stripped NALs") - } + /** Classify a NAL at sampleBuf[offset..offset+len) without copying. */ + private fun processNalInline(offset: Int, len: Int): NalAction { + if (len < 2) return NalAction.KEEP + val nalType = (sampleBuf[offset].toInt() ushr 1) and 0x3F + val nuhLayerId = ((sampleBuf[offset].toInt() and 1) shl 5) or + ((sampleBuf[offset + 1].toInt() ushr 3) and 0x1F) + return when { + nalType == NAL_TYPE_UNSPEC62 && dvMode == DvConversionMode.DV81 -> NalAction.CONVERT + nalType == NAL_TYPE_UNSPEC62 || nalType == NAL_TYPE_UNSPEC63 || nuhLayerId > 0 -> NalAction.STRIP + else -> NalAction.KEEP } + } - private enum class NalAction { KEEP, STRIP, CONVERT } - - /** Classify a NAL at sampleBuf[offset..offset+len) without copying. */ - private fun processNalInline(offset: Int, len: Int): NalAction { - if (len < 2) return NalAction.KEEP - val nalType = (sampleBuf[offset].toInt() ushr 1) and 0x3F - val nuhLayerId = ((sampleBuf[offset].toInt() and 1) shl 5) or - ((sampleBuf[offset + 1].toInt() ushr 3) and 0x1F) - return when { - nalType == NAL_TYPE_UNSPEC62 && dvMode == DvConversionMode.DV81 -> NalAction.CONVERT - nalType == NAL_TYPE_UNSPEC62 || nalType == NAL_TYPE_UNSPEC63 || nuhLayerId > 0 -> NalAction.STRIP - else -> NalAction.KEEP - } + private fun normalizeLayerId(data: ByteArray, offset: Int) { + if (data.size - offset >= 2) { + data[offset] = (data[offset].toInt() and 0xFE).toByte() + data[offset + 1] = (data[offset + 1].toInt() and 0x07).toByte() } + } - private fun normalizeLayerId(data: ByteArray, offset: Int) { - if (data.size - offset >= 2) { - data[offset] = (data[offset].toInt() and 0xFE).toByte() - data[offset + 1] = (data[offset + 1].toInt() and 0x07).toByte() - } - } + private fun writeInt32BE(buf: ByteArray, offset: Int, value: Int) { + buf[offset] = ((value ushr 24) and 0xFF).toByte() + buf[offset + 1] = ((value ushr 16) and 0xFF).toByte() + buf[offset + 2] = ((value ushr 8) and 0xFF).toByte() + buf[offset + 3] = (value and 0xFF).toByte() + } - private fun writeInt32BE(buf: ByteArray, offset: Int, value: Int) { - buf[offset] = ((value ushr 24) and 0xFF).toByte() - buf[offset + 1] = ((value ushr 16) and 0xFF).toByte() - buf[offset + 2] = ((value ushr 8) and 0xFF).toByte() - buf[offset + 3] = (value and 0xFF).toByte() + private fun ensureSampleCapacity(needed: Int) { + if (sampleBuf.size < needed) { + sampleBuf = sampleBuf.copyOf(maxOf(needed, sampleBuf.size * 2)) } + } - private fun ensureSampleCapacity(needed: Int) { - if (sampleBuf.size < needed) { - sampleBuf = sampleBuf.copyOf(maxOf(needed, sampleBuf.size * 2)) - } + private fun ensureOutputCapacity(needed: Int) { + if (outputBuf.size < needed) { + outputBuf = outputBuf.copyOf(maxOf(needed, outputBuf.size * 2)) } + } - private fun ensureOutputCapacity(needed: Int) { - if (outputBuf.size < needed) { - outputBuf = outputBuf.copyOf(maxOf(needed, outputBuf.size * 2)) - } - } - - /** - * Build a 24-byte DOVIDecoderConfigurationRecord for DV Profile 8.1. - * - * Binary layout (from Dolby Vision spec): - * byte[0]: dv_version_major = 1 - * byte[1]: dv_version_minor = 0 - * byte[2]: dv_profile (7 bits) | dv_level MSB (1 bit) - * byte[3]: dv_level low 5 bits (5 bits) | rpu_present (1) | el_present (1) | bl_present (1) - * byte[4]: bl_compatibility_id (4 bits) | md_compression (2 bits) | reserved (2 bits) - * byte[5-23]: reserved (zeros) - */ - private fun buildDv81ConfigRecord(level: Int): ByteArray { - val record = ByteArray(24) - record[0] = 0x01 // dv_version_major = 1 - record[1] = 0x00 // dv_version_minor = 0 - record[2] = ((8 shl 1) or ((level ushr 5) and 0x01)).toByte() // profile=8 | level MSB - record[3] = (((level and 0x1F) shl 3) or 0x05).toByte() // level low 5 | rpu=1 el=0 bl=1 - record[4] = (1 shl 4).toByte() // bl_compatibility_id=1 (HDR10) - // bytes 5-23 remain 0 (reserved) - return record - } + /** + * Build a 24-byte DOVIDecoderConfigurationRecord for DV Profile 8.1. + * + * Binary layout (from Dolby Vision spec): + * byte[0]: dv_version_major = 1 + * byte[1]: dv_version_minor = 0 + * byte[2]: dv_profile (7 bits) | dv_level MSB (1 bit) + * byte[3]: dv_level low 5 bits (5 bits) | rpu_present (1) | el_present (1) | bl_present (1) + * byte[4]: bl_compatibility_id (4 bits) | md_compression (2 bits) | reserved (2 bits) + * byte[5-23]: reserved (zeros) + */ + private fun buildDv81ConfigRecord(level: Int): ByteArray { + val record = ByteArray(24) + record[0] = 0x01 // dv_version_major = 1 + record[1] = 0x00 // dv_version_minor = 0 + record[2] = ((8 shl 1) or ((level ushr 5) and 0x01)).toByte() // profile=8 | level MSB + record[3] = (((level and 0x1F) shl 3) or 0x05).toByte() // level low 5 | rpu=1 el=0 bl=1 + record[4] = (1 shl 4).toByte() // bl_compatibility_id=1 (HDR10) + // bytes 5-23 remain 0 (reserved) + return record + } } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviExtractorWrapper.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviExtractorWrapper.kt index fd41adc1..31bf9c68 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviExtractorWrapper.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviExtractorWrapper.kt @@ -14,22 +14,22 @@ import androidx.media3.extractor.TrackOutput * Shared by DoviExtractorWrapper for both MP4 and MKV containers. */ class DoviExtractorOutputWrapper( - private val delegate: ExtractorOutput, - private val dvMode: DvConversionMode, - private val onVideoTrackWrapped: (DoviConvertingTrackOutput) -> Unit, + private val delegate: ExtractorOutput, + private val dvMode: DvConversionMode, + private val onVideoTrackWrapped: (DoviConvertingTrackOutput) -> Unit ) : ExtractorOutput { - override fun track(id: Int, type: Int): TrackOutput { - val original = delegate.track(id, type) - if (type == C.TRACK_TYPE_VIDEO) { - val wrapper = DoviConvertingTrackOutput(original, dvMode) - onVideoTrackWrapped(wrapper) - return wrapper - } - return original + override fun track(id: Int, type: Int): TrackOutput { + val original = delegate.track(id, type) + if (type == C.TRACK_TYPE_VIDEO) { + val wrapper = DoviConvertingTrackOutput(original, dvMode) + onVideoTrackWrapped(wrapper) + return wrapper } + return original + } - override fun endTracks() = delegate.endTracks() - override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap) + override fun endTracks() = delegate.endTracks() + override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap) } /** @@ -38,23 +38,22 @@ class DoviExtractorOutputWrapper( * DV Profile 7 → 8.1 conversion via inline NAL processing. */ class DoviExtractorWrapper( - private val delegate: Extractor, - private val dvMode: DvConversionMode = DvConversionMode.HEVC_STRIP, + private val delegate: Extractor, + private val dvMode: DvConversionMode = DvConversionMode.HEVC_STRIP ) : Extractor { - @Volatile var doviTrackOutput: DoviConvertingTrackOutput? = null - private set + @Volatile var doviTrackOutput: DoviConvertingTrackOutput? = null + private set - override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input) + override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input) - override fun init(output: ExtractorOutput) { - delegate.init(DoviExtractorOutputWrapper(output, dvMode) { doviTrackOutput = it }) - } + override fun init(output: ExtractorOutput) { + delegate.init(DoviExtractorOutputWrapper(output, dvMode) { doviTrackOutput = it }) + } - override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int = - delegate.read(input, seekPosition) + override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int = delegate.read(input, seekPosition) - override fun seek(position: Long, timeUs: Long) = delegate.seek(position, timeUs) + override fun seek(position: Long, timeUs: Long) = delegate.seek(position, timeUs) - override fun release() = delegate.release() + override fun release() = delegate.release() } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index 2f80a53d..cfa08ed7 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -7,7 +7,6 @@ import android.graphics.Color import android.graphics.PixelFormat import android.graphics.Typeface import android.net.Uri -import android.os.Build import android.os.Handler import android.os.Looper import android.util.Log @@ -24,32 +23,28 @@ import androidx.media3.common.MediaItem import androidx.media3.common.MimeTypes import androidx.media3.common.PlaybackException import androidx.media3.common.Player -import androidx.media3.common.text.CueGroup import androidx.media3.common.TrackGroup import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.Tracks import androidx.media3.common.VideoSize +import androidx.media3.common.text.CueGroup import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.analytics.AnalyticsListener -import androidx.media3.datasource.DataSpec import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.HttpDataSource import androidx.media3.datasource.cronet.CronetDataSource -import org.chromium.net.CronetEngine -import java.util.concurrent.Executors -import java.util.concurrent.atomic.AtomicLong import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.DefaultRenderersFactory -import androidx.media3.exoplayer.mediacodec.MediaCodecSelector import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.RenderersFactory +import androidx.media3.exoplayer.analytics.AnalyticsListener +import androidx.media3.exoplayer.mediacodec.MediaCodecSelector import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.extractor.DefaultExtractorsFactory +import androidx.media3.extractor.mkv.MatroskaExtractor import androidx.media3.extractor.mp4.FragmentedMp4Extractor import androidx.media3.extractor.mp4.Mp4Extractor -import androidx.media3.extractor.mkv.MatroskaExtractor import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.CaptionStyleCompat import androidx.media3.ui.SubtitleView @@ -57,1820 +52,1859 @@ import com.edde746.plezy.shared.AudioFocusManager import com.edde746.plezy.shared.FlutterOverlayHelper import com.edde746.plezy.shared.FrameRateManager import io.github.peerless2012.ass.media.AssHandler - import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory import io.github.peerless2012.ass.media.type.AssRenderType import io.github.peerless2012.ass.media.widget.AssSubtitleSurfaceView +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicLong +import org.chromium.net.CronetEngine interface ExoPlayerDelegate : com.edde746.plezy.shared.PlayerDelegate { - /** - * Called when ExoPlayer encounters a format it cannot play. - * The plugin should handle fallback to MPV. - * @return true if fallback was handled, false to emit error event to Flutter - */ - fun onFormatUnsupported( - uri: String, - headers: Map?, - positionMs: Long, - errorMessage: String - ): Boolean = false + /** + * Called when ExoPlayer encounters a format it cannot play. + * The plugin should handle fallback to MPV. + * @return true if fallback was handled, false to emit error event to Flutter + */ + fun onFormatUnsupported( + uri: String, + headers: Map?, + positionMs: Long, + errorMessage: String + ): Boolean = false } @OptIn(UnstableApi::class) class ExoPlayerCore(private val activity: Activity) : Player.Listener { - companion object { - private const val TAG = "ExoPlayerCore" + companion object { + private const val TAG = "ExoPlayerCore" - private const val WATCHDOG_CHECK_INTERVAL_MS = 1000L - private const val WATCHDOG_TIMEOUT_MS = 8000L - private const val DECODER_HANG_TIMEOUT_MS = 5000L - private const val FPS_SAMPLE_COUNT = 8 + private const val WATCHDOG_CHECK_INTERVAL_MS = 1000L + private const val WATCHDOG_TIMEOUT_MS = 8000L + private const val DECODER_HANG_TIMEOUT_MS = 5000L + private const val FPS_SAMPLE_COUNT = 8 - // Codec capability caches — codec support doesn't change at runtime - private val hwAudioDecoderCache = HashMap() - private val tunneledPlaybackCache = HashMap() + // Codec capability caches — codec support doesn't change at runtime + private val hwAudioDecoderCache = HashMap() + private val tunneledPlaybackCache = HashMap() - private var assGlCrashHandlerInstalled = false + private var assGlCrashHandlerInstalled = false - private var cronetEngine: CronetEngine? = null - private fun getCronetEngine(context: Context): CronetEngine { - return cronetEngine ?: synchronized(this) { - cronetEngine ?: CronetEngine.Builder(context.applicationContext) - .enableHttp2(true) - .enableQuic(true) - .build() - .also { cronetEngine = it } - } + private var cronetEngine: CronetEngine? = null + private fun getCronetEngine(context: Context): CronetEngine = cronetEngine ?: synchronized(this) { + cronetEngine ?: CronetEngine.Builder(context.applicationContext) + .enableHttp2(true) + .enableQuic(true) + .build() + .also { cronetEngine = it } + } + private val cronetExecutor by lazy { Executors.newSingleThreadExecutor() } + } + + private var surfaceView: SurfaceView? = null + private var surfaceContainer: FrameLayout? = null + private var videoAspectContainer: AspectRatioFrameLayout? = null + private var subtitleView: SubtitleView? = null + private var assHandler: AssHandler? = null + private var overlayLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null + private var lastVideoSize: VideoSize? = null + private var exoPlayer: ExoPlayer? = null + private var renderersFactory: PlezyRenderersFactory? = null + private val subtitleDelayUs = AtomicLong(0L) + private var httpDataSourceFactory: HttpDataSource.Factory? = null + private var dataSourceFactory: DefaultDataSource.Factory? = null + private var trackSelector: DefaultTrackSelector? = null + private var tunnelingUserEnabled: Boolean = true + private var tunnelingDisabledForAudioCodec: Boolean = false + private var tunnelingDisabledForVideoCodec: Boolean = false + private val tunnelingDisabledForCodec: Boolean + get() = tunnelingDisabledForAudioCodec || tunnelingDisabledForVideoCodec + private var currentTunneledPlayback: Boolean = false + private var lastSeekable: Boolean? = null + + @Volatile private var disposing: Boolean = false + private var pendingStartPositionMs: Long = 0L + private var pendingPlayWhenReady: Boolean? = null + + // Frame watchdog: detects black screen (audio plays but 0 video frames rendered) + private var frameWatchdogRunnable: Runnable? = null + private var frameWatchdogStartTime: Long = 0L + + // Decoder hang detection: tracks gap between decoder init and first rendered frame + private var decoderHangRunnable: Runnable? = null + private var decoderInitName: String? = null + private var audioDecoderInitName: String? = null + private var firstFrameRendered: Boolean = false + var delegate: ExoPlayerDelegate? = null + var debugLoggingEnabled: Boolean = false + var isInitialized: Boolean = false + private set + + // Frame rate matching + private var frameRateManager: FrameRateManager? = null + private val handler = Handler(Looper.getMainLooper()) + + // FPS detection from frame timestamps (fallback when Format.frameRate is NO_VALUE) + @Volatile private var detectedFrameRate: Float = -1f + private val fpsTimestamps = LongArray(FPS_SAMPLE_COUNT) + + @Volatile private var fpsTimestampCount = 0 + + // Audio focus + private var audioFocusManager: AudioFocusManager? = null + + // Track state for event emission + private var lastPosition: Long = 0 + + /** Position to use for fallback: max of current position and pending start position. */ + private val effectivePosition: Long get() = maxOf(lastPosition, pendingStartPositionMs) + private var lastDuration: Long = 0 + private var lastBufferedPosition: Long = 0 + private var positionUpdateRunnable: Runnable? = null + + // External subtitles added dynamically + private val externalSubtitles = mutableListOf() + private val externalSubtitleUris = mutableListOf() + private var currentMediaUri: String? = null + private var currentHeaders: Map? = null + private var currentMediaIsLive: Boolean = false + private var currentVisible: Boolean = false + private var selectedAudioTrackId: String? = null + private var selectedSubtitleTrackId: String? = null + private val audioTrackGroupMap = mutableMapOf() + private val subtitleTrackGroupMap = mutableMapOf() + + private fun emitLog(level: String, prefix: String, message: String) { + when (level) { + "error" -> Log.e(TAG, "[$prefix] $message") + "warn" -> Log.w(TAG, "[$prefix] $message") + "info" -> Log.i(TAG, "[$prefix] $message") + else -> Log.d(TAG, "[$prefix] $message") + } + if (debugLoggingEnabled || level == "error" || level == "warn") { + delegate?.onEvent( + "log-message", + mapOf( + "prefix" to prefix, + "level" to level, + "text" to message + ) + ) + } + } + + private fun redactUri(uri: String): String { + return try { + val parsed = Uri.parse(uri) + val params = parsed.queryParameterNames + if (params.isEmpty()) return uri + val builder = parsed.buildUpon().clearQuery() + for (name in params) { + val lower = name.lowercase() + if (lower.contains("token") || lower.contains("key") || lower.contains("auth")) { + builder.appendQueryParameter(name, "[REDACTED]") + } else { + builder.appendQueryParameter(name, parsed.getQueryParameter(name)) } - private val cronetExecutor by lazy { Executors.newSingleThreadExecutor() } + } + builder.build().toString() + } catch (_: Exception) { + uri + } + } + + private fun ensureFlutterOverlayOnTop() { + if (disposing) return + val contentView = activity.findViewById(android.R.id.content) + contentView.post { + if (disposing || !isInitialized) return@post + val container = FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer) + ?: return@post + // Fires on every layout pass via OnGlobalLayoutListener; skip when the + // container is already at the front to avoid recursing the view tree + // and re-writing compositionOrder each time. + if (contentView.getChildAt(contentView.childCount - 1) === container) return@post + FlutterOverlayHelper.configureFlutterZOrder(contentView, container, compositionOrder = 1) + } + } + + private fun configureSubtitleOverlaySurface() { + subtitleView?.post { + val count = subtitleView?.childCount ?: 0 + for (i in 0 until count) { + val child = subtitleView?.getChildAt(i) + if (child is SurfaceView) { + child.setZOrderOnTop(false) + child.setZOrderMediaOverlay(true) + child.holder.setFormat(PixelFormat.TRANSLUCENT) + FlutterOverlayHelper.applyCompositionOrder(child, -1) + } else if (child is TextureView) { + child.isOpaque = false + } + } + } + } + + // DV conversion state + private var dvMode: DvConversionMode = DvConversionMode.DISABLED + private var dv7RetryAttempted = false + + @Volatile private var activeDoviMkvWrapper: DoviExtractorWrapper? = null + + @Volatile private var activeDoviMp4Wrapper: DoviExtractorWrapper? = null + + fun initialize(bufferSizeBytes: Int? = null, tunnelingEnabled: Boolean = true): Boolean { + if (isInitialized) { + Log.d(TAG, "Already initialized") + return true } - private var surfaceView: SurfaceView? = null - private var surfaceContainer: FrameLayout? = null - private var videoAspectContainer: AspectRatioFrameLayout? = null - private var subtitleView: SubtitleView? = null - private var assHandler: AssHandler? = null - private var overlayLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null - private var lastVideoSize: VideoSize? = null - private var exoPlayer: ExoPlayer? = null - private var renderersFactory: PlezyRenderersFactory? = null - private val subtitleDelayUs = AtomicLong(0L) - private var httpDataSourceFactory: HttpDataSource.Factory? = null - private var dataSourceFactory: DefaultDataSource.Factory? = null - private var trackSelector: DefaultTrackSelector? = null - private var tunnelingUserEnabled: Boolean = true - private var tunnelingDisabledForAudioCodec: Boolean = false - private var tunnelingDisabledForVideoCodec: Boolean = false - private val tunnelingDisabledForCodec: Boolean - get() = tunnelingDisabledForAudioCodec || tunnelingDisabledForVideoCodec - private var currentTunneledPlayback: Boolean = false - private var lastSeekable: Boolean? = null - @Volatile private var disposing: Boolean = false - private var pendingStartPositionMs: Long = 0L - private var pendingPlayWhenReady: Boolean? = null + tunnelingUserEnabled = tunnelingEnabled + this.dvMode = DoviBridge.getConversionMode() + Log.i( + TAG, + "DV conversion: mode=$dvMode, bridge=${DoviBridge.isAvailable()}, " + + "deviceDV7=${DoviBridge.deviceSupportsDvProfile7}, deviceDV8=${DoviBridge.deviceSupportsDvProfile8}" + ) + disposing = false - // Frame watchdog: detects black screen (audio plays but 0 video frames rendered) - private var frameWatchdogRunnable: Runnable? = null - private var frameWatchdogStartTime: Long = 0L - // Decoder hang detection: tracks gap between decoder init and first rendered frame - private var decoderHangRunnable: Runnable? = null - private var decoderInitName: String? = null - private var audioDecoderInitName: String? = null - private var firstFrameRendered: Boolean = false - var delegate: ExoPlayerDelegate? = null - var debugLoggingEnabled: Boolean = false - var isInitialized: Boolean = false - private set + try { + audioFocusManager = AudioFocusManager( + context = activity, + handler = handler, + onPause = { if (isInitialized) exoPlayer?.pause() }, + onResume = { if (isInitialized) exoPlayer?.play() }, + isPaused = { exoPlayer?.isPlaying != true }, + log = { emitLog("debug", "audio", it) } + ) + frameRateManager = FrameRateManager( + activity = activity, + handler = handler, + log = { emitLog("info", "framerate", it) } + ) - // Frame rate matching - private var frameRateManager: FrameRateManager? = null - private val handler = Handler(Looper.getMainLooper()) + // Create FrameLayout container for video (clips overflow for ZOOM crop mode) + surfaceContainer = FrameLayout(activity).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + setBackgroundColor(Color.BLACK) + clipChildren = true + } - // FPS detection from frame timestamps (fallback when Format.frameRate is NO_VALUE) - @Volatile private var detectedFrameRate: Float = -1f - private val fpsTimestamps = LongArray(FPS_SAMPLE_COUNT) - @Volatile private var fpsTimestampCount = 0 - - // Audio focus - private var audioFocusManager: AudioFocusManager? = null - - // Track state for event emission - private var lastPosition: Long = 0 - /** Position to use for fallback: max of current position and pending start position. */ - private val effectivePosition: Long get() = maxOf(lastPosition, pendingStartPositionMs) - private var lastDuration: Long = 0 - private var lastBufferedPosition: Long = 0 - private var positionUpdateRunnable: Runnable? = null - - // External subtitles added dynamically - private val externalSubtitles = mutableListOf() - private val externalSubtitleUris = mutableListOf() - private var currentMediaUri: String? = null - private var currentHeaders: Map? = null - private var currentMediaIsLive: Boolean = false - private var currentVisible: Boolean = false - private var selectedAudioTrackId: String? = null - private var selectedSubtitleTrackId: String? = null - private val audioTrackGroupMap = mutableMapOf() - private val subtitleTrackGroupMap = mutableMapOf() - - private fun emitLog(level: String, prefix: String, message: String) { - when (level) { - "error" -> Log.e(TAG, "[$prefix] $message") - "warn" -> Log.w(TAG, "[$prefix] $message") - "info" -> Log.i(TAG, "[$prefix] $message") - else -> Log.d(TAG, "[$prefix] $message") + // AspectRatioFrameLayout drives FIT/ZOOM/FILL via Media3's resizeMode. + // Centered inside the container; in ZOOM mode it measures larger than + // the container and the parent's clipChildren crops the overflow. + videoAspectContainer = AspectRatioFrameLayout(activity).apply { + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ).apply { + gravity = Gravity.CENTER } - if (debugLoggingEnabled || level == "error" || level == "warn") { - delegate?.onEvent("log-message", mapOf( - "prefix" to prefix, "level" to level, "text" to message - )) + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT + } + + // Create SurfaceView for video rendering (fills the ARFL) + surfaceView = SurfaceView(activity).apply { + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + holder.addCallback(surfaceCallback) + setZOrderOnTop(false) + setZOrderMediaOverlay(false) + FlutterOverlayHelper.applyCompositionOrder(this, -2) + } + + videoAspectContainer!!.addView(surfaceView) + surfaceContainer!!.addView(videoAspectContainer) + + // Create SubtitleView - added to surfaceContainer above video + // With OVERLAY_OPEN_GL mode, libass-android adds AssSubtitleTextureView as a child + // which renders ASS subtitles with full styling using GPU texture composition + subtitleView = SubtitleView(activity).apply { + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + } + // Add SubtitleView to surfaceContainer (above video SurfaceView) + // Flutter renders on top of entire surfaceContainer, keeping subtitles below UI + surfaceContainer!!.addView(subtitleView) + Log.d(TAG, "SubtitleView created and added to surfaceContainer") + + val contentView = activity.findViewById(android.R.id.content) + contentView.addView(surfaceContainer, 0) + + // Find FlutterView and configure z-order. compositionOrder maps directly to + // SurfaceView mSubLayer on API 36+: negative values are hole-punched behind + // the parent canvas, non-negative are composited above. Media3's + // CanvasSubtitleOutput renders SRT/VTT/SDH text on the parent canvas, so the + // video and libass surfaces must be negative for non-ASS subs to be visible. + // Stack (back → front): video (-2) → libass overlay (-1) → parent canvas + // (CanvasSubtitleOutput) → Flutter UI (+1). Pre-36 falls back to legacy buckets. + FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer)?.let { container -> + FlutterOverlayHelper.configureFlutterZOrder(contentView, container, compositionOrder = 1) + } + + ensureFlutterOverlayOnTop() + overlayLayoutListener = ViewTreeObserver.OnGlobalLayoutListener { + ensureFlutterOverlayOnTop() + // Recalculate surface size on layout change (orientation/PiP transitions) + lastVideoSize?.let { vs -> + if (vs.width > 0 && vs.height > 0) { + updateSurfaceViewSize(vs.width, vs.height, vs.pixelWidthHeightRatio) + } } - } + } + contentView.viewTreeObserver.addOnGlobalLayoutListener(overlayLayoutListener) - private fun redactUri(uri: String): String { - return try { - val parsed = Uri.parse(uri) - val params = parsed.queryParameterNames - if (params.isEmpty()) return uri - val builder = parsed.buildUpon().clearQuery() - for (name in params) { - val lower = name.lowercase() - if (lower.contains("token") || lower.contains("key") || lower.contains("auth")) { - builder.appendQueryParameter(name, "[REDACTED]") - } else { - builder.appendQueryParameter(name, parsed.getQueryParameter(name)) - } - } - builder.build().toString() - } catch (_: Exception) { - uri - } - } + Log.d(TAG, "SurfaceView added to content view") - private fun ensureFlutterOverlayOnTop() { - if (disposing) return - val contentView = activity.findViewById(android.R.id.content) - contentView.post { - if (disposing || !isInitialized) return@post - val container = FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer) - ?: return@post - // Fires on every layout pass via OnGlobalLayoutListener; skip when the - // container is already at the front to avoid recursing the view tree - // and re-writing compositionOrder each time. - if (contentView.getChildAt(contentView.childCount - 1) === container) return@post - FlutterOverlayHelper.configureFlutterZOrder(contentView, container, compositionOrder = 1) - } - } + // Create track selector with text tracks enabled + trackSelector = DefaultTrackSelector(activity).apply { + setParameters( + buildUponParameters() + .setTunnelingEnabled(tunnelingUserEnabled) + // Recover passthrough when HDMI capabilities flap (Shield refresh-rate / AVR link drop): + // the sink temporarily falls back to PCM, and this flag lets the selector re-pick the + // encoded audio track when capabilities come back. See androidx/media#2258. + .setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true) + .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) + .setPreferredTextLanguage("en") + ) + } - private fun configureSubtitleOverlaySurface() { - subtitleView?.post { - val count = subtitleView?.childCount ?: 0 - for (i in 0 until count) { - val child = subtitleView?.getChildAt(i) - if (child is SurfaceView) { - child.setZOrderOnTop(false) - child.setZOrderMediaOverlay(true) - child.holder.setFormat(PixelFormat.TRANSLUCENT) - FlutterOverlayHelper.applyCompositionOrder(child, -1) - } else if (child is TextureView) { - child.isOpaque = false - } - } - } - } + // Create ExoPlayer with FFmpeg audio decoder fallback + val audioAttributes = androidx.media3.common.AudioAttributes.Builder() + .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) + .setUsage(C.USAGE_MEDIA) + .build() - // DV conversion state - private var dvMode: DvConversionMode = DvConversionMode.DISABLED - private var dv7RetryAttempted = false - @Volatile private var activeDoviMkvWrapper: DoviExtractorWrapper? = null - @Volatile private var activeDoviMp4Wrapper: DoviExtractorWrapper? = null - - fun initialize(bufferSizeBytes: Int? = null, tunnelingEnabled: Boolean = true): Boolean { - if (isInitialized) { - Log.d(TAG, "Already initialized") - return true - } - - tunnelingUserEnabled = tunnelingEnabled - this.dvMode = DoviBridge.getConversionMode() - Log.i(TAG, "DV conversion: mode=$dvMode, bridge=${DoviBridge.isAvailable()}, " + - "deviceDV7=${DoviBridge.deviceSupportsDvProfile7}, deviceDV8=${DoviBridge.deviceSupportsDvProfile8}") - disposing = false - - try { - audioFocusManager = AudioFocusManager( - context = activity, - handler = handler, - onPause = { if (isInitialized) exoPlayer?.pause() }, - onResume = { if (isInitialized) exoPlayer?.play() }, - isPaused = { exoPlayer?.isPlaying != true }, - log = { emitLog("debug", "audio", it) } + // Use DefaultRenderersFactory with FFmpeg fallback for unsupported audio codecs + val renderersFactory = PlezyRenderersFactory(activity).apply { + setEnableDecoderFallback(true) + setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON) + // Force FFmpeg for FLAC — hardware FLAC decoders (e.g. Samsung c2.sec.flac.decoder) + // have buggy 32KB input buffer limits causing InsufficientCapacityException. + setMediaCodecSelector { mimeType, requiresSecureDecoder, requiresTunnelingDecoder -> + if (mimeType == MimeTypes.AUDIO_FLAC) { + emptyList() + } else { + MediaCodecSelector.DEFAULT.getDecoderInfos( + mimeType, + requiresSecureDecoder, + requiresTunnelingDecoder ) - frameRateManager = FrameRateManager( - activity = activity, - handler = handler, - log = { emitLog("info", "framerate", it) } - ) - - // Create FrameLayout container for video (clips overflow for ZOOM crop mode) - surfaceContainer = FrameLayout(activity).apply { - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - setBackgroundColor(Color.BLACK) - clipChildren = true - } - - // AspectRatioFrameLayout drives FIT/ZOOM/FILL via Media3's resizeMode. - // Centered inside the container; in ZOOM mode it measures larger than - // the container and the parent's clipChildren crops the overflow. - videoAspectContainer = AspectRatioFrameLayout(activity).apply { - layoutParams = FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.MATCH_PARENT - ).apply { - gravity = Gravity.CENTER - } - resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT - } - - // Create SurfaceView for video rendering (fills the ARFL) - surfaceView = SurfaceView(activity).apply { - layoutParams = FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.MATCH_PARENT - ) - holder.addCallback(surfaceCallback) - setZOrderOnTop(false) - setZOrderMediaOverlay(false) - FlutterOverlayHelper.applyCompositionOrder(this, -2) - } - - videoAspectContainer!!.addView(surfaceView) - surfaceContainer!!.addView(videoAspectContainer) - - // Create SubtitleView - added to surfaceContainer above video - // With OVERLAY_OPEN_GL mode, libass-android adds AssSubtitleTextureView as a child - // which renders ASS subtitles with full styling using GPU texture composition - subtitleView = SubtitleView(activity).apply { - layoutParams = FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.MATCH_PARENT - ) - } - // Add SubtitleView to surfaceContainer (above video SurfaceView) - // Flutter renders on top of entire surfaceContainer, keeping subtitles below UI - surfaceContainer!!.addView(subtitleView) - Log.d(TAG, "SubtitleView created and added to surfaceContainer") - - val contentView = activity.findViewById(android.R.id.content) - contentView.addView(surfaceContainer, 0) - - // Find FlutterView and configure z-order. compositionOrder maps directly to - // SurfaceView mSubLayer on API 36+: negative values are hole-punched behind - // the parent canvas, non-negative are composited above. Media3's - // CanvasSubtitleOutput renders SRT/VTT/SDH text on the parent canvas, so the - // video and libass surfaces must be negative for non-ASS subs to be visible. - // Stack (back → front): video (-2) → libass overlay (-1) → parent canvas - // (CanvasSubtitleOutput) → Flutter UI (+1). Pre-36 falls back to legacy buckets. - FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer)?.let { container -> - FlutterOverlayHelper.configureFlutterZOrder(contentView, container, compositionOrder = 1) - } - - ensureFlutterOverlayOnTop() - overlayLayoutListener = ViewTreeObserver.OnGlobalLayoutListener { - ensureFlutterOverlayOnTop() - // Recalculate surface size on layout change (orientation/PiP transitions) - lastVideoSize?.let { vs -> - if (vs.width > 0 && vs.height > 0) { - updateSurfaceViewSize(vs.width, vs.height, vs.pixelWidthHeightRatio) - } - } - } - contentView.viewTreeObserver.addOnGlobalLayoutListener(overlayLayoutListener) - - Log.d(TAG, "SurfaceView added to content view") - - // Create track selector with text tracks enabled - trackSelector = DefaultTrackSelector(activity).apply { - setParameters( - buildUponParameters() - .setTunnelingEnabled(tunnelingUserEnabled) - // Recover passthrough when HDMI capabilities flap (Shield refresh-rate / AVR link drop): - // the sink temporarily falls back to PCM, and this flag lets the selector re-pick the - // encoded audio track when capabilities come back. See androidx/media#2258. - .setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true) - .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) - .setPreferredTextLanguage("en") - ) - } - - // Create ExoPlayer with FFmpeg audio decoder fallback - val audioAttributes = androidx.media3.common.AudioAttributes.Builder() - .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) - .setUsage(C.USAGE_MEDIA) - .build() - - // Use DefaultRenderersFactory with FFmpeg fallback for unsupported audio codecs - val renderersFactory = PlezyRenderersFactory(activity).apply { - setEnableDecoderFallback(true) - setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON) - // Force FFmpeg for FLAC — hardware FLAC decoders (e.g. Samsung c2.sec.flac.decoder) - // have buggy 32KB input buffer limits causing InsufficientCapacityException. - setMediaCodecSelector { mimeType, requiresSecureDecoder, requiresTunnelingDecoder -> - if (mimeType == MimeTypes.AUDIO_FLAC) { - emptyList() - } else { - MediaCodecSelector.DEFAULT.getDecoderInfos( - mimeType, requiresSecureDecoder, requiresTunnelingDecoder - ) - } - } - } - this.renderersFactory = renderersFactory - - // Cronet DataSource for HTTP/2 multiplexing — all range requests share one connection - httpDataSourceFactory = CronetDataSource.Factory(getCronetEngine(activity), cronetExecutor) - .setConnectionTimeoutMs(15_000) - .setReadTimeoutMs(10_000) - dataSourceFactory = DefaultDataSource.Factory(activity, httpDataSourceFactory!!) - val extractorsFactory = DefaultExtractorsFactory() - - // Inline buildWithAssSupport to retain AssHandler reference for font scale control. - // OVERLAY_OPEN_GL uses TextureView which follows normal View hierarchy z-ordering, - // preventing hardware overlay promotion issues on devices like Nvidia Shield. - Log.d(TAG, "SubtitleView childCount before ASS setup: ${subtitleView?.childCount}") - - val renderType = AssRenderType.OVERLAY_OPEN_GL - val handler = AssHandler(renderType) - assHandler = handler - - val assParserFactory = AssSubtitleParserFactory(handler) - - // Wrap extractors: replace MatroskaExtractor with ASS+DV variant, - // wrap MP4 extractors with DV converter when enabled. - // Reads this.dvMode each time (not captured) so DV7→8.1 retry can - // change mode and reload without reinitializing the player. - val wrappedExtractorsFactory = androidx.media3.extractor.ExtractorsFactory { - val currentDvMode = this.dvMode - val doviEnabled = currentDvMode != DvConversionMode.DISABLED - extractorsFactory.createExtractors().map { extractor -> - when { - extractor is MatroskaExtractor -> { - val assExtractor = ZlibMatroskaExtractor(assParserFactory, handler) - val inner = if (doviEnabled) { - DoviExtractorWrapper(assExtractor, currentDvMode).also { - activeDoviMkvWrapper = it - } - } else { - assExtractor - } - // Wrap with approximate seeking for MKV files without Cues - CuelessSeekExtractorWrapper(inner) - } - doviEnabled && (extractor is Mp4Extractor || extractor is FragmentedMp4Extractor) -> { - DoviExtractorWrapper(extractor, currentDvMode).also { - activeDoviMp4Wrapper = it - } - } - else -> extractor - } - }.toTypedArray() - } - - val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory!!, wrappedExtractorsFactory) - .setSubtitleParserFactory(assParserFactory) - - // Wrap text renderers with subtitle delay support - val wrappedRenderersFactory = RenderersFactory { - eventHandler, videoListener, audioListener, textOutput, metadataOutput -> - renderersFactory.createRenderers(eventHandler, videoListener, audioListener, textOutput, metadataOutput) - .map { if (it.trackType == C.TRACK_TYPE_TEXT) SubtitleDelayRenderer(it, subtitleDelayUs) else it } - .toTypedArray() - } - - // Compute memory-aware buffer limits to prevent CCodec OOM crashes - val activityManager = activity.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager - val memoryInfo = ActivityManager.MemoryInfo() - activityManager.getMemoryInfo(memoryInfo) - val availableMB = memoryInfo.availMem / (1024 * 1024) - - val targetBufferBytes = if (bufferSizeBytes != null && bufferSizeBytes > 0) { - bufferSizeBytes - } else { - // Scale buffer to available memory to reduce hardware decoder pressure. - // Larger buffers reduce oscillation frequency at high bitrates (50-100Mbps). - when { - availableMB <= 512 -> 30 * 1024 * 1024 - availableMB <= 1024 -> 80 * 1024 * 1024 - availableMB <= 2048 -> 120 * 1024 * 1024 - else -> 200 * 1024 * 1024 - } - } - - val loadControl = DefaultLoadControl.Builder().apply { - setTargetBufferBytes(targetBufferBytes) - setPrioritizeTimeOverSizeThresholds(false) - if (availableMB <= 2048) { - setBufferDurationsMs(15_000, 50_000, 1_000, 5_000) - } else { - setBufferDurationsMs(30_000, 60_000, 1_000, 5_000) - } - }.build() - emitLog("info", "init", "Buffer: ${targetBufferBytes / 1024 / 1024}MB limit, available=${availableMB}MB, tunneling=${tunnelingUserEnabled}, dataSource=Cronet") - - exoPlayer = ExoPlayer.Builder(activity) - .setTrackSelector(trackSelector!!) - .setLoadControl(loadControl) - .setAudioAttributes(audioAttributes, false) // We handle audio focus manually - .setMediaSourceFactory(mediaSourceFactory) - .setRenderersFactory(wrappedRenderersFactory) - .build() - - // Add ASS overlay view to SubtitleView for OVERLAY modes. - // We use AssSubtitleSurfaceView directly (not AssSubtitleView) so we get a - // SurfaceFlinger-layer-backed overlay that eglPresentationTimeANDROID can - // vsync-pin. Z-order: video SurfaceView (-2) < this MediaOverlay-flagged - // SurfaceView (-1) < parent canvas < Flutter SurfaceView (+1) in the window. - // - // Inserted at child index 0 so the SurfaceView's transparent punch runs BEFORE - // SubtitleView's built-in CanvasSubtitleOutput child renders non-ASS cues. - // Appending would punch away already-drawn SRT/VTT text. - var assSubtitleSurfaceView: AssSubtitleSurfaceView? = null - subtitleView?.let { sv -> - val assView = AssSubtitleSurfaceView(sv.context, handler) - assSubtitleSurfaceView = assView - sv.addView( - assView, - 0, - FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.MATCH_PARENT - ) - ) - } - - // Initialize handler (registers as Player.Listener, creates Handler). - // AssHandler.init calls player.setVideoFrameMetadataListener internally, but - // our own setVideoFrameMetadataListener below would overwrite it (ExoPlayer - // only keeps one listener). Skip AssHandler's wiring and invoke - // assView.requestRender directly from the listener below. - handler.init(exoPlayer!!) - - // Suppress ass-media GL thread crash when EGL init partially fails (e.g. Tegra). - // AssRender.onSurfaceDestroyed() accesses uninitialized glProgram lateinit property - // during error cleanup, which is a bug in the library. The render thread dying only - // affects ASS subtitle GPU rendering; non-ASS subtitles are unaffected. - if (!assGlCrashHandlerInstalled) { - assGlCrashHandlerInstalled = true - val previousHandler = Thread.getDefaultUncaughtExceptionHandler() - Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> - if (thread.name.contains("AssTexRenderThread") && - throwable is UninitializedPropertyAccessException) { - Log.e(TAG, "ASS GL thread crash suppressed (EGL init failure)", throwable) - } else { - previousHandler?.uncaughtException(thread, throwable) - } - } - } - - exoPlayer!!.addListener(this) - exoPlayer!!.addAnalyticsListener(decoderHangListener) - exoPlayer!!.setVideoFrameMetadataListener { presentationTimeUs, releaseTimeNs, _, _ -> - assSubtitleSurfaceView?.requestRender(presentationTimeUs, releaseTimeNs) - val count = fpsTimestampCount - if (count < FPS_SAMPLE_COUNT) { - fpsTimestamps[count] = presentationTimeUs - fpsTimestampCount = count + 1 - if (count + 1 == FPS_SAMPLE_COUNT) { - detectedFrameRate = computeFrameRate(fpsTimestamps) - Log.d(TAG, "Detected frame rate: $detectedFrameRate fps") - } - } - } - surfaceView?.let { exoPlayer!!.setVideoSurfaceView(it) } - - Log.d(TAG, "SubtitleView childCount after ASS setup: ${subtitleView?.childCount}") - configureSubtitleOverlaySurface() - - // Debug: Log SubtitleView child hierarchy - subtitleView?.post { - Log.d(TAG, "SubtitleView post-layout: width=${subtitleView?.width}, height=${subtitleView?.height}, childCount=${subtitleView?.childCount}") - for (i in 0 until (subtitleView?.childCount ?: 0)) { - val child = subtitleView?.getChildAt(i) - Log.d(TAG, " Child $i: ${child?.javaClass?.simpleName}, w=${child?.width}, h=${child?.height}, visibility=${child?.visibility}") - } - } - - // Start position update loop - startPositionUpdates() - - isInitialized = true - Log.d(TAG, "Initialized successfully") - return true - } catch (e: Exception) { - Log.e(TAG, "Failed to initialize: ${e.message}", e) - return false + } } + } + this.renderersFactory = renderersFactory + + // Cronet DataSource for HTTP/2 multiplexing — all range requests share one connection + httpDataSourceFactory = CronetDataSource.Factory(getCronetEngine(activity), cronetExecutor) + .setConnectionTimeoutMs(15_000) + .setReadTimeoutMs(10_000) + dataSourceFactory = DefaultDataSource.Factory(activity, httpDataSourceFactory!!) + val extractorsFactory = DefaultExtractorsFactory() + + // Inline buildWithAssSupport to retain AssHandler reference for font scale control. + // OVERLAY_OPEN_GL uses TextureView which follows normal View hierarchy z-ordering, + // preventing hardware overlay promotion issues on devices like Nvidia Shield. + Log.d(TAG, "SubtitleView childCount before ASS setup: ${subtitleView?.childCount}") + + val renderType = AssRenderType.OVERLAY_OPEN_GL + val handler = AssHandler(renderType) + assHandler = handler + + val assParserFactory = AssSubtitleParserFactory(handler) + + // Wrap extractors: replace MatroskaExtractor with ASS+DV variant, + // wrap MP4 extractors with DV converter when enabled. + // Reads this.dvMode each time (not captured) so DV7→8.1 retry can + // change mode and reload without reinitializing the player. + val wrappedExtractorsFactory = androidx.media3.extractor.ExtractorsFactory { + val currentDvMode = this.dvMode + val doviEnabled = currentDvMode != DvConversionMode.DISABLED + extractorsFactory.createExtractors().map { extractor -> + when { + extractor is MatroskaExtractor -> { + val assExtractor = ZlibMatroskaExtractor(assParserFactory, handler) + val inner = if (doviEnabled) { + DoviExtractorWrapper(assExtractor, currentDvMode).also { + activeDoviMkvWrapper = it + } + } else { + assExtractor + } + // Wrap with approximate seeking for MKV files without Cues + CuelessSeekExtractorWrapper(inner) + } + doviEnabled && (extractor is Mp4Extractor || extractor is FragmentedMp4Extractor) -> { + DoviExtractorWrapper(extractor, currentDvMode).also { + activeDoviMp4Wrapper = it + } + } + else -> extractor + } + }.toTypedArray() + } + + val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory!!, wrappedExtractorsFactory) + .setSubtitleParserFactory(assParserFactory) + + // Wrap text renderers with subtitle delay support + val wrappedRenderersFactory = RenderersFactory { eventHandler, videoListener, audioListener, textOutput, metadataOutput -> + renderersFactory.createRenderers(eventHandler, videoListener, audioListener, textOutput, metadataOutput) + .map { if (it.trackType == C.TRACK_TYPE_TEXT) SubtitleDelayRenderer(it, subtitleDelayUs) else it } + .toTypedArray() + } + + // Compute memory-aware buffer limits to prevent CCodec OOM crashes + val activityManager = activity.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val memoryInfo = ActivityManager.MemoryInfo() + activityManager.getMemoryInfo(memoryInfo) + val availableMB = memoryInfo.availMem / (1024 * 1024) + + val targetBufferBytes = if (bufferSizeBytes != null && bufferSizeBytes > 0) { + bufferSizeBytes + } else { + // Scale buffer to available memory to reduce hardware decoder pressure. + // Larger buffers reduce oscillation frequency at high bitrates (50-100Mbps). + when { + availableMB <= 512 -> 30 * 1024 * 1024 + availableMB <= 1024 -> 80 * 1024 * 1024 + availableMB <= 2048 -> 120 * 1024 * 1024 + else -> 200 * 1024 * 1024 + } + } + + val loadControl = DefaultLoadControl.Builder().apply { + setTargetBufferBytes(targetBufferBytes) + setPrioritizeTimeOverSizeThresholds(false) + if (availableMB <= 2048) { + setBufferDurationsMs(15_000, 50_000, 1_000, 5_000) + } else { + setBufferDurationsMs(30_000, 60_000, 1_000, 5_000) + } + }.build() + emitLog("info", "init", "Buffer: ${targetBufferBytes / 1024 / 1024}MB limit, available=${availableMB}MB, tunneling=$tunnelingUserEnabled, dataSource=Cronet") + + exoPlayer = ExoPlayer.Builder(activity) + .setTrackSelector(trackSelector!!) + .setLoadControl(loadControl) + .setAudioAttributes(audioAttributes, false) // We handle audio focus manually + .setMediaSourceFactory(mediaSourceFactory) + .setRenderersFactory(wrappedRenderersFactory) + .build() + + // Add ASS overlay view to SubtitleView for OVERLAY modes. + // We use AssSubtitleSurfaceView directly (not AssSubtitleView) so we get a + // SurfaceFlinger-layer-backed overlay that eglPresentationTimeANDROID can + // vsync-pin. Z-order: video SurfaceView (-2) < this MediaOverlay-flagged + // SurfaceView (-1) < parent canvas < Flutter SurfaceView (+1) in the window. + // + // Inserted at child index 0 so the SurfaceView's transparent punch runs BEFORE + // SubtitleView's built-in CanvasSubtitleOutput child renders non-ASS cues. + // Appending would punch away already-drawn SRT/VTT text. + var assSubtitleSurfaceView: AssSubtitleSurfaceView? = null + subtitleView?.let { sv -> + val assView = AssSubtitleSurfaceView(sv.context, handler) + assSubtitleSurfaceView = assView + sv.addView( + assView, + 0, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + ) + } + + // Initialize handler (registers as Player.Listener, creates Handler). + // AssHandler.init calls player.setVideoFrameMetadataListener internally, but + // our own setVideoFrameMetadataListener below would overwrite it (ExoPlayer + // only keeps one listener). Skip AssHandler's wiring and invoke + // assView.requestRender directly from the listener below. + handler.init(exoPlayer!!) + + // Suppress ass-media GL thread crash when EGL init partially fails (e.g. Tegra). + // AssRender.onSurfaceDestroyed() accesses uninitialized glProgram lateinit property + // during error cleanup, which is a bug in the library. The render thread dying only + // affects ASS subtitle GPU rendering; non-ASS subtitles are unaffected. + if (!assGlCrashHandlerInstalled) { + assGlCrashHandlerInstalled = true + val previousHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + if (thread.name.contains("AssTexRenderThread") && + throwable is UninitializedPropertyAccessException + ) { + Log.e(TAG, "ASS GL thread crash suppressed (EGL init failure)", throwable) + } else { + previousHandler?.uncaughtException(thread, throwable) + } + } + } + + exoPlayer!!.addListener(this) + exoPlayer!!.addAnalyticsListener(decoderHangListener) + exoPlayer!!.setVideoFrameMetadataListener { presentationTimeUs, releaseTimeNs, _, _ -> + assSubtitleSurfaceView?.requestRender(presentationTimeUs, releaseTimeNs) + val count = fpsTimestampCount + if (count < FPS_SAMPLE_COUNT) { + fpsTimestamps[count] = presentationTimeUs + fpsTimestampCount = count + 1 + if (count + 1 == FPS_SAMPLE_COUNT) { + detectedFrameRate = computeFrameRate(fpsTimestamps) + Log.d(TAG, "Detected frame rate: $detectedFrameRate fps") + } + } + } + surfaceView?.let { exoPlayer!!.setVideoSurfaceView(it) } + + Log.d(TAG, "SubtitleView childCount after ASS setup: ${subtitleView?.childCount}") + configureSubtitleOverlaySurface() + + // Debug: Log SubtitleView child hierarchy + subtitleView?.post { + Log.d(TAG, "SubtitleView post-layout: width=${subtitleView?.width}, height=${subtitleView?.height}, childCount=${subtitleView?.childCount}") + for (i in 0 until (subtitleView?.childCount ?: 0)) { + val child = subtitleView?.getChildAt(i) + Log.d(TAG, " Child $i: ${child?.javaClass?.simpleName}, w=${child?.width}, h=${child?.height}, visibility=${child?.visibility}") + } + } + + // Start position update loop + startPositionUpdates() + + isInitialized = true + Log.d(TAG, "Initialized successfully") + return true + } catch (e: Exception) { + Log.e(TAG, "Failed to initialize: ${e.message}", e) + return false + } + } + + private val surfaceCallback = object : android.view.SurfaceHolder.Callback { + override fun surfaceCreated(holder: android.view.SurfaceHolder) { + if (disposing) return + emitLog("debug", "surface", "Created") + ensureFlutterOverlayOnTop() } - private val surfaceCallback = object : android.view.SurfaceHolder.Callback { - override fun surfaceCreated(holder: android.view.SurfaceHolder) { - if (disposing) return - emitLog("debug", "surface", "Created") - ensureFlutterOverlayOnTop() - } - - override fun surfaceChanged(holder: android.view.SurfaceHolder, format: Int, width: Int, height: Int) { - emitLog("debug", "surface", "Changed: ${width}x${height}") - } - - override fun surfaceDestroyed(holder: android.view.SurfaceHolder) { - emitLog("debug", "surface", "Destroyed") - } + override fun surfaceChanged(holder: android.view.SurfaceHolder, format: Int, width: Int, height: Int) { + emitLog("debug", "surface", "Changed: ${width}x$height") } - private fun startPositionUpdates() { - positionUpdateRunnable = object : Runnable { - override fun run() { - if (isInitialized && exoPlayer != null) { - val player = exoPlayer!! - val currentPosition = player.currentPosition - val duration = player.duration - val bufferedPosition = player.bufferedPosition - - // Emit position changes (every 250ms update) - if (currentPosition != lastPosition) { - lastPosition = currentPosition - delegate?.onPropertyChange("time-pos", currentPosition / 1000.0) - } - - // Emit duration changes - if (duration != lastDuration && duration != C.TIME_UNSET) { - lastDuration = duration - delegate?.onPropertyChange("duration", duration / 1000.0) - } - - // Emit buffer changes - if (bufferedPosition != lastBufferedPosition && bufferedPosition != C.TIME_UNSET) { - lastBufferedPosition = bufferedPosition - delegate?.onPropertyChange("demuxer-cache-time", bufferedPosition / 1000.0) - } - - handler.postDelayed(this, 250) - } - } - } - handler.post(positionUpdateRunnable!!) + override fun surfaceDestroyed(holder: android.view.SurfaceHolder) { + emitLog("debug", "surface", "Destroyed") } + } - private fun stopPositionUpdates() { - positionUpdateRunnable?.let { handler.removeCallbacks(it) } - positionUpdateRunnable = null - } + private fun startPositionUpdates() { + positionUpdateRunnable = object : Runnable { + override fun run() { + if (isInitialized && exoPlayer != null) { + val player = exoPlayer!! + val currentPosition = player.currentPosition + val duration = player.duration + val bufferedPosition = player.bufferedPosition - private fun emitSeekable(seekable: Boolean, force: Boolean = false) { - if (!force && lastSeekable == seekable) return - lastSeekable = seekable - delegate?.onPropertyChange("seekable", seekable) - } + // Emit position changes (every 250ms update) + if (currentPosition != lastPosition) { + lastPosition = currentPosition + delegate?.onPropertyChange("time-pos", currentPosition / 1000.0) + } - private fun emitCurrentSeekable(force: Boolean = false) { - val player = exoPlayer - val seekable = player?.isCurrentMediaItemSeekable == true && !currentMediaIsLive - emitSeekable(seekable, force) - } + // Emit duration changes + if (duration != lastDuration && duration != C.TIME_UNSET) { + lastDuration = duration + delegate?.onPropertyChange("duration", duration / 1000.0) + } - // Player.Listener + // Emit buffer changes + if (bufferedPosition != lastBufferedPosition && bufferedPosition != C.TIME_UNSET) { + lastBufferedPosition = bufferedPosition + delegate?.onPropertyChange("demuxer-cache-time", bufferedPosition / 1000.0) + } - override fun onCues(cueGroup: CueGroup) { - // With OVERLAY_CANVAS mode, ASS subtitles are rendered directly by AssSubtitleView - // This callback is for non-ASS subtitles (SRT, VTT, etc.) - if (cueGroup.cues.isNotEmpty()) { - Log.d(TAG, "onCues: received ${cueGroup.cues.size} cues (non-ASS)") + handler.postDelayed(this, 250) } - subtitleView?.setCues(cueGroup.cues) + } } + handler.post(positionUpdateRunnable!!) + } - override fun onIsPlayingChanged(isPlaying: Boolean) { - Log.d(TAG, "onIsPlayingChanged: $isPlaying") - if (isPlaying) pendingPlayWhenReady = null - delegate?.onPropertyChange("pause", !isPlaying) + private fun stopPositionUpdates() { + positionUpdateRunnable?.let { handler.removeCallbacks(it) } + positionUpdateRunnable = null + } + + private fun emitSeekable(seekable: Boolean, force: Boolean = false) { + if (!force && lastSeekable == seekable) return + lastSeekable = seekable + delegate?.onPropertyChange("seekable", seekable) + } + + private fun emitCurrentSeekable(force: Boolean = false) { + val player = exoPlayer + val seekable = player?.isCurrentMediaItemSeekable == true && !currentMediaIsLive + emitSeekable(seekable, force) + } + + // Player.Listener + + override fun onCues(cueGroup: CueGroup) { + // With OVERLAY_CANVAS mode, ASS subtitles are rendered directly by AssSubtitleView + // This callback is for non-ASS subtitles (SRT, VTT, etc.) + if (cueGroup.cues.isNotEmpty()) { + Log.d(TAG, "onCues: received ${cueGroup.cues.size} cues (non-ASS)") } + subtitleView?.setCues(cueGroup.cues) + } - override fun onPlaybackStateChanged(state: Int) { - val stateStr = when (state) { - Player.STATE_IDLE -> "idle" - Player.STATE_BUFFERING -> "buffering" - Player.STATE_READY -> "ready" - Player.STATE_ENDED -> "ended" - else -> "unknown" - } - emitLog("debug", "state", stateStr) - emitCurrentSeekable() + override fun onIsPlayingChanged(isPlaying: Boolean) { + Log.d(TAG, "onIsPlayingChanged: $isPlaying") + if (isPlaying) pendingPlayWhenReady = null + delegate?.onPropertyChange("pause", !isPlaying) + } - when (state) { - Player.STATE_BUFFERING -> { - delegate?.onPropertyChange("paused-for-cache", true) - } - Player.STATE_READY -> { - // Restore start position if it was lost during track reselection - // (e.g. tunneling state change in onTracksChanged triggers renderer teardown) - if (pendingStartPositionMs > 0L) { - val currentPos = exoPlayer?.currentPosition ?: 0L - if (currentPos < 1000L) { - emitLog("warn", "state", "Position lost (at ${currentPos}ms, expected ${pendingStartPositionMs}ms) — restoring") - exoPlayer?.seekTo(pendingStartPositionMs) - } - pendingStartPositionMs = 0L - } - val pendingPlay = pendingPlayWhenReady - val currentPlayWhenReady = exoPlayer?.playWhenReady - if (pendingPlay != null && currentPlayWhenReady != pendingPlay) { - emitLog("warn", "state", "playWhenReady lost (now $currentPlayWhenReady, expected $pendingPlay) — restoring") - exoPlayer?.playWhenReady = pendingPlay - } - delegate?.onPropertyChange("paused-for-cache", false) - delegate?.onEvent("playback-restart", null) - emitTrackList() - - // Start frame watchdog to detect black screen (HDR tunneling issue) - startFrameWatchdog() - } - Player.STATE_ENDED -> { - stopFrameWatchdog() - delegate?.onPropertyChange("eof-reached", true) - delegate?.onEvent("end-file", mapOf("reason" to "eof")) - } - } + override fun onPlaybackStateChanged(state: Int) { + val stateStr = when (state) { + Player.STATE_IDLE -> "idle" + Player.STATE_BUFFERING -> "buffering" + Player.STATE_READY -> "ready" + Player.STATE_ENDED -> "ended" + else -> "unknown" } + emitLog("debug", "state", stateStr) + emitCurrentSeekable() - override fun onTracksChanged(tracks: Tracks) { - Log.d(TAG, "onTracksChanged") - // Log selected video and audio track details - val videoGroup = tracks.groups.firstOrNull { it.type == C.TRACK_TYPE_VIDEO && it.isSelected } - val audioGroup = tracks.groups.firstOrNull { it.type == C.TRACK_TYPE_AUDIO && it.isSelected } - if (videoGroup != null) { - val vf = videoGroup.mediaTrackGroup.getFormat(0) - val hdr = vf.colorInfo?.let { ci -> - val transfer = ci.colorTransfer - if (transfer != null && transfer != 0) " HDR(transfer=$transfer)" else "" - } ?: "" - emitLog("info", "tracks", "Video: ${vf.codecs} ${vf.width}x${vf.height}$hdr") + when (state) { + Player.STATE_BUFFERING -> { + delegate?.onPropertyChange("paused-for-cache", true) + } + Player.STATE_READY -> { + // Restore start position if it was lost during track reselection + // (e.g. tunneling state change in onTracksChanged triggers renderer teardown) + if (pendingStartPositionMs > 0L) { + val currentPos = exoPlayer?.currentPosition ?: 0L + if (currentPos < 1000L) { + emitLog("warn", "state", "Position lost (at ${currentPos}ms, expected ${pendingStartPositionMs}ms) — restoring") + exoPlayer?.seekTo(pendingStartPositionMs) + } + pendingStartPositionMs = 0L } - if (audioGroup != null) { - val af = audioGroup.mediaTrackGroup.getFormat(0) - emitLog("info", "tracks", "Audio: ${af.codecs} ${af.channelCount}ch ${af.sampleRate}Hz") + val pendingPlay = pendingPlayWhenReady + val currentPlayWhenReady = exoPlayer?.playWhenReady + if (pendingPlay != null && currentPlayWhenReady != pendingPlay) { + emitLog("warn", "state", "playWhenReady lost (now $currentPlayWhenReady, expected $pendingPlay) — restoring") + exoPlayer?.playWhenReady = pendingPlay } - // Detect video track present but deselected (unsupported codec — plays audio only) - val hasAnyVideoGroup = tracks.groups.any { it.type == C.TRACK_TYPE_VIDEO } - val hasSelectedVideo = tracks.groups.any { it.type == C.TRACK_TYPE_VIDEO && it.isSelected } - if (hasAnyVideoGroup && !hasSelectedVideo && currentMediaUri != null) { - // Try DV conversion before falling to MPV - if (retryWithDvConversion("video track not selected")) return - emitLog("warn", "fallback", "Video track present but not selected (unsupported codec)") - delegate?.onFormatUnsupported( - uri = currentMediaUri!!, - headers = currentHeaders, - positionMs = effectivePosition, - errorMessage = "Video track present but no decoder available" - ) - return - } - - evaluateAudioCodecForTunneling() - evaluateVideoCodecForTunneling() - updateTunnelingState("tracks changed") + delegate?.onPropertyChange("paused-for-cache", false) + delegate?.onEvent("playback-restart", null) emitTrackList() + + // Start frame watchdog to detect black screen (HDR tunneling issue) + startFrameWatchdog() + } + Player.STATE_ENDED -> { + stopFrameWatchdog() + delegate?.onPropertyChange("eof-reached", true) + delegate?.onEvent("end-file", mapOf("reason" to "eof")) + } + } + } + + override fun onTracksChanged(tracks: Tracks) { + Log.d(TAG, "onTracksChanged") + // Log selected video and audio track details + val videoGroup = tracks.groups.firstOrNull { it.type == C.TRACK_TYPE_VIDEO && it.isSelected } + val audioGroup = tracks.groups.firstOrNull { it.type == C.TRACK_TYPE_AUDIO && it.isSelected } + if (videoGroup != null) { + val vf = videoGroup.mediaTrackGroup.getFormat(0) + val hdr = vf.colorInfo?.let { ci -> + val transfer = ci.colorTransfer + if (transfer != null && transfer != 0) " HDR(transfer=$transfer)" else "" + } ?: "" + emitLog("info", "tracks", "Video: ${vf.codecs} ${vf.width}x${vf.height}$hdr") + } + if (audioGroup != null) { + val af = audioGroup.mediaTrackGroup.getFormat(0) + emitLog("info", "tracks", "Audio: ${af.codecs} ${af.channelCount}ch ${af.sampleRate}Hz") + } + // Detect video track present but deselected (unsupported codec — plays audio only) + val hasAnyVideoGroup = tracks.groups.any { it.type == C.TRACK_TYPE_VIDEO } + val hasSelectedVideo = tracks.groups.any { it.type == C.TRACK_TYPE_VIDEO && it.isSelected } + if (hasAnyVideoGroup && !hasSelectedVideo && currentMediaUri != null) { + // Try DV conversion before falling to MPV + if (retryWithDvConversion("video track not selected")) return + emitLog("warn", "fallback", "Video track present but not selected (unsupported codec)") + delegate?.onFormatUnsupported( + uri = currentMediaUri!!, + headers = currentHeaders, + positionMs = effectivePosition, + errorMessage = "Video track present but no decoder available" + ) + return } - override fun onPlayerError(error: PlaybackException) { - // Log full exception chain unminified — R8 mangles simpleName but not toString/message - val causeChain = buildString { - var t: Throwable? = error.cause - while (t != null) { - if (isNotEmpty()) append(" → ") - append("${t.javaClass.name}: ${t.message}") - t = t.cause - } + evaluateAudioCodecForTunneling() + evaluateVideoCodecForTunneling() + updateTunnelingState("tracks changed") + emitTrackList() + } + + override fun onPlayerError(error: PlaybackException) { + // Log full exception chain unminified — R8 mangles simpleName but not toString/message + val causeChain = buildString { + var t: Throwable? = error.cause + while (t != null) { + if (isNotEmpty()) append(" → ") + append("${t.javaClass.name}: ${t.message}") + t = t.cause + } + } + emitLog("error", "player", "Error code=${error.errorCode}: ${error.message}, cause=${causeChain.ifEmpty { "none" }}") + stopFrameWatchdog() + cancelDecoderHangCheck() + emitSeekable(false, force = true) + + // If native DV7 failed, retry with conversion before falling to MPV + if (error.errorCode in 4001..4005 && retryWithDvConversion("decoder error ${error.errorCode}")) return + + // Server returned HTTP 500 — typically a shared-user bandwidth/transcoding limit + // set by the server owner. MPV will hit the same rejection, so skip the fallback. + // Keep the "server-http-500" tag in sync with PlayerError.serverHttp500 in Dart. + val isHttp500 = + causeChain.contains("Response code: 500") || + (error.message?.contains("Response code: 500") == true) + if (isHttp500) { + Log.w(TAG, "Server returned HTTP 500 - skipping MPV fallback (unrecoverable until server-side change)") + delegate?.onEvent( + "end-file", + mapOf( + "reason" to "error", + "message" to (error.message ?: "HTTP 500"), + "cause" to "server-http-500" + ) + ) + return + } + + if (currentMediaUri != null) { + Log.w(TAG, "ExoPlayer error (code ${error.errorCode}) - attempting fallback to MPV") + val handled = delegate?.onFormatUnsupported( + uri = currentMediaUri!!, + headers = currentHeaders, + positionMs = effectivePosition, + errorMessage = error.message ?: "Unknown error" + ) ?: false + + if (handled) return + } + + delegate?.onEvent( + "end-file", + mapOf( + "reason" to "error", + "message" to (error.message ?: "Unknown error") + ) + ) + } + + /** + * When native DV7 decoding fails (device falsely advertises DV7 support), + * upgrade to DV7→8.1 conversion or HEVC strip and reload the media. + * Returns true if retry was initiated. + */ + private fun retryWithDvConversion(reason: String): Boolean { + if (dv7RetryAttempted) return false + if (dvMode != DvConversionMode.DISABLED) return false + if (!DoviBridge.isAvailable()) return false + val uri = currentMediaUri ?: return false + + dv7RetryAttempted = true + val newMode = DoviBridge.getDv7FallbackMode() + dvMode = newMode + Log.i(TAG, "Native DV7 playback failed ($reason), retrying with $newMode") + emitLog("info", "dv-fallback", "DV7 native failed ($reason), retrying as $newMode") + + open( + uri = uri, + headers = currentHeaders, + startPositionMs = lastPosition, + autoPlay = true + ) + return true + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + Log.d(TAG, "onMediaItemTransition: ${mediaItem?.mediaId}, reason: $reason") + delegate?.onEvent("file-loaded", null) + delegate?.onPropertyChange("eof-reached", false) + emitCurrentSeekable(force = true) + } + + override fun onVideoSizeChanged(videoSize: VideoSize) { + Log.d(TAG, "Video size changed: ${videoSize.width}x${videoSize.height}, ratio: ${videoSize.pixelWidthHeightRatio}") + lastVideoSize = videoSize + updateSurfaceViewSize(videoSize.width, videoSize.height, videoSize.pixelWidthHeightRatio) + } + + private fun updateSurfaceViewSize(videoWidth: Int, videoHeight: Int, pixelRatio: Float) { + if (disposing) return + if (videoWidth == 0 || videoHeight == 0) return + + val videoAspect = (videoWidth * pixelRatio) / videoHeight + activity.runOnUiThread { + videoAspectContainer?.setAspectRatio(videoAspect) + } + updateSubtitleViewSize(videoWidth, videoHeight, pixelRatio) + } + + private fun updateSubtitleViewSize(videoWidth: Int, videoHeight: Int, pixelRatio: Float) { + if (disposing) return + if (videoWidth == 0 || videoHeight == 0) return + + val subtitle = subtitleView ?: return + val contentView = activity.findViewById(android.R.id.content) + val containerWidth = contentView.width + val containerHeight = contentView.height + if (containerWidth == 0 || containerHeight == 0) return + + // In cover/stretch modes subtitles stay at container size so they never get + // cropped or distorted. In letterbox mode they follow the video rect so they + // anchor to the bottom of the video (matching MPV's default sub positioning). + val isLetterbox = videoAspectContainer?.resizeMode == AspectRatioFrameLayout.RESIZE_MODE_FIT + val (subWidth, subHeight) = if (isLetterbox) { + val videoAspect = (videoWidth * pixelRatio) / videoHeight + val containerAspect = containerWidth.toFloat() / containerHeight + if (videoAspect > containerAspect) { + containerWidth to (containerWidth / videoAspect).toInt() + } else { + (containerHeight * videoAspect).toInt() to containerHeight + } + } else { + containerWidth to containerHeight + } + + activity.runOnUiThread { + subtitle.layoutParams = FrameLayout.LayoutParams(subWidth, subHeight).apply { + gravity = Gravity.CENTER + } + subtitle.requestLayout() + } + } + + private fun boxFitModeToResizeMode(mode: Int): Int = when (mode) { + 1 -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM + 2 -> AspectRatioFrameLayout.RESIZE_MODE_FILL + else -> AspectRatioFrameLayout.RESIZE_MODE_FIT + } + + fun setBoxFitMode(mode: Int) { + if (disposing) return + activity.runOnUiThread { + videoAspectContainer?.resizeMode = boxFitModeToResizeMode(mode.coerceIn(0, 2)) + lastVideoSize?.let { vs -> + if (vs.width > 0 && vs.height > 0) { + updateSubtitleViewSize(vs.width, vs.height, vs.pixelWidthHeightRatio) } - emitLog("error", "player", "Error code=${error.errorCode}: ${error.message}, cause=${causeChain.ifEmpty { "none" }}") + } + } + } + + private fun emitTrackList() { + val player = exoPlayer ?: return + val tracks = player.currentTracks + + val trackList = mutableListOf>() + audioTrackGroupMap.clear() + subtitleTrackGroupMap.clear() + + // Group tracks by type and use group index as track ID (matching select functions) + val audioGroups = tracks.groups.filter { it.type == C.TRACK_TYPE_AUDIO } + val textGroups = tracks.groups.filter { it.type == C.TRACK_TYPE_TEXT } + val videoGroups = tracks.groups.filter { it.type == C.TRACK_TYPE_VIDEO } + + var selectedAudioId: String? = null + var selectedSubId: String? = null + + // Process audio tracks + audioGroups.forEachIndexed { groupIndex, group -> + val trackGroup = group.mediaTrackGroup + // Use first format in group as the representative track + val format = trackGroup.getFormat(0) + val trackId = "${C.TRACK_TYPE_AUDIO}_$groupIndex" + audioTrackGroupMap[trackId] = trackGroup + val isSelected = group.isSelected + + val track = mutableMapOf( + "type" to "audio", + "id" to trackId, + "title" to format.label, + "lang" to format.language, + "codec" to format.codecs, + "default" to (format.selectionFlags and C.SELECTION_FLAG_DEFAULT != 0), + "selected" to isSelected, + "demux-channel-count" to format.channelCount, + "demux-samplerate" to format.sampleRate + ) + trackList.add(track) + + if (isSelected) { + selectedAudioId = trackId + } + } + + // Process subtitle tracks (embedded + side-loaded external) + Log.d(TAG, "emitTrackList: found ${textGroups.size} subtitle track groups") + textGroups.forEachIndexed { groupIndex, group -> + val trackGroup = group.mediaTrackGroup + val format = trackGroup.getFormat(0) + val trackId = "${C.TRACK_TYPE_TEXT}_$groupIndex" + subtitleTrackGroupMap[trackId] = trackGroup + val isSelected = group.isSelected + + // Detect external (side-loaded) subtitle by the ID prefix set in open() + val isExternal = format.id?.startsWith("external_") == true + val externalIndex = if (isExternal) format.id?.removePrefix("external_")?.toIntOrNull() else null + val externalUri = externalIndex?.takeIf { it in externalSubtitleUris.indices }?.let { externalSubtitleUris[it] } + + Log.d(TAG, "Subtitle track $groupIndex: codec=${format.codecs}, lang=${format.language}, selected=$isSelected, external=$isExternal") + + val track = mutableMapOf( + "type" to "sub", + "id" to trackId, + "title" to format.label, + "lang" to format.language, + "codec" to format.codecs, + "default" to (format.selectionFlags and C.SELECTION_FLAG_DEFAULT != 0), + "forced" to (format.selectionFlags and C.SELECTION_FLAG_FORCED != 0), + "selected" to isSelected, + "external" to isExternal, + "external-filename" to externalUri + ) + trackList.add(track) + + if (isSelected) { + selectedSubId = trackId + } + } + + // Process video tracks (for completeness, typically only one) + videoGroups.forEachIndexed { groupIndex, group -> + val trackGroup = group.mediaTrackGroup + val format = trackGroup.getFormat(0) + val trackId = "${C.TRACK_TYPE_VIDEO}_$groupIndex" + + val track = mutableMapOf( + "type" to "video", + "id" to trackId, + "title" to format.label, + "lang" to format.language, + "codec" to format.codecs, + "default" to (format.selectionFlags and C.SELECTION_FLAG_DEFAULT != 0), + "selected" to group.isSelected + ) + trackList.add(track) + } + + // Emit selected track IDs + if (selectedAudioId != null) { + selectedAudioTrackId = selectedAudioId + delegate?.onPropertyChange("aid", selectedAudioId) + } + + if (selectedSubId != null) { + selectedSubtitleTrackId = selectedSubId + delegate?.onPropertyChange("sid", selectedSubId) + } else if (textGroups.isNotEmpty()) { + selectedSubtitleTrackId = "no" + delegate?.onPropertyChange("sid", "no") + } + + delegate?.onPropertyChange("track-list", trackList) + } + + // Tunneling control — disabled when audio codec has no hardware decoder (requires FFmpeg) + + private fun hasHardwareAudioDecoder(mimeType: String): Boolean { + // FLAC hardware decoders are excluded via MediaCodecSelector (Samsung c2.sec.flac.decoder + // has buggy 32KB input buffer limits), so report no hardware decoder for tunneling purposes. + if (mimeType == MimeTypes.AUDIO_FLAC) return false + hwAudioDecoderCache[mimeType]?.let { return it } + val result = try { + val codecList = android.media.MediaCodecList(android.media.MediaCodecList.REGULAR_CODECS) + var found = false + for (info in codecList.codecInfos) { + if (info.isEncoder) continue + for (type in info.supportedTypes) { + if (type.equals(mimeType, ignoreCase = true)) { + val name = info.name + if (!name.startsWith("OMX.google.") && + !name.startsWith("c2.android.") && + !name.contains(".sw.") && + !name.startsWith("c2.ffmpeg.") + ) { + Log.d(TAG, "Found hardware audio decoder for $mimeType: $name") + found = true + break + } + } + } + if (found) break + } + if (!found) Log.d(TAG, "No hardware audio decoder for $mimeType — FFmpeg will handle it") + found + } catch (e: Exception) { + Log.w(TAG, "Failed to query audio decoders for $mimeType: ${e.message}") + false + } + hwAudioDecoderCache[mimeType] = result + return result + } + + private fun videoCodecSupportsTunneledPlayback(mimeType: String): Boolean { + tunneledPlaybackCache[mimeType]?.let { return it } + val result = try { + val codecList = android.media.MediaCodecList(android.media.MediaCodecList.REGULAR_CODECS) + var supported = false + for (info in codecList.codecInfos) { + if (info.isEncoder) continue + for (type in info.supportedTypes) { + if (type.equals(mimeType, ignoreCase = true)) { + val name = info.name + if (name.startsWith("OMX.google.") || + name.startsWith("c2.android.") || + name.contains(".sw.") || + name.startsWith("c2.ffmpeg.") + ) { + continue // Skip software decoders + } + val caps = info.getCapabilitiesForType(type) + if (caps.isFeatureSupported(android.media.MediaCodecInfo.CodecCapabilities.FEATURE_TunneledPlayback)) { + Log.d(TAG, "Hardware video decoder $name supports tunneled playback for $mimeType") + supported = true + break + } else { + Log.d(TAG, "Hardware video decoder $name does NOT support tunneled playback for $mimeType") + } + } + } + if (supported) break + } + supported + } catch (e: Exception) { + Log.w(TAG, "Failed to query video decoders for tunneling support ($mimeType): ${e.message}") + false + } + tunneledPlaybackCache[mimeType] = result + return result + } + + private fun evaluateVideoCodecForTunneling() { + val player = exoPlayer ?: return + val selectedVideoGroup = player.currentTracks.groups.firstOrNull { + it.type == C.TRACK_TYPE_VIDEO && it.isSelected + } ?: return + + val format = selectedVideoGroup.mediaTrackGroup.getFormat(0) + val mimeType = format.sampleMimeType ?: return + + val newDisabled = !videoCodecSupportsTunneledPlayback(mimeType) + if (newDisabled != tunnelingDisabledForVideoCodec) { + tunnelingDisabledForVideoCodec = newDisabled + emitLog("info", "tunneling", "Video codec ${format.codecs} ($mimeType): tunneling ${if (newDisabled) "DISABLED (no tunneling support)" else "enabled"}") + } + } + + private fun updateTunnelingState(reason: String) { + val selector = trackSelector ?: return + val player = exoPlayer ?: return + val audioDelayActive = (renderersFactory?.audioDelayUs?.get() ?: 0L) != 0L + val shouldTunnel = tunnelingUserEnabled && (player.playbackParameters.speed == 1f) && !tunnelingDisabledForCodec && !audioDelayActive + if (shouldTunnel == currentTunneledPlayback) return + currentTunneledPlayback = shouldTunnel + emitLog("info", "tunneling", "Toggling tunneling=$shouldTunnel (reason=$reason, user=$tunnelingUserEnabled, speed=${player.playbackParameters.speed}, audioCodecDisabled=$tunnelingDisabledForAudioCodec, videoCodecDisabled=$tunnelingDisabledForVideoCodec, audioDelay=$audioDelayActive)") + selector.setParameters( + selector.buildUponParameters().setTunnelingEnabled(shouldTunnel) + ) + } + + private fun evaluateAudioCodecForTunneling() { + val player = exoPlayer ?: return + val selectedAudioGroup = player.currentTracks.groups.firstOrNull { + it.type == C.TRACK_TYPE_AUDIO && it.isSelected + } ?: return + + val format = selectedAudioGroup.mediaTrackGroup.getFormat(0) + val mimeType = format.sampleMimeType ?: return + + val newDisabled = !hasHardwareAudioDecoder(mimeType) + if (newDisabled != tunnelingDisabledForAudioCodec) { + tunnelingDisabledForAudioCodec = newDisabled + emitLog("info", "tunneling", "Audio codec ${format.codecs} ($mimeType): tunneling ${if (newDisabled) "DISABLED (no hw decoder)" else "enabled"}") + } + } + + private fun buildMediaItem(uri: String): MediaItem { + val mediaItemBuilder = MediaItem.Builder() + .setUri(uri) + + if (externalSubtitles.isNotEmpty()) { + mediaItemBuilder.setSubtitleConfigurations(externalSubtitles.toList()) + } + + return mediaItemBuilder.build() + } + + // Decoder hang detection via AnalyticsListener: + // Tracks the gap between onVideoDecoderInitialized and onRenderedFirstFrame. + // If the decoder is initialized and fed input but never produces output, it's hung + // (e.g. DV profile 7 on PowerVR GPUs that accept the format but never decode). + + private val decoderHangListener = object : AnalyticsListener { + override fun onVideoDecoderInitialized( + eventTime: AnalyticsListener.EventTime, + decoderName: String, + initializationDurationMs: Long + ) { + decoderInitName = decoderName + firstFrameRendered = false + emitLog("debug", "decoder-hang", "Decoder initialized: $decoderName (${initializationDurationMs}ms)") + startDecoderHangCheck(decoderName) + } + + override fun onAudioDecoderInitialized( + eventTime: AnalyticsListener.EventTime, + decoderName: String, + initializationDurationMs: Long + ) { + audioDecoderInitName = decoderName + } + + override fun onRenderedFirstFrame( + eventTime: AnalyticsListener.EventTime, + output: Any, + renderTimeMs: Long + ) { + firstFrameRendered = true + cancelDecoderHangCheck() + emitLog("debug", "decoder-hang", "First frame rendered — decoder OK") + // STATE_READY fires when the player has enough buffered to start, but + // the first frame may not be on screen yet (decoder init + keyframe + // decode). The MPV-parity `playback-restart` event consumers (Dart + // first-frame detection, frame-rate matching) want the moment the + // pixel actually hits the screen, which is here. + delegate?.onEvent("playback-restart", null) + } + } + + private fun startDecoderHangCheck(decoderName: String) { + cancelDecoderHangCheck() + if (currentMediaUri == null) return + decoderHangRunnable = Runnable { + if (firstFrameRendered) return@Runnable + val uri = currentMediaUri ?: return@Runnable + val player = exoPlayer ?: return@Runnable + + // Confirm via DecoderCounters: input queued but no output produced + val counters = player.videoDecoderCounters + val inputQueued = counters?.queuedInputBufferCount ?: 0 + val outputTotal = (counters?.renderedOutputBufferCount ?: 0) + + (counters?.skippedOutputBufferCount ?: 0) + + (counters?.droppedBufferCount ?: 0) + + if (inputQueued > 0 && outputTotal == 0) { + emitLog("warn", "fallback", "Decoder hang: $decoderName queued $inputQueued buffers, 0 output after ${DECODER_HANG_TIMEOUT_MS}ms") stopFrameWatchdog() cancelDecoderHangCheck() - emitSeekable(false, force = true) - - // If native DV7 failed, retry with conversion before falling to MPV - if (error.errorCode in 4001..4005 && retryWithDvConversion("decoder error ${error.errorCode}")) return - - // Server returned HTTP 500 — typically a shared-user bandwidth/transcoding limit - // set by the server owner. MPV will hit the same rejection, so skip the fallback. - // Keep the "server-http-500" tag in sync with PlayerError.serverHttp500 in Dart. - val isHttp500 = - causeChain.contains("Response code: 500") || - (error.message?.contains("Response code: 500") == true) - if (isHttp500) { - Log.w(TAG, "Server returned HTTP 500 - skipping MPV fallback (unrecoverable until server-side change)") - delegate?.onEvent("end-file", mapOf( - "reason" to "error", - "message" to (error.message ?: "HTTP 500"), - "cause" to "server-http-500" - )) - return - } - - if (currentMediaUri != null) { - Log.w(TAG, "ExoPlayer error (code ${error.errorCode}) - attempting fallback to MPV") - val handled = delegate?.onFormatUnsupported( - uri = currentMediaUri!!, - headers = currentHeaders, - positionMs = effectivePosition, - errorMessage = error.message ?: "Unknown error" - ) ?: false - - if (handled) return - } - - delegate?.onEvent("end-file", mapOf( - "reason" to "error", - "message" to (error.message ?: "Unknown error") - )) + if (retryWithDvConversion("decoder hang: $decoderName")) return@Runnable + delegate?.onFormatUnsupported( + uri = uri, + headers = currentHeaders, + positionMs = effectivePosition, + errorMessage = "Decoder hang: $decoderName accepted input but produced no output" + ) + } } + handler.postDelayed(decoderHangRunnable!!, DECODER_HANG_TIMEOUT_MS) + } - /** - * When native DV7 decoding fails (device falsely advertises DV7 support), - * upgrade to DV7→8.1 conversion or HEVC strip and reload the media. - * Returns true if retry was initiated. - */ - private fun retryWithDvConversion(reason: String): Boolean { - if (dv7RetryAttempted) return false - if (dvMode != DvConversionMode.DISABLED) return false - if (!DoviBridge.isAvailable()) return false - val uri = currentMediaUri ?: return false + private fun cancelDecoderHangCheck() { + decoderHangRunnable?.let { handler.removeCallbacks(it) } + decoderHangRunnable = null + } - dv7RetryAttempted = true - val newMode = DoviBridge.getDv7FallbackMode() - dvMode = newMode - Log.i(TAG, "Native DV7 playback failed ($reason), retrying with $newMode") - emitLog("info", "dv-fallback", "DV7 native failed ($reason), retrying as $newMode") + // Frame watchdog: detects when ExoPlayer plays audio but renders 0 video frames + // (common with HDR tunneling on unsupported devices — black screen, no error) - open( + private fun startFrameWatchdog() { + stopFrameWatchdog() + emitLog("debug", "watchdog", "Started (timeout=${WATCHDOG_TIMEOUT_MS}ms)") + frameWatchdogStartTime = System.currentTimeMillis() + frameWatchdogRunnable = object : Runnable { + override fun run() { + val player = exoPlayer ?: return + val renderedFrames = player.videoDecoderCounters?.renderedOutputBufferCount ?: 0 + + if (renderedFrames > 0) { + emitLog("debug", "watchdog", "$renderedFrames frames rendered, cleared") + stopFrameWatchdog() + return + } + + val elapsed = System.currentTimeMillis() - frameWatchdogStartTime + + // Check if we have a video track selected + val hasVideoTrack = player.currentTracks.groups.any { + it.type == C.TRACK_TYPE_VIDEO && it.isSelected + } + val hasAnyVideoGroup = player.currentTracks.groups.any { + it.type == C.TRACK_TYPE_VIDEO + } + + // Secondary safety net: video track exists but was deselected (unsupported codec) + if (hasAnyVideoGroup && !hasVideoTrack) { + emitLog("warn", "watchdog", "Video track deselected — triggering fallback") + stopFrameWatchdog() + if (retryWithDvConversion("watchdog: video track deselected")) return + val uri = currentMediaUri ?: return + delegate?.onFormatUnsupported( uri = uri, headers = currentHeaders, - startPositionMs = lastPosition, - autoPlay = true, - ) - return true - } - - override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - Log.d(TAG, "onMediaItemTransition: ${mediaItem?.mediaId}, reason: $reason") - delegate?.onEvent("file-loaded", null) - delegate?.onPropertyChange("eof-reached", false) - emitCurrentSeekable(force = true) - } - - override fun onVideoSizeChanged(videoSize: VideoSize) { - Log.d(TAG, "Video size changed: ${videoSize.width}x${videoSize.height}, ratio: ${videoSize.pixelWidthHeightRatio}") - lastVideoSize = videoSize - updateSurfaceViewSize(videoSize.width, videoSize.height, videoSize.pixelWidthHeightRatio) - } - - private fun updateSurfaceViewSize(videoWidth: Int, videoHeight: Int, pixelRatio: Float) { - if (disposing) return - if (videoWidth == 0 || videoHeight == 0) return - - val videoAspect = (videoWidth * pixelRatio) / videoHeight - activity.runOnUiThread { - videoAspectContainer?.setAspectRatio(videoAspect) - } - updateSubtitleViewSize(videoWidth, videoHeight, pixelRatio) - } - - private fun updateSubtitleViewSize(videoWidth: Int, videoHeight: Int, pixelRatio: Float) { - if (disposing) return - if (videoWidth == 0 || videoHeight == 0) return - - val subtitle = subtitleView ?: return - val contentView = activity.findViewById(android.R.id.content) - val containerWidth = contentView.width - val containerHeight = contentView.height - if (containerWidth == 0 || containerHeight == 0) return - - // In cover/stretch modes subtitles stay at container size so they never get - // cropped or distorted. In letterbox mode they follow the video rect so they - // anchor to the bottom of the video (matching MPV's default sub positioning). - val isLetterbox = videoAspectContainer?.resizeMode == AspectRatioFrameLayout.RESIZE_MODE_FIT - val (subWidth, subHeight) = if (isLetterbox) { - val videoAspect = (videoWidth * pixelRatio) / videoHeight - val containerAspect = containerWidth.toFloat() / containerHeight - if (videoAspect > containerAspect) { - containerWidth to (containerWidth / videoAspect).toInt() - } else { - (containerHeight * videoAspect).toInt() to containerHeight - } - } else { - containerWidth to containerHeight + positionMs = player.currentPosition, + errorMessage = "Video track present but no decoder available" + ) + return } - activity.runOnUiThread { - subtitle.layoutParams = FrameLayout.LayoutParams(subWidth, subHeight).apply { - gravity = Gravity.CENTER - } - subtitle.requestLayout() - } - } - - private fun boxFitModeToResizeMode(mode: Int): Int = when (mode) { - 1 -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM - 2 -> AspectRatioFrameLayout.RESIZE_MODE_FILL - else -> AspectRatioFrameLayout.RESIZE_MODE_FIT - } - - fun setBoxFitMode(mode: Int) { - if (disposing) return - activity.runOnUiThread { - videoAspectContainer?.resizeMode = boxFitModeToResizeMode(mode.coerceIn(0, 2)) - lastVideoSize?.let { vs -> - if (vs.width > 0 && vs.height > 0) { - updateSubtitleViewSize(vs.width, vs.height, vs.pixelWidthHeightRatio) - } - } - } - } - - private fun emitTrackList() { - val player = exoPlayer ?: return - val tracks = player.currentTracks - - val trackList = mutableListOf>() - audioTrackGroupMap.clear() - subtitleTrackGroupMap.clear() - - // Group tracks by type and use group index as track ID (matching select functions) - val audioGroups = tracks.groups.filter { it.type == C.TRACK_TYPE_AUDIO } - val textGroups = tracks.groups.filter { it.type == C.TRACK_TYPE_TEXT } - val videoGroups = tracks.groups.filter { it.type == C.TRACK_TYPE_VIDEO } - - var selectedAudioId: String? = null - var selectedSubId: String? = null - - // Process audio tracks - audioGroups.forEachIndexed { groupIndex, group -> - val trackGroup = group.mediaTrackGroup - // Use first format in group as the representative track - val format = trackGroup.getFormat(0) - val trackId = "${C.TRACK_TYPE_AUDIO}_$groupIndex" - audioTrackGroupMap[trackId] = trackGroup - val isSelected = group.isSelected - - val track = mutableMapOf( - "type" to "audio", - "id" to trackId, - "title" to format.label, - "lang" to format.language, - "codec" to format.codecs, - "default" to (format.selectionFlags and C.SELECTION_FLAG_DEFAULT != 0), - "selected" to isSelected, - "demux-channel-count" to format.channelCount, - "demux-samplerate" to format.sampleRate - ) - trackList.add(track) - - if (isSelected) { - selectedAudioId = trackId - } + if (elapsed >= WATCHDOG_TIMEOUT_MS && player.isPlaying && hasVideoTrack) { + emitLog("warn", "watchdog", "0 frames rendered after ${elapsed}ms — triggering fallback") + stopFrameWatchdog() + if (retryWithDvConversion("watchdog: black screen after ${elapsed}ms")) return + // Trigger fallback via the same delegate path as player errors + val uri = currentMediaUri ?: return + delegate?.onFormatUnsupported( + uri = uri, + headers = currentHeaders, + positionMs = player.currentPosition, + errorMessage = "Black screen detected: 0 video frames rendered after ${elapsed}ms" + ) + return } - // Process subtitle tracks (embedded + side-loaded external) - Log.d(TAG, "emitTrackList: found ${textGroups.size} subtitle track groups") - textGroups.forEachIndexed { groupIndex, group -> - val trackGroup = group.mediaTrackGroup - val format = trackGroup.getFormat(0) - val trackId = "${C.TRACK_TYPE_TEXT}_$groupIndex" - subtitleTrackGroupMap[trackId] = trackGroup - val isSelected = group.isSelected + handler.postDelayed(this, WATCHDOG_CHECK_INTERVAL_MS) + } + } + handler.postDelayed(frameWatchdogRunnable!!, WATCHDOG_CHECK_INTERVAL_MS) + } - // Detect external (side-loaded) subtitle by the ID prefix set in open() - val isExternal = format.id?.startsWith("external_") == true - val externalIndex = if (isExternal) format.id?.removePrefix("external_")?.toIntOrNull() else null - val externalUri = externalIndex?.takeIf { it in externalSubtitleUris.indices }?.let { externalSubtitleUris[it] } + private fun stopFrameWatchdog() { + frameWatchdogRunnable?.let { handler.removeCallbacks(it) } + frameWatchdogRunnable = null + } - Log.d(TAG, "Subtitle track $groupIndex: codec=${format.codecs}, lang=${format.language}, selected=$isSelected, external=$isExternal") + // Public API - val track = mutableMapOf( - "type" to "sub", - "id" to trackId, - "title" to format.label, - "lang" to format.language, - "codec" to format.codecs, - "default" to (format.selectionFlags and C.SELECTION_FLAG_DEFAULT != 0), - "forced" to (format.selectionFlags and C.SELECTION_FLAG_FORCED != 0), - "selected" to isSelected, - "external" to isExternal, - "external-filename" to externalUri - ) - trackList.add(track) + fun open( + uri: String, + headers: Map?, + startPositionMs: Long, + autoPlay: Boolean, + isLive: Boolean = false, + externalSubtitleList: List>? = null + ) { + if (!isInitialized) return - if (isSelected) { - selectedSubId = trackId - } - } + stopFrameWatchdog() + cancelDecoderHangCheck() - // Process video tracks (for completeness, typically only one) - videoGroups.forEachIndexed { groupIndex, group -> - val trackGroup = group.mediaTrackGroup - val format = trackGroup.getFormat(0) - val trackId = "${C.TRACK_TYPE_VIDEO}_$groupIndex" + // Reset FPS detection for new content + detectedFrameRate = -1f + fpsTimestampCount = 0 - val track = mutableMapOf( - "type" to "video", - "id" to trackId, - "title" to format.label, - "lang" to format.language, - "codec" to format.codecs, - "default" to (format.selectionFlags and C.SELECTION_FLAG_DEFAULT != 0), - "selected" to group.isSelected - ) - trackList.add(track) - } - - // Emit selected track IDs - if (selectedAudioId != null) { - selectedAudioTrackId = selectedAudioId - delegate?.onPropertyChange("aid", selectedAudioId) - } - - if (selectedSubId != null) { - selectedSubtitleTrackId = selectedSubId - delegate?.onPropertyChange("sid", selectedSubId) - } else if (textGroups.isNotEmpty()) { - selectedSubtitleTrackId = "no" - delegate?.onPropertyChange("sid", "no") - } - - delegate?.onPropertyChange("track-list", trackList) + // Reset DV7 retry flag when opening a different file + if (uri != currentMediaUri) { + dv7RetryAttempted = false } - // Tunneling control — disabled when audio codec has no hardware decoder (requires FFmpeg) + decoderInitName = null + audioDecoderInitName = null + currentMediaUri = uri + currentHeaders = headers + currentMediaIsLive = isLive - private fun hasHardwareAudioDecoder(mimeType: String): Boolean { - // FLAC hardware decoders are excluded via MediaCodecSelector (Samsung c2.sec.flac.decoder - // has buggy 32KB input buffer limits), so report no hardware decoder for tunneling purposes. - if (mimeType == MimeTypes.AUDIO_FLAC) return false - hwAudioDecoderCache[mimeType]?.let { return it } - val result = try { - val codecList = android.media.MediaCodecList(android.media.MediaCodecList.REGULAR_CODECS) - var found = false - for (info in codecList.codecInfos) { - if (info.isEncoder) continue - for (type in info.supportedTypes) { - if (type.equals(mimeType, ignoreCase = true)) { - val name = info.name - if (!name.startsWith("OMX.google.") && - !name.startsWith("c2.android.") && - !name.contains(".sw.") && - !name.startsWith("c2.ffmpeg.")) { - Log.d(TAG, "Found hardware audio decoder for $mimeType: $name") - found = true - break - } - } - } - if (found) break - } - if (!found) Log.d(TAG, "No hardware audio decoder for $mimeType — FFmpeg will handle it") - found - } catch (e: Exception) { - Log.w(TAG, "Failed to query audio decoders for $mimeType: ${e.message}") - false - } - hwAudioDecoderCache[mimeType] = result - return result + // Apply auth/custom headers to the HTTP DataSource for this session + httpDataSourceFactory?.setDefaultRequestProperties( + if (!headers.isNullOrEmpty()) headers else emptyMap() + ) + + externalSubtitles.clear() + externalSubtitleUris.clear() + audioTrackGroupMap.clear() + subtitleTrackGroupMap.clear() + selectedAudioTrackId = null + selectedSubtitleTrackId = null + + // Build external subtitle configurations (attached to MediaItem before prepare) + externalSubtitleList?.forEachIndexed { index, sub -> + val subUri = sub["uri"] ?: return@forEachIndexed + val config = MediaItem.SubtitleConfiguration.Builder(Uri.parse(subUri)) + .setId("external_$index") + .setLabel(sub["title"] ?: "External") + .setLanguage(sub["language"]) + .setMimeType(sub["mimeType"] ?: detectSubtitleMimeType(subUri)) + .build() + externalSubtitles.add(config) + externalSubtitleUris.add(subUri) + } + tunnelingDisabledForAudioCodec = false + tunnelingDisabledForVideoCodec = false + currentTunneledPlayback = tunnelingUserEnabled + pendingStartPositionMs = startPositionMs + pendingPlayWhenReady = autoPlay + trackSelector?.setParameters( + trackSelector!!.buildUponParameters() + .setTunnelingEnabled(tunnelingUserEnabled) + .clearOverridesOfType(C.TRACK_TYPE_AUDIO) + .clearOverridesOfType(C.TRACK_TYPE_TEXT) + ) + emitSeekable(false, force = true) + + if (isLive) { + // Live MKV streams lack Cues (seek index). FLAG_DISABLE_SEEK_FOR_CUES tells + // MatroskaExtractor to not seek for them, treating the stream as unseekable + // so data flows immediately without hanging. + // Headers already applied to httpDataSourceFactory above. + val extractorsFactory = androidx.media3.extractor.ExtractorsFactory { + arrayOf(MatroskaExtractor(MatroskaExtractor.FLAG_DISABLE_SEEK_FOR_CUES)) + } + + val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory!!, extractorsFactory) + .createMediaSource(MediaItem.fromUri(uri)) + + exoPlayer?.apply { + setMediaSource(mediaSource, startPositionMs) + prepare() + playWhenReady = autoPlay + } + + emitLog("info", "media", "Opened live: ${redactUri(uri)}, startPosition: ${startPositionMs}ms, autoPlay: $autoPlay, sessionTunneling=$currentTunneledPlayback") + return } - private fun videoCodecSupportsTunneledPlayback(mimeType: String): Boolean { - tunneledPlaybackCache[mimeType]?.let { return it } - val result = try { - val codecList = android.media.MediaCodecList(android.media.MediaCodecList.REGULAR_CODECS) - var supported = false - for (info in codecList.codecInfos) { - if (info.isEncoder) continue - for (type in info.supportedTypes) { - if (type.equals(mimeType, ignoreCase = true)) { - val name = info.name - if (name.startsWith("OMX.google.") || - name.startsWith("c2.android.") || - name.contains(".sw.") || - name.startsWith("c2.ffmpeg.")) { - continue // Skip software decoders - } - val caps = info.getCapabilitiesForType(type) - if (caps.isFeatureSupported(android.media.MediaCodecInfo.CodecCapabilities.FEATURE_TunneledPlayback)) { - Log.d(TAG, "Hardware video decoder $name supports tunneled playback for $mimeType") - supported = true - break - } else { - Log.d(TAG, "Hardware video decoder $name does NOT support tunneled playback for $mimeType") - } - } - } - if (supported) break - } - supported - } catch (e: Exception) { - Log.w(TAG, "Failed to query video decoders for tunneling support ($mimeType): ${e.message}") - false - } - tunneledPlaybackCache[mimeType] = result - return result + val mediaItem = buildMediaItem(uri) + + exoPlayer?.apply { + setMediaItem(mediaItem, startPositionMs) + prepare() + playWhenReady = autoPlay } - private fun evaluateVideoCodecForTunneling() { - val player = exoPlayer ?: return - val selectedVideoGroup = player.currentTracks.groups.firstOrNull { - it.type == C.TRACK_TYPE_VIDEO && it.isSelected - } ?: return + emitLog("info", "media", "Opened: ${redactUri(uri)}, startPosition: ${startPositionMs}ms, autoPlay: $autoPlay, sessionTunneling=$currentTunneledPlayback, userTunneling=$tunnelingUserEnabled") + } - val format = selectedVideoGroup.mediaTrackGroup.getFormat(0) - val mimeType = format.sampleMimeType ?: return + fun setAudioDelay(seconds: Double) { + renderersFactory?.audioDelayUs?.set((seconds * 1_000_000).toLong()) + updateTunnelingState("audio-delay") + } - val newDisabled = !videoCodecSupportsTunneledPlayback(mimeType) - if (newDisabled != tunnelingDisabledForVideoCodec) { - tunnelingDisabledForVideoCodec = newDisabled - emitLog("info", "tunneling", "Video codec ${format.codecs} ($mimeType): tunneling ${if (newDisabled) "DISABLED (no tunneling support)" else "enabled"}") - } + fun setSubtitleDelay(seconds: Double) { + subtitleDelayUs.set((seconds * 1_000_000).toLong()) + } + + fun play() { + pendingPlayWhenReady = null + exoPlayer?.play() + } + + fun pause() { + pendingPlayWhenReady = null + exoPlayer?.pause() + } + + fun stop() { + stopFrameWatchdog() + cancelDecoderHangCheck() + exoPlayer?.stop() + emitSeekable(false, force = true) + setVisible(false) + } + + fun seekTo(positionMs: Long) { + exoPlayer?.seekTo(positionMs) + } + + fun setVolume(volume: Float) { + exoPlayer?.volume = volume.coerceIn(0f, 1f) + delegate?.onPropertyChange("volume", (volume * 100).toDouble()) + } + + fun setPlaybackSpeed(speed: Float) { + val clampedSpeed = speed.coerceIn(0.25f, 4f) + exoPlayer?.setPlaybackSpeed(clampedSpeed) + updateTunnelingState("speed changed") + delegate?.onPropertyChange("speed", speed.toDouble()) + } + + fun selectAudioTrack(trackId: String) { + val selector = trackSelector ?: return + val trackGroup = audioTrackGroupMap[trackId] ?: return + + selector.parameters = selector.buildUponParameters() + .setOverrideForType(TrackSelectionOverride(trackGroup, 0)) + .setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false) + .build() + + selectedAudioTrackId = trackId + delegate?.onPropertyChange("aid", trackId) + } + + fun selectSubtitleTrack(trackId: String?) { + val selector = trackSelector ?: return + + if (trackId == null || trackId == "no") { + selectedSubtitleTrackId = "no" + selector.parameters = selector.buildUponParameters() + .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true) + .build() + delegate?.onPropertyChange("sid", "no") + return } - private fun updateTunnelingState(reason: String) { - val selector = trackSelector ?: return - val player = exoPlayer ?: return - val audioDelayActive = (renderersFactory?.audioDelayUs?.get() ?: 0L) != 0L - val shouldTunnel = tunnelingUserEnabled && (player.playbackParameters.speed == 1f) && !tunnelingDisabledForCodec && !audioDelayActive - if (shouldTunnel == currentTunneledPlayback) return - currentTunneledPlayback = shouldTunnel - emitLog("info", "tunneling", "Toggling tunneling=$shouldTunnel (reason=$reason, user=$tunnelingUserEnabled, speed=${player.playbackParameters.speed}, audioCodecDisabled=$tunnelingDisabledForAudioCodec, videoCodecDisabled=$tunnelingDisabledForVideoCodec, audioDelay=$audioDelayActive)") - selector.setParameters( - selector.buildUponParameters().setTunnelingEnabled(shouldTunnel) - ) + val trackGroup = subtitleTrackGroupMap[trackId] ?: return + selectedSubtitleTrackId = trackId + selector.parameters = selector.buildUponParameters() + .setOverrideForType(TrackSelectionOverride(trackGroup, 0)) + .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) + .build() + delegate?.onPropertyChange("sid", trackId) + } + + fun addSubtitleTrack(uri: String, title: String?, language: String?, mimeType: String?, select: Boolean) { + val existingIndex = externalSubtitleUris.indexOf(uri) + val isNew = existingIndex < 0 + val index = if (isNew) externalSubtitles.size else existingIndex + val formatId = "external_$index" + + if (isNew) { + // SELECTION_FLAG_DEFAULT marks this as the preferred text track so ExoPlayer's + // natural selection picks it on prepare. Avoids pinning the selector to a + // specific TrackGroup override — if the URL 404s (e.g. stale Plex stream key), + // ExoPlayer falls back to another available track (e.g. embedded SRT) instead + // of leaving text disabled. + val selectionFlags = if (select) C.SELECTION_FLAG_DEFAULT else 0 + val subtitleConfig = MediaItem.SubtitleConfiguration.Builder(Uri.parse(uri)) + .setId(formatId) + .setLabel(title ?: "External") + .setLanguage(language) + .setMimeType(mimeType ?: detectSubtitleMimeType(uri)) + .setSelectionFlags(selectionFlags) + .build() + externalSubtitles.add(subtitleConfig) + externalSubtitleUris.add(uri) } - private fun evaluateAudioCodecForTunneling() { - val player = exoPlayer ?: return - val selectedAudioGroup = player.currentTracks.groups.firstOrNull { - it.type == C.TRACK_TYPE_AUDIO && it.isSelected - } ?: return + // Media3 only picks up MediaItem.SubtitleConfiguration at prepare() time + // (tracking issue androidx/media #1649). When the caller wants this subtitle + // activated immediately (e.g. after OpenSubtitles download), rebuild the + // MediaItem and re-prepare with the position preserved. + val player = exoPlayer + val mediaUri = currentMediaUri + if (select && player != null && mediaUri != null && !currentMediaIsLive) { + if (isNew) { + val savedPosition = player.currentPosition + val savedPlayWhenReady = player.playWhenReady - val format = selectedAudioGroup.mediaTrackGroup.getFormat(0) - val mimeType = format.sampleMimeType ?: return - - val newDisabled = !hasHardwareAudioDecoder(mimeType) - if (newDisabled != tunnelingDisabledForAudioCodec) { - tunnelingDisabledForAudioCodec = newDisabled - emitLog("info", "tunneling", "Audio codec ${format.codecs} ($mimeType): tunneling ${if (newDisabled) "DISABLED (no hw decoder)" else "enabled"}") - } - } - - private fun buildMediaItem(uri: String): MediaItem { - val mediaItemBuilder = MediaItem.Builder() - .setUri(uri) - - if (externalSubtitles.isNotEmpty()) { - mediaItemBuilder.setSubtitleConfigurations(externalSubtitles.toList()) - } - - return mediaItemBuilder.build() - } - - // Decoder hang detection via AnalyticsListener: - // Tracks the gap between onVideoDecoderInitialized and onRenderedFirstFrame. - // If the decoder is initialized and fed input but never produces output, it's hung - // (e.g. DV profile 7 on PowerVR GPUs that accept the format but never decode). - - private val decoderHangListener = object : AnalyticsListener { - override fun onVideoDecoderInitialized( - eventTime: AnalyticsListener.EventTime, - decoderName: String, - initializationDurationMs: Long - ) { - decoderInitName = decoderName - firstFrameRendered = false - emitLog("debug", "decoder-hang", "Decoder initialized: $decoderName (${initializationDurationMs}ms)") - startDecoderHangCheck(decoderName) - } - - override fun onAudioDecoderInitialized( - eventTime: AnalyticsListener.EventTime, - decoderName: String, - initializationDurationMs: Long - ) { - audioDecoderInitName = decoderName - } - - override fun onRenderedFirstFrame( - eventTime: AnalyticsListener.EventTime, - output: Any, - renderTimeMs: Long - ) { - firstFrameRendered = true - cancelDecoderHangCheck() - emitLog("debug", "decoder-hang", "First frame rendered — decoder OK") - // STATE_READY fires when the player has enough buffered to start, but - // the first frame may not be on screen yet (decoder init + keyframe - // decode). The MPV-parity `playback-restart` event consumers (Dart - // first-frame detection, frame-rate matching) want the moment the - // pixel actually hits the screen, which is here. - delegate?.onEvent("playback-restart", null) - } - } - - private fun startDecoderHangCheck(decoderName: String) { - cancelDecoderHangCheck() - if (currentMediaUri == null) return - decoderHangRunnable = Runnable { - if (firstFrameRendered) return@Runnable - val uri = currentMediaUri ?: return@Runnable - val player = exoPlayer ?: return@Runnable - - // Confirm via DecoderCounters: input queued but no output produced - val counters = player.videoDecoderCounters - val inputQueued = counters?.queuedInputBufferCount ?: 0 - val outputTotal = (counters?.renderedOutputBufferCount ?: 0) + - (counters?.skippedOutputBufferCount ?: 0) + - (counters?.droppedBufferCount ?: 0) - - if (inputQueued > 0 && outputTotal == 0) { - emitLog("warn", "fallback", "Decoder hang: $decoderName queued $inputQueued buffers, 0 output after ${DECODER_HANG_TIMEOUT_MS}ms") - stopFrameWatchdog() - cancelDecoderHangCheck() - if (retryWithDvConversion("decoder hang: $decoderName")) return@Runnable - delegate?.onFormatUnsupported( - uri = uri, - headers = currentHeaders, - positionMs = effectivePosition, - errorMessage = "Decoder hang: $decoderName accepted input but produced no output" - ) - } - } - handler.postDelayed(decoderHangRunnable!!, DECODER_HANG_TIMEOUT_MS) - } - - private fun cancelDecoderHangCheck() { - decoderHangRunnable?.let { handler.removeCallbacks(it) } - decoderHangRunnable = null - } - - // Frame watchdog: detects when ExoPlayer plays audio but renders 0 video frames - // (common with HDR tunneling on unsupported devices — black screen, no error) - - private fun startFrameWatchdog() { - stopFrameWatchdog() - emitLog("debug", "watchdog", "Started (timeout=${WATCHDOG_TIMEOUT_MS}ms)") - frameWatchdogStartTime = System.currentTimeMillis() - frameWatchdogRunnable = object : Runnable { - override fun run() { - val player = exoPlayer ?: return - val renderedFrames = player.videoDecoderCounters?.renderedOutputBufferCount ?: 0 - - if (renderedFrames > 0) { - emitLog("debug", "watchdog", "$renderedFrames frames rendered, cleared") - stopFrameWatchdog() - return - } - - val elapsed = System.currentTimeMillis() - frameWatchdogStartTime - - // Check if we have a video track selected - val hasVideoTrack = player.currentTracks.groups.any { - it.type == C.TRACK_TYPE_VIDEO && it.isSelected - } - val hasAnyVideoGroup = player.currentTracks.groups.any { - it.type == C.TRACK_TYPE_VIDEO - } - - // Secondary safety net: video track exists but was deselected (unsupported codec) - if (hasAnyVideoGroup && !hasVideoTrack) { - emitLog("warn", "watchdog", "Video track deselected — triggering fallback") - stopFrameWatchdog() - if (retryWithDvConversion("watchdog: video track deselected")) return - val uri = currentMediaUri ?: return - delegate?.onFormatUnsupported( - uri = uri, - headers = currentHeaders, - positionMs = player.currentPosition, - errorMessage = "Video track present but no decoder available" - ) - return - } - - if (elapsed >= WATCHDOG_TIMEOUT_MS && player.isPlaying && hasVideoTrack) { - emitLog("warn", "watchdog", "0 frames rendered after ${elapsed}ms — triggering fallback") - stopFrameWatchdog() - if (retryWithDvConversion("watchdog: black screen after ${elapsed}ms")) return - // Trigger fallback via the same delegate path as player errors - val uri = currentMediaUri ?: return - delegate?.onFormatUnsupported( - uri = uri, - headers = currentHeaders, - positionMs = player.currentPosition, - errorMessage = "Black screen detected: 0 video frames rendered after ${elapsed}ms" - ) - return - } - - handler.postDelayed(this, WATCHDOG_CHECK_INTERVAL_MS) - } - } - handler.postDelayed(frameWatchdogRunnable!!, WATCHDOG_CHECK_INTERVAL_MS) - } - - private fun stopFrameWatchdog() { - frameWatchdogRunnable?.let { handler.removeCallbacks(it) } - frameWatchdogRunnable = null - } - - // Public API - - fun open(uri: String, headers: Map?, startPositionMs: Long, autoPlay: Boolean, isLive: Boolean = false, - externalSubtitleList: List>? = null) { - if (!isInitialized) return - - stopFrameWatchdog() - cancelDecoderHangCheck() - - // Reset FPS detection for new content - detectedFrameRate = -1f - fpsTimestampCount = 0 - - // Reset DV7 retry flag when opening a different file - if (uri != currentMediaUri) { - dv7RetryAttempted = false - } - - decoderInitName = null - audioDecoderInitName = null - currentMediaUri = uri - currentHeaders = headers - currentMediaIsLive = isLive - - // Apply auth/custom headers to the HTTP DataSource for this session - httpDataSourceFactory?.setDefaultRequestProperties( - if (!headers.isNullOrEmpty()) headers else emptyMap() - ) - - externalSubtitles.clear() - externalSubtitleUris.clear() - audioTrackGroupMap.clear() - subtitleTrackGroupMap.clear() - selectedAudioTrackId = null - selectedSubtitleTrackId = null - - // Build external subtitle configurations (attached to MediaItem before prepare) - externalSubtitleList?.forEachIndexed { index, sub -> - val subUri = sub["uri"] ?: return@forEachIndexed - val config = MediaItem.SubtitleConfiguration.Builder(Uri.parse(subUri)) - .setId("external_$index") - .setLabel(sub["title"] ?: "External") - .setLanguage(sub["language"]) - .setMimeType(sub["mimeType"] ?: detectSubtitleMimeType(subUri)) - .build() - externalSubtitles.add(config) - externalSubtitleUris.add(subUri) - } - tunnelingDisabledForAudioCodec = false - tunnelingDisabledForVideoCodec = false - currentTunneledPlayback = tunnelingUserEnabled - pendingStartPositionMs = startPositionMs - pendingPlayWhenReady = autoPlay - trackSelector?.setParameters( - trackSelector!!.buildUponParameters() - .setTunnelingEnabled(tunnelingUserEnabled) - .clearOverridesOfType(C.TRACK_TYPE_AUDIO) - .clearOverridesOfType(C.TRACK_TYPE_TEXT) - ) - emitSeekable(false, force = true) - - if (isLive) { - // Live MKV streams lack Cues (seek index). FLAG_DISABLE_SEEK_FOR_CUES tells - // MatroskaExtractor to not seek for them, treating the stream as unseekable - // so data flows immediately without hanging. - // Headers already applied to httpDataSourceFactory above. - val extractorsFactory = androidx.media3.extractor.ExtractorsFactory { - arrayOf(MatroskaExtractor(MatroskaExtractor.FLAG_DISABLE_SEEK_FOR_CUES)) - } - - val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory!!, extractorsFactory) - .createMediaSource(MediaItem.fromUri(uri)) - - exoPlayer?.apply { - setMediaSource(mediaSource, startPositionMs) - prepare() - playWhenReady = autoPlay - } - - emitLog("info", "media", "Opened live: ${redactUri(uri)}, startPosition: ${startPositionMs}ms, autoPlay: $autoPlay, sessionTunneling=$currentTunneledPlayback") - return - } - - val mediaItem = buildMediaItem(uri) - - exoPlayer?.apply { - setMediaItem(mediaItem, startPositionMs) - prepare() - playWhenReady = autoPlay - } - - emitLog("info", "media", "Opened: ${redactUri(uri)}, startPosition: ${startPositionMs}ms, autoPlay: $autoPlay, sessionTunneling=$currentTunneledPlayback, userTunneling=$tunnelingUserEnabled") - } - - fun setAudioDelay(seconds: Double) { - renderersFactory?.audioDelayUs?.set((seconds * 1_000_000).toLong()) - updateTunnelingState("audio-delay") - } - - fun setSubtitleDelay(seconds: Double) { - subtitleDelayUs.set((seconds * 1_000_000).toLong()) - } - - fun play() { - pendingPlayWhenReady = null - exoPlayer?.play() - } - - fun pause() { - pendingPlayWhenReady = null - exoPlayer?.pause() - } - - fun stop() { - stopFrameWatchdog() - cancelDecoderHangCheck() - exoPlayer?.stop() - emitSeekable(false, force = true) - setVisible(false) - } - - fun seekTo(positionMs: Long) { - exoPlayer?.seekTo(positionMs) - } - - fun setVolume(volume: Float) { - exoPlayer?.volume = volume.coerceIn(0f, 1f) - delegate?.onPropertyChange("volume", (volume * 100).toDouble()) - } - - fun setPlaybackSpeed(speed: Float) { - val clampedSpeed = speed.coerceIn(0.25f, 4f) - exoPlayer?.setPlaybackSpeed(clampedSpeed) - updateTunnelingState("speed changed") - delegate?.onPropertyChange("speed", speed.toDouble()) - } - - fun selectAudioTrack(trackId: String) { - val selector = trackSelector ?: return - val trackGroup = audioTrackGroupMap[trackId] ?: return - - selector.parameters = selector.buildUponParameters() - .setOverrideForType(TrackSelectionOverride(trackGroup, 0)) - .setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false) - .build() - - selectedAudioTrackId = trackId - delegate?.onPropertyChange("aid", trackId) - } - - fun selectSubtitleTrack(trackId: String?) { - val selector = trackSelector ?: return - - if (trackId == null || trackId == "no") { - selectedSubtitleTrackId = "no" - selector.parameters = selector.buildUponParameters() - .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true) - .build() - delegate?.onPropertyChange("sid", "no") - return - } - - val trackGroup = subtitleTrackGroupMap[trackId] ?: return - selectedSubtitleTrackId = trackId - selector.parameters = selector.buildUponParameters() - .setOverrideForType(TrackSelectionOverride(trackGroup, 0)) + // Clear any stale text-type override (pointing at a pre-reload TrackGroup) + // and re-enable the text type — mirrors the reset done in open(). Without + // this, a previously-selected sub's override would either block the new + // DEFAULT-flagged sub from winning or, if the new sub fails to load, keep + // the text renderer stuck with no selection. + trackSelector?.let { selector -> + selector.parameters = selector.buildUponParameters() + .clearOverridesOfType(C.TRACK_TYPE_TEXT) .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) .build() - delegate?.onPropertyChange("sid", trackId) - } - - fun addSubtitleTrack(uri: String, title: String?, language: String?, mimeType: String?, select: Boolean) { - val existingIndex = externalSubtitleUris.indexOf(uri) - val isNew = existingIndex < 0 - val index = if (isNew) externalSubtitles.size else existingIndex - val formatId = "external_$index" - - if (isNew) { - // SELECTION_FLAG_DEFAULT marks this as the preferred text track so ExoPlayer's - // natural selection picks it on prepare. Avoids pinning the selector to a - // specific TrackGroup override — if the URL 404s (e.g. stale Plex stream key), - // ExoPlayer falls back to another available track (e.g. embedded SRT) instead - // of leaving text disabled. - val selectionFlags = if (select) C.SELECTION_FLAG_DEFAULT else 0 - val subtitleConfig = MediaItem.SubtitleConfiguration.Builder(Uri.parse(uri)) - .setId(formatId) - .setLabel(title ?: "External") - .setLanguage(language) - .setMimeType(mimeType ?: detectSubtitleMimeType(uri)) - .setSelectionFlags(selectionFlags) - .build() - externalSubtitles.add(subtitleConfig) - externalSubtitleUris.add(uri) } - - // Media3 only picks up MediaItem.SubtitleConfiguration at prepare() time - // (tracking issue androidx/media #1649). When the caller wants this subtitle - // activated immediately (e.g. after OpenSubtitles download), rebuild the - // MediaItem and re-prepare with the position preserved. - val player = exoPlayer - val mediaUri = currentMediaUri - if (select && player != null && mediaUri != null && !currentMediaIsLive) { - if (isNew) { - val savedPosition = player.currentPosition - val savedPlayWhenReady = player.playWhenReady - - // Clear any stale text-type override (pointing at a pre-reload TrackGroup) - // and re-enable the text type — mirrors the reset done in open(). Without - // this, a previously-selected sub's override would either block the new - // DEFAULT-flagged sub from winning or, if the new sub fails to load, keep - // the text renderer stuck with no selection. - trackSelector?.let { selector -> - selector.parameters = selector.buildUponParameters() - .clearOverridesOfType(C.TRACK_TYPE_TEXT) - .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) - .build() - } - selectedSubtitleTrackId = null - - val mediaItem = buildMediaItem(mediaUri) - player.setMediaItem(mediaItem, savedPosition) - player.prepare() - player.playWhenReady = savedPlayWhenReady - } else { - // Already attached — select the existing track via override. - val trackId = subtitleTrackGroupMap.entries - .firstOrNull { (_, group) -> group.getFormat(0).id == formatId } - ?.key - if (trackId != null) { - selectSubtitleTrack(trackId) - } - } - } - - if (isNew) emitTrackList() - } - - private fun detectSubtitleMimeType(uri: String): String { - // Strip query params before checking extension (Plex URLs have ?X-Plex-Token=...) - val path = Uri.parse(uri).path?.lowercase() ?: uri.lowercase() - return when { - path.endsWith(".srt") -> MimeTypes.APPLICATION_SUBRIP - path.endsWith(".ass") || path.endsWith(".ssa") -> MimeTypes.TEXT_SSA - path.endsWith(".vtt") -> MimeTypes.TEXT_VTT - path.endsWith(".ttml") -> MimeTypes.APPLICATION_TTML - else -> MimeTypes.APPLICATION_SUBRIP - } - } - - fun setVisible(visible: Boolean) { - if (disposing) return - currentVisible = visible - activity.runOnUiThread { - if (disposing) return@runOnUiThread - surfaceContainer?.visibility = if (visible) View.VISIBLE else View.INVISIBLE - // subtitleView is inside surfaceContainer, inherits visibility - Log.d(TAG, "setVisible($visible)") - } - } - - fun setSubtitleStyle( - fontSize: Float, - textColor: String, - borderSize: Float, - borderColor: String, - bgColor: String, - bgOpacity: Int, - subtitlePosition: Int = 100, - bold: Boolean = false, - italic: Boolean = false - ) { - activity.runOnUiThread { - // 1. Non-ASS subtitles: CaptionStyleCompat on SubtitleView - val fgColor = Color.parseColor(textColor) - val bgAlpha = (bgOpacity * 255 / 100) - val bgColorInt = Color.parseColor(bgColor).let { - Color.argb(bgAlpha, Color.red(it), Color.green(it), Color.blue(it)) - } - val edgeColor = Color.parseColor(borderColor) - val edgeType = if (borderSize > 0) CaptionStyleCompat.EDGE_TYPE_OUTLINE - else CaptionStyleCompat.EDGE_TYPE_NONE - - val typefaceStyle = when { - bold && italic -> Typeface.BOLD_ITALIC - bold -> Typeface.BOLD - italic -> Typeface.ITALIC - else -> Typeface.NORMAL - } - val typeface = if (typefaceStyle != Typeface.NORMAL) - Typeface.create(Typeface.DEFAULT, typefaceStyle) else null - - val style = CaptionStyleCompat( - fgColor, - bgColorInt, - Color.TRANSPARENT, - edgeType, - edgeColor, - typeface - ) - subtitleView?.setStyle(style) - // Font size: MPV sub-font-size is scaled pixels at 720p height - // Convert to fractional size (0.0-1.0 relative to view height) - val fraction = fontSize / 720f - subtitleView?.setFractionalTextSize(fraction) - - // Subtitle position: adjust gravity and bottom padding - val clampedPosition = subtitlePosition.coerceIn(0, 100) - val gravity = when { - clampedPosition <= 33 -> Gravity.TOP - clampedPosition <= 66 -> Gravity.CENTER - else -> Gravity.BOTTOM - } - (subtitleView?.layoutParams as? FrameLayout.LayoutParams)?.let { params -> - params.gravity = gravity or Gravity.CENTER_HORIZONTAL - subtitleView?.layoutParams = params - } - // Fine-grained positioning within bottom region via bottom padding fraction - if (clampedPosition > 66) { - val bottomFraction = (100 - clampedPosition) / 100f - subtitleView?.setBottomPaddingFraction(bottomFraction) - } else { - subtitleView?.setBottomPaddingFraction(0f) - } - - // 2. ASS subtitles: font scale via libass - // MPV default sub-font-size is 38 - val defaultSize = 38f - val scale = fontSize / defaultSize - try { - assHandler?.render?.setFontScale(scale) - } catch (e: Exception) { - Log.w(TAG, "Failed to set ASS font scale: ${e.message}") - } - - Log.d(TAG, "setSubtitleStyle: fontSize=$fontSize, textColor=$textColor, borderSize=$borderSize, bgOpacity=$bgOpacity, position=$subtitlePosition, bold=$bold, italic=$italic, assScale=$scale") - } - } - - fun onPipModeChanged(isInPipMode: Boolean) { - if (disposing) return - activity.runOnUiThread { - if (disposing) return@runOnUiThread - // Force recalculation of surface size based on new container dimensions - // Use a slight delay to allow the window to resize first - handler.postDelayed({ - if (disposing) return@postDelayed - val videoSize = exoPlayer?.videoSize - if (videoSize != null && videoSize.width > 0 && videoSize.height > 0) { - updateSurfaceViewSize(videoSize.width, videoSize.height, videoSize.pixelWidthHeightRatio) - } - }, 100) - } - } - - fun updateFrame() { - if (disposing) return - activity.runOnUiThread { - if (disposing) return@runOnUiThread - ensureFlutterOverlayOnTop() - lastVideoSize?.let { videoSize -> - if (videoSize.width > 0 && videoSize.height > 0) { - updateSurfaceViewSize(videoSize.width, videoSize.height, videoSize.pixelWidthHeightRatio) - } - } - } - } - - // Audio Focus - - fun requestAudioFocus(): Boolean = audioFocusManager?.requestAudioFocus() ?: false - - fun abandonAudioFocus() { audioFocusManager?.abandonAudioFocus() } - - // Frame Rate Matching - - fun setVideoFrameRate( - fps: Float, - videoDurationMs: Long, - extraDelayMs: Long, - onComplete: (switched: Boolean) -> Unit, - ) { - val mgr = frameRateManager - if (mgr == null) { - onComplete(false) - return - } - mgr.setVideoFrameRate(fps, videoDurationMs, surfaceView?.holder?.surface, extraDelayMs, onComplete) - } - - fun clearVideoFrameRate() { - frameRateManager?.clearVideoFrameRate() - } - - private fun computeFrameRate(timestamps: LongArray): Float { - val deltas = (1 until FPS_SAMPLE_COUNT).map { timestamps[it] - timestamps[it - 1] }.filter { it > 0 } - if (deltas.isEmpty()) return -1f - val medianDelta = deltas.sorted()[deltas.size / 2] - val rawFps = 1_000_000.0 / medianDelta - return normalizeFrameRate(rawFps) - } - - private fun normalizeFrameRate(fps: Double): Float { - val knownRates = doubleArrayOf(23.976, 24.0, 25.0, 29.97, 30.0, 48.0, 50.0, 59.94, 60.0) - val nearest = knownRates.minByOrNull { kotlin.math.abs(it - fps) } ?: fps - return if (kotlin.math.abs(nearest - fps) < 0.5) nearest.toFloat() else fps.toFloat() - } - - // Stats - - fun getStats(): Map { - val player = exoPlayer ?: return emptyMap() - val videoFormat = player.videoFormat - val audioFormat = player.audioFormat - - // Get decoder info from the format's codecs field and check if hardware accelerated - val videoDecoderInfo = getVideoDecoderInfo(videoFormat) - - return mapOf( - // Video metrics - "videoCodec" to videoFormat?.codecs, - "videoMimeType" to videoFormat?.sampleMimeType, - "videoWidth" to videoFormat?.width, - "videoHeight" to videoFormat?.height, - "videoFps" to (videoFormat?.frameRate?.takeIf { it > 0 } ?: detectedFrameRate.takeIf { it > 0 }), - "videoBitrate" to videoFormat?.bitrate, - "videoDecoderName" to (decoderInitName ?: videoDecoderInfo), - "videoDroppedFrames" to player.videoDecoderCounters?.droppedBufferCount, - "videoRenderedFrames" to player.videoDecoderCounters?.renderedOutputBufferCount, - // Color info - "colorSpace" to videoFormat?.colorInfo?.colorSpace, - "colorRange" to videoFormat?.colorInfo?.colorRange, - "colorTransfer" to videoFormat?.colorInfo?.colorTransfer, - "hdrStaticInfo" to (videoFormat?.colorInfo?.hdrStaticInfo != null), - // Audio metrics - "audioCodec" to audioFormat?.codecs, - "audioMimeType" to audioFormat?.sampleMimeType, - "audioSampleRate" to audioFormat?.sampleRate, - "audioChannels" to audioFormat?.channelCount, - "audioBitrate" to audioFormat?.bitrate, - "audioDecoderName" to audioDecoderInitName, - // Tunneling - "tunneledPlayback" to currentTunneledPlayback, - "tunnelingStatus" to getTunnelingStatus(player), - // Buffer metrics - "bufferedPositionMs" to player.bufferedPosition, - "currentPositionMs" to player.currentPosition, - "totalBufferedDurationMs" to player.totalBufferedDuration, - // Playback state - "playbackSpeed" to player.playbackParameters.speed, - "isPlaying" to player.isPlaying, - "playbackState" to player.playbackState, - // DV conversion (query extractor's track output, which is set during extraction) - *(activeDoviMkvWrapper?.doviTrackOutput - ?: activeDoviMp4Wrapper?.doviTrackOutput).let { dovi -> - arrayOf( - "dvConversionActive" to (dovi?.conversionActive == true), - "dvConversionMode" to dvMode.name, - "dvStrippedNals" to (dovi?.strippedNalCount ?: 0L), - "dvConvertedRpus" to (dovi?.convertedRpuCount ?: 0L), - ) - }, - ) - } - - private fun getVideoDecoderInfo(videoFormat: androidx.media3.common.Format?): String? { - if (videoFormat == null) return null - val mimeType = videoFormat.sampleMimeType ?: return null - - // Check available decoders for this mime type - try { - val codecList = android.media.MediaCodecList(android.media.MediaCodecList.ALL_CODECS) - for (info in codecList.codecInfos) { - if (info.isEncoder) continue - for (type in info.supportedTypes) { - if (type.equals(mimeType, ignoreCase = true)) { - // Return the first hardware decoder found, or software if none - val name = info.name - if (!name.startsWith("OMX.google.") && !name.contains(".sw.")) { - return name // Hardware decoder - } - } - } - } - // Fallback - assume software if no HW decoder found - return "Software" - } catch (e: Exception) { - return null - } - } - - private fun getTunnelingStatus(player: ExoPlayer): String { - if (currentTunneledPlayback) return "Active" - if (!tunnelingUserEnabled) return "Disabled by user" - if (player.playbackParameters.speed != 1f) return "Off (speed ≠ 1×)" - if (tunnelingDisabledForVideoCodec) return "Off (video codec unsupported)" - if (tunnelingDisabledForAudioCodec) return "Off (no HW audio decoder)" - return "Off" - } - - fun triggerFallback() { - val uri = currentMediaUri ?: return - val pos = exoPlayer?.currentPosition ?: 0L - delegate?.onFormatUnsupported(uri, currentHeaders, pos, "debug: manual fallback trigger") - } - - // Cleanup - - fun dispose() { - if (disposing) return - disposing = true - check(Looper.myLooper() == Looper.getMainLooper()) - Log.d(TAG, "Disposing") - - stopFrameWatchdog() - cancelDecoderHangCheck() - stopPositionUpdates() - handler.removeCallbacksAndMessages(null) - frameRateManager?.clearVideoFrameRate() - frameRateManager = null - audioFocusManager?.release() - audioFocusManager = null - - decoderInitName = null - audioDecoderInitName = null - tunnelingDisabledForAudioCodec = false - tunnelingDisabledForVideoCodec = false - currentTunneledPlayback = false - pendingStartPositionMs = 0L - pendingPlayWhenReady = null - currentMediaIsLive = false - currentVisible = false - emitSeekable(false, force = true) - selectedAudioTrackId = null selectedSubtitleTrackId = null - audioTrackGroupMap.clear() - subtitleTrackGroupMap.clear() - exoPlayer?.clearVideoSurface() - exoPlayer?.removeListener(this) - exoPlayer?.release() - exoPlayer = null - renderersFactory = null - trackSelector = null - httpDataSourceFactory = null - dataSourceFactory = null - assHandler?.release() - assHandler = null - // Capture locals for deferred cleanup - val cb = surfaceCallback - val sv = surfaceView - val container = surfaceContainer - val contentView = activity.findViewById(android.R.id.content) - - // Synchronous ownership invalidation — stale code can no longer - // reach surface state through instance fields. - surfaceContainer = null - videoAspectContainer = null - surfaceView = null - subtitleView = null - - // Remove layout listener synchronously - overlayLayoutListener?.let { listener -> - contentView.viewTreeObserver.removeOnGlobalLayoutListener(listener) + val mediaItem = buildMediaItem(mediaUri) + player.setMediaItem(mediaItem, savedPosition) + player.prepare() + player.playWhenReady = savedPlayWhenReady + } else { + // Already attached — select the existing track via override. + val trackId = subtitleTrackGroupMap.entries + .firstOrNull { (_, group) -> group.getFormat(0).id == formatId } + ?.key + if (trackId != null) { + selectSubtitleTrack(trackId) } - overlayLayoutListener = null - - isInitialized = false - - // Deferred view removal only — uses captured locals. - // postAtFrontOfQueue as defense-in-depth: orders removal before - // queued initialize messages. - Handler(Looper.getMainLooper()).postAtFrontOfQueue { - sv?.holder?.removeCallback(cb) - if (container?.parent != null) { - contentView.removeView(container) - } - } - - Log.d(TAG, "Disposed") + } } + if (isNew) emitTrackList() + } + + private fun detectSubtitleMimeType(uri: String): String { + // Strip query params before checking extension (Plex URLs have ?X-Plex-Token=...) + val path = Uri.parse(uri).path?.lowercase() ?: uri.lowercase() + return when { + path.endsWith(".srt") -> MimeTypes.APPLICATION_SUBRIP + path.endsWith(".ass") || path.endsWith(".ssa") -> MimeTypes.TEXT_SSA + path.endsWith(".vtt") -> MimeTypes.TEXT_VTT + path.endsWith(".ttml") -> MimeTypes.APPLICATION_TTML + else -> MimeTypes.APPLICATION_SUBRIP + } + } + + fun setVisible(visible: Boolean) { + if (disposing) return + currentVisible = visible + activity.runOnUiThread { + if (disposing) return@runOnUiThread + surfaceContainer?.visibility = if (visible) View.VISIBLE else View.INVISIBLE + // subtitleView is inside surfaceContainer, inherits visibility + Log.d(TAG, "setVisible($visible)") + } + } + + fun setSubtitleStyle( + fontSize: Float, + textColor: String, + borderSize: Float, + borderColor: String, + bgColor: String, + bgOpacity: Int, + subtitlePosition: Int = 100, + bold: Boolean = false, + italic: Boolean = false + ) { + activity.runOnUiThread { + // 1. Non-ASS subtitles: CaptionStyleCompat on SubtitleView + val fgColor = Color.parseColor(textColor) + val bgAlpha = (bgOpacity * 255 / 100) + val bgColorInt = Color.parseColor(bgColor).let { + Color.argb(bgAlpha, Color.red(it), Color.green(it), Color.blue(it)) + } + val edgeColor = Color.parseColor(borderColor) + val edgeType = if (borderSize > 0) { + CaptionStyleCompat.EDGE_TYPE_OUTLINE + } else { + CaptionStyleCompat.EDGE_TYPE_NONE + } + + val typefaceStyle = when { + bold && italic -> Typeface.BOLD_ITALIC + bold -> Typeface.BOLD + italic -> Typeface.ITALIC + else -> Typeface.NORMAL + } + val typeface = if (typefaceStyle != Typeface.NORMAL) { + Typeface.create(Typeface.DEFAULT, typefaceStyle) + } else { + null + } + + val style = CaptionStyleCompat( + fgColor, + bgColorInt, + Color.TRANSPARENT, + edgeType, + edgeColor, + typeface + ) + subtitleView?.setStyle(style) + // Font size: MPV sub-font-size is scaled pixels at 720p height + // Convert to fractional size (0.0-1.0 relative to view height) + val fraction = fontSize / 720f + subtitleView?.setFractionalTextSize(fraction) + + // Subtitle position: adjust gravity and bottom padding + val clampedPosition = subtitlePosition.coerceIn(0, 100) + val gravity = when { + clampedPosition <= 33 -> Gravity.TOP + clampedPosition <= 66 -> Gravity.CENTER + else -> Gravity.BOTTOM + } + (subtitleView?.layoutParams as? FrameLayout.LayoutParams)?.let { params -> + params.gravity = gravity or Gravity.CENTER_HORIZONTAL + subtitleView?.layoutParams = params + } + // Fine-grained positioning within bottom region via bottom padding fraction + if (clampedPosition > 66) { + val bottomFraction = (100 - clampedPosition) / 100f + subtitleView?.setBottomPaddingFraction(bottomFraction) + } else { + subtitleView?.setBottomPaddingFraction(0f) + } + + // 2. ASS subtitles: font scale via libass + // MPV default sub-font-size is 38 + val defaultSize = 38f + val scale = fontSize / defaultSize + try { + assHandler?.render?.setFontScale(scale) + } catch (e: Exception) { + Log.w(TAG, "Failed to set ASS font scale: ${e.message}") + } + + Log.d(TAG, "setSubtitleStyle: fontSize=$fontSize, textColor=$textColor, borderSize=$borderSize, bgOpacity=$bgOpacity, position=$subtitlePosition, bold=$bold, italic=$italic, assScale=$scale") + } + } + + fun onPipModeChanged(isInPipMode: Boolean) { + if (disposing) return + activity.runOnUiThread { + if (disposing) return@runOnUiThread + // Force recalculation of surface size based on new container dimensions + // Use a slight delay to allow the window to resize first + handler.postDelayed({ + if (disposing) return@postDelayed + val videoSize = exoPlayer?.videoSize + if (videoSize != null && videoSize.width > 0 && videoSize.height > 0) { + updateSurfaceViewSize(videoSize.width, videoSize.height, videoSize.pixelWidthHeightRatio) + } + }, 100) + } + } + + fun updateFrame() { + if (disposing) return + activity.runOnUiThread { + if (disposing) return@runOnUiThread + ensureFlutterOverlayOnTop() + lastVideoSize?.let { videoSize -> + if (videoSize.width > 0 && videoSize.height > 0) { + updateSurfaceViewSize(videoSize.width, videoSize.height, videoSize.pixelWidthHeightRatio) + } + } + } + } + + // Audio Focus + + fun requestAudioFocus(): Boolean = audioFocusManager?.requestAudioFocus() ?: false + + fun abandonAudioFocus() { + audioFocusManager?.abandonAudioFocus() + } + + // Frame Rate Matching + + fun setVideoFrameRate( + fps: Float, + videoDurationMs: Long, + extraDelayMs: Long, + onComplete: (switched: Boolean) -> Unit + ) { + val mgr = frameRateManager + if (mgr == null) { + onComplete(false) + return + } + mgr.setVideoFrameRate(fps, videoDurationMs, surfaceView?.holder?.surface, extraDelayMs, onComplete) + } + + fun clearVideoFrameRate() { + frameRateManager?.clearVideoFrameRate() + } + + private fun computeFrameRate(timestamps: LongArray): Float { + val deltas = (1 until FPS_SAMPLE_COUNT).map { timestamps[it] - timestamps[it - 1] }.filter { it > 0 } + if (deltas.isEmpty()) return -1f + val medianDelta = deltas.sorted()[deltas.size / 2] + val rawFps = 1_000_000.0 / medianDelta + return normalizeFrameRate(rawFps) + } + + private fun normalizeFrameRate(fps: Double): Float { + val knownRates = doubleArrayOf(23.976, 24.0, 25.0, 29.97, 30.0, 48.0, 50.0, 59.94, 60.0) + val nearest = knownRates.minByOrNull { kotlin.math.abs(it - fps) } ?: fps + return if (kotlin.math.abs(nearest - fps) < 0.5) nearest.toFloat() else fps.toFloat() + } + + // Stats + + fun getStats(): Map { + val player = exoPlayer ?: return emptyMap() + val videoFormat = player.videoFormat + val audioFormat = player.audioFormat + + // Get decoder info from the format's codecs field and check if hardware accelerated + val videoDecoderInfo = getVideoDecoderInfo(videoFormat) + + return mapOf( + // Video metrics + "videoCodec" to videoFormat?.codecs, + "videoMimeType" to videoFormat?.sampleMimeType, + "videoWidth" to videoFormat?.width, + "videoHeight" to videoFormat?.height, + "videoFps" to (videoFormat?.frameRate?.takeIf { it > 0 } ?: detectedFrameRate.takeIf { it > 0 }), + "videoBitrate" to videoFormat?.bitrate, + "videoDecoderName" to (decoderInitName ?: videoDecoderInfo), + "videoDroppedFrames" to player.videoDecoderCounters?.droppedBufferCount, + "videoRenderedFrames" to player.videoDecoderCounters?.renderedOutputBufferCount, + // Color info + "colorSpace" to videoFormat?.colorInfo?.colorSpace, + "colorRange" to videoFormat?.colorInfo?.colorRange, + "colorTransfer" to videoFormat?.colorInfo?.colorTransfer, + "hdrStaticInfo" to (videoFormat?.colorInfo?.hdrStaticInfo != null), + // Audio metrics + "audioCodec" to audioFormat?.codecs, + "audioMimeType" to audioFormat?.sampleMimeType, + "audioSampleRate" to audioFormat?.sampleRate, + "audioChannels" to audioFormat?.channelCount, + "audioBitrate" to audioFormat?.bitrate, + "audioDecoderName" to audioDecoderInitName, + // Tunneling + "tunneledPlayback" to currentTunneledPlayback, + "tunnelingStatus" to getTunnelingStatus(player), + // Buffer metrics + "bufferedPositionMs" to player.bufferedPosition, + "currentPositionMs" to player.currentPosition, + "totalBufferedDurationMs" to player.totalBufferedDuration, + // Playback state + "playbackSpeed" to player.playbackParameters.speed, + "isPlaying" to player.isPlaying, + "playbackState" to player.playbackState, + // DV conversion (query extractor's track output, which is set during extraction) + *( + activeDoviMkvWrapper?.doviTrackOutput + ?: activeDoviMp4Wrapper?.doviTrackOutput + ).let { dovi -> + arrayOf( + "dvConversionActive" to (dovi?.conversionActive == true), + "dvConversionMode" to dvMode.name, + "dvStrippedNals" to (dovi?.strippedNalCount ?: 0L), + "dvConvertedRpus" to (dovi?.convertedRpuCount ?: 0L) + ) + } + ) + } + + private fun getVideoDecoderInfo(videoFormat: androidx.media3.common.Format?): String? { + if (videoFormat == null) return null + val mimeType = videoFormat.sampleMimeType ?: return null + + // Check available decoders for this mime type + try { + val codecList = android.media.MediaCodecList(android.media.MediaCodecList.ALL_CODECS) + for (info in codecList.codecInfos) { + if (info.isEncoder) continue + for (type in info.supportedTypes) { + if (type.equals(mimeType, ignoreCase = true)) { + // Return the first hardware decoder found, or software if none + val name = info.name + if (!name.startsWith("OMX.google.") && !name.contains(".sw.")) { + return name // Hardware decoder + } + } + } + } + // Fallback - assume software if no HW decoder found + return "Software" + } catch (e: Exception) { + return null + } + } + + private fun getTunnelingStatus(player: ExoPlayer): String { + if (currentTunneledPlayback) return "Active" + if (!tunnelingUserEnabled) return "Disabled by user" + if (player.playbackParameters.speed != 1f) return "Off (speed ≠ 1×)" + if (tunnelingDisabledForVideoCodec) return "Off (video codec unsupported)" + if (tunnelingDisabledForAudioCodec) return "Off (no HW audio decoder)" + return "Off" + } + + fun triggerFallback() { + val uri = currentMediaUri ?: return + val pos = exoPlayer?.currentPosition ?: 0L + delegate?.onFormatUnsupported(uri, currentHeaders, pos, "debug: manual fallback trigger") + } + + // Cleanup + + fun dispose() { + if (disposing) return + disposing = true + check(Looper.myLooper() == Looper.getMainLooper()) + Log.d(TAG, "Disposing") + + stopFrameWatchdog() + cancelDecoderHangCheck() + stopPositionUpdates() + handler.removeCallbacksAndMessages(null) + frameRateManager?.clearVideoFrameRate() + frameRateManager = null + audioFocusManager?.release() + audioFocusManager = null + + decoderInitName = null + audioDecoderInitName = null + tunnelingDisabledForAudioCodec = false + tunnelingDisabledForVideoCodec = false + currentTunneledPlayback = false + pendingStartPositionMs = 0L + pendingPlayWhenReady = null + currentMediaIsLive = false + currentVisible = false + emitSeekable(false, force = true) + selectedAudioTrackId = null + selectedSubtitleTrackId = null + audioTrackGroupMap.clear() + subtitleTrackGroupMap.clear() + exoPlayer?.clearVideoSurface() + exoPlayer?.removeListener(this) + exoPlayer?.release() + exoPlayer = null + renderersFactory = null + trackSelector = null + httpDataSourceFactory = null + dataSourceFactory = null + assHandler?.release() + assHandler = null + + // Capture locals for deferred cleanup + val cb = surfaceCallback + val sv = surfaceView + val container = surfaceContainer + val contentView = activity.findViewById(android.R.id.content) + + // Synchronous ownership invalidation — stale code can no longer + // reach surface state through instance fields. + surfaceContainer = null + videoAspectContainer = null + surfaceView = null + subtitleView = null + + // Remove layout listener synchronously + overlayLayoutListener?.let { listener -> + contentView.viewTreeObserver.removeOnGlobalLayoutListener(listener) + } + overlayLayoutListener = null + + isInitialized = false + + // Deferred view removal only — uses captured locals. + // postAtFrontOfQueue as defense-in-depth: orders removal before + // queued initialize messages. + Handler(Looper.getMainLooper()).postAtFrontOfQueue { + sv?.holder?.removeCallback(cb) + if (container?.parent != null) { + contentView.removeView(container) + } + } + + Log.d(TAG, "Disposed") + } } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt index 25fca1c3..bf43c7be 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt @@ -16,836 +16,853 @@ import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel -class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, - EventChannel.StreamHandler, ActivityAware, ExoPlayerDelegate { +class ExoPlayerPlugin : + FlutterPlugin, + MethodChannel.MethodCallHandler, + EventChannel.StreamHandler, + ActivityAware, + ExoPlayerDelegate { - companion object { - private const val TAG = "ExoPlayerPlugin" - private const val METHOD_CHANNEL = "com.plezy/exo_player" - private const val EVENT_CHANNEL = "com.plezy/exo_player/events" + companion object { + private const val TAG = "ExoPlayerPlugin" + private const val METHOD_CHANNEL = "com.plezy/exo_player" + private const val EVENT_CHANNEL = "com.plezy/exo_player/events" + } + + private lateinit var methodChannel: MethodChannel + private lateinit var eventChannel: EventChannel + private var eventSink: EventChannel.EventSink? = null + private var playerCore: ExoPlayerCore? = null + private var mpvCore: MpvPlayerCore? = null // MPV fallback player + private var usingMpvFallback: Boolean = false + private var fallbackInProgress: Boolean = false + private var activity: Activity? = null + private var activityBinding: ActivityPluginBinding? = null + private val nameToId = mutableMapOf() + private val mainHandler = Handler(Looper.getMainLooper()) + private var configuredBufferSizeBytes: Int? = null + + private var sessionGeneration = 0 + private var debugLoggingEnabled: Boolean = false + private val pendingMpvProperties = mutableListOf>() + + // FlutterPlugin + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL) + methodChannel.setMethodCallHandler(this) + + eventChannel = EventChannel(binding.binaryMessenger, EVENT_CHANNEL) + eventChannel.setStreamHandler(this) + + Log.d(TAG, "Attached to engine") + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + methodChannel.setMethodCallHandler(null) + eventChannel.setStreamHandler(null) + Log.d(TAG, "Detached from engine") + } + + // ActivityAware + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + activity = binding.activity + activityBinding = binding + Log.d(TAG, "Attached to activity") + } + + override fun onDetachedFromActivity() { + sessionGeneration++ + playerCore?.dispose() + playerCore = null + mpvCore?.dispose() + mpvCore = null + usingMpvFallback = false + fallbackInProgress = false + activity = null + activityBinding = null + Log.d(TAG, "Detached from activity") + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + activity = binding.activity + activityBinding = binding + Log.d(TAG, "Reattached to activity for config changes") + } + + override fun onDetachedFromActivityForConfigChanges() { + sessionGeneration++ + fallbackInProgress = false + activity = null + activityBinding = null + Log.d(TAG, "Detached from activity for config changes") + } + + // EventChannel.StreamHandler + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + eventSink = events + Log.d(TAG, "Event stream connected") + } + + override fun onCancel(arguments: Any?) { + eventSink = null + Log.d(TAG, "Event stream disconnected") + } + + // MethodChannel.MethodCallHandler + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "initialize" -> handleInitialize(call, result) + "dispose" -> handleDispose(result) + "open" -> handleOpen(call, result) + "play" -> handlePlay(result) + "pause" -> handlePause(result) + "stop" -> handleStop(result) + "seek" -> handleSeek(call, result) + "setVolume" -> handleSetVolume(call, result) + "setRate" -> handleSetRate(call, result) + "selectAudioTrack" -> handleSelectAudioTrack(call, result) + "selectSubtitleTrack" -> handleSelectSubtitleTrack(call, result) + "addSubtitleTrack" -> handleAddSubtitleTrack(call, result) + "setVisible" -> handleSetVisible(call, result) + "updateFrame" -> handleUpdateFrame(result) + "setVideoFrameRate" -> handleSetVideoFrameRate(call, result) + "clearVideoFrameRate" -> handleClearVideoFrameRate(result) + "requestAudioFocus" -> handleRequestAudioFocus(result) + "abandonAudioFocus" -> handleAbandonAudioFocus(result) + "isInitialized" -> result.success( + if (usingMpvFallback) { + mpvCore?.isInitialized ?: false + } else { + playerCore?.isInitialized ?: false + } + ) + "getStats" -> handleGetStats(result) + "getPlayerType" -> result.success(if (usingMpvFallback) "mpv" else "exoplayer") + "getHeapSize" -> { + val am = activity?.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager + result.success(am?.largeMemoryClass ?: 0) + } + "setSubtitleStyle" -> handleSetSubtitleStyle(call, result) + "setBoxFitMode" -> handleSetBoxFitMode(call, result) + "observeProperty" -> handleObserveProperty(call, result) + "setMpvProperty" -> handleSetMpvProperty(call, result) + "setLogLevel" -> { + val level = call.argument("level") ?: "warn" + debugLoggingEnabled = (level == "v" || level == "debug" || level == "trace") + playerCore?.debugLoggingEnabled = debugLoggingEnabled + result.success(null) + } + "triggerFallback" -> { + playerCore?.triggerFallback() + result.success(null) + } + else -> result.notImplemented() + } + } + + private fun handleInitialize(call: MethodCall, result: MethodChannel.Result) { + val currentActivity = activity + if (currentActivity == null) { + result.error("NO_ACTIVITY", "Activity not available", null) + return } - private lateinit var methodChannel: MethodChannel - private lateinit var eventChannel: EventChannel - private var eventSink: EventChannel.EventSink? = null - private var playerCore: ExoPlayerCore? = null - private var mpvCore: MpvPlayerCore? = null // MPV fallback player - private var usingMpvFallback: Boolean = false - private var fallbackInProgress: Boolean = false - private var activity: Activity? = null - private var activityBinding: ActivityPluginBinding? = null - private val nameToId = mutableMapOf() - private val mainHandler = Handler(Looper.getMainLooper()) - private var configuredBufferSizeBytes: Int? = null - - private var sessionGeneration = 0 - private var debugLoggingEnabled: Boolean = false - private val pendingMpvProperties = mutableListOf>() - - // FlutterPlugin - - override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { - methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL) - methodChannel.setMethodCallHandler(this) - - eventChannel = EventChannel(binding.binaryMessenger, EVENT_CHANNEL) - eventChannel.setStreamHandler(this) - - Log.d(TAG, "Attached to engine") + if (playerCore?.isInitialized == true) { + Log.d(TAG, "Already initialized") + result.success(true) + return } - override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { - methodChannel.setMethodCallHandler(null) - eventChannel.setStreamHandler(null) - Log.d(TAG, "Detached from engine") - } + val bufferSizeBytes = call.argument("bufferSizeBytes") + val tunnelingEnabled = call.argument("tunnelingEnabled") ?: true + configuredBufferSizeBytes = bufferSizeBytes - // ActivityAware + currentActivity.runOnUiThread { + sessionGeneration++ - override fun onAttachedToActivity(binding: ActivityPluginBinding) { - activity = binding.activity - activityBinding = binding - Log.d(TAG, "Attached to activity") - } - - override fun onDetachedFromActivity() { - sessionGeneration++ - playerCore?.dispose() - playerCore = null + if (mpvCore != null || fallbackInProgress) { mpvCore?.dispose() mpvCore = null usingMpvFallback = false fallbackInProgress = false - activity = null - activityBinding = null - Log.d(TAG, "Detached from activity") - } + } - override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { - activity = binding.activity - activityBinding = binding - Log.d(TAG, "Reattached to activity for config changes") - } - - override fun onDetachedFromActivityForConfigChanges() { - sessionGeneration++ - fallbackInProgress = false - activity = null - activityBinding = null - Log.d(TAG, "Detached from activity for config changes") - } - - // EventChannel.StreamHandler - - override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { - eventSink = events - Log.d(TAG, "Event stream connected") - } - - override fun onCancel(arguments: Any?) { - eventSink = null - Log.d(TAG, "Event stream disconnected") - } - - // MethodChannel.MethodCallHandler - - override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { - when (call.method) { - "initialize" -> handleInitialize(call, result) - "dispose" -> handleDispose(result) - "open" -> handleOpen(call, result) - "play" -> handlePlay(result) - "pause" -> handlePause(result) - "stop" -> handleStop(result) - "seek" -> handleSeek(call, result) - "setVolume" -> handleSetVolume(call, result) - "setRate" -> handleSetRate(call, result) - "selectAudioTrack" -> handleSelectAudioTrack(call, result) - "selectSubtitleTrack" -> handleSelectSubtitleTrack(call, result) - "addSubtitleTrack" -> handleAddSubtitleTrack(call, result) - "setVisible" -> handleSetVisible(call, result) - "updateFrame" -> handleUpdateFrame(result) - "setVideoFrameRate" -> handleSetVideoFrameRate(call, result) - "clearVideoFrameRate" -> handleClearVideoFrameRate(result) - "requestAudioFocus" -> handleRequestAudioFocus(result) - "abandonAudioFocus" -> handleAbandonAudioFocus(result) - "isInitialized" -> result.success( - if (usingMpvFallback) mpvCore?.isInitialized ?: false - else playerCore?.isInitialized ?: false - ) - "getStats" -> handleGetStats(result) - "getPlayerType" -> result.success(if (usingMpvFallback) "mpv" else "exoplayer") - "getHeapSize" -> { - val am = activity?.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager - result.success(am?.largeMemoryClass ?: 0) - } - "setSubtitleStyle" -> handleSetSubtitleStyle(call, result) - "setBoxFitMode" -> handleSetBoxFitMode(call, result) - "observeProperty" -> handleObserveProperty(call, result) - "setMpvProperty" -> handleSetMpvProperty(call, result) - "setLogLevel" -> { - val level = call.argument("level") ?: "warn" - debugLoggingEnabled = (level == "v" || level == "debug" || level == "trace") - playerCore?.debugLoggingEnabled = debugLoggingEnabled - result.success(null) - } - "triggerFallback" -> { - playerCore?.triggerFallback() - result.success(null) - } - else -> result.notImplemented() + try { + playerCore = ExoPlayerCore(currentActivity).apply { + delegate = this@ExoPlayerPlugin + this.debugLoggingEnabled = this@ExoPlayerPlugin.debugLoggingEnabled } + val success = playerCore?.initialize( + bufferSizeBytes = bufferSizeBytes, + tunnelingEnabled = tunnelingEnabled + ) ?: false + + // Start hidden + playerCore?.setVisible(false) + + Log.d(TAG, "Initialized: $success") + result.success(success) + } catch (e: Exception) { + Log.e(TAG, "Failed to initialize: ${e.message}", e) + result.error("INIT_FAILED", e.message, null) + } + } + } + + private fun handleDispose(result: MethodChannel.Result) { + activity?.runOnUiThread { + sessionGeneration++ + playerCore?.dispose() + playerCore = null + mpvCore?.dispose() + mpvCore = null + usingMpvFallback = false + fallbackInProgress = false + Log.d(TAG, "Disposed") + result.success(null) + } ?: result.success(null) + } + + @Suppress("UNCHECKED_CAST") + private fun handleOpen(call: MethodCall, result: MethodChannel.Result) { + val uri = call.argument("uri") + val headers = call.argument>("headers") + val startPositionMs = call.argument("startPositionMs")?.toLong() ?: 0L + val autoPlay = call.argument("autoPlay") ?: true + val isLive = call.argument("isLive") ?: false + val externalSubtitles = call.argument>>("externalSubtitles") + + if (uri == null) { + result.error("INVALID_ARGS", "Missing 'uri'", null) + return } - private fun handleInitialize(call: MethodCall, result: MethodChannel.Result) { - val currentActivity = activity - if (currentActivity == null) { - result.error("NO_ACTIVITY", "Activity not available", null) - return - } - - if (playerCore?.isInitialized == true) { - Log.d(TAG, "Already initialized") - result.success(true) - return - } - - val bufferSizeBytes = call.argument("bufferSizeBytes") - val tunnelingEnabled = call.argument("tunnelingEnabled") ?: true - configuredBufferSizeBytes = bufferSizeBytes - - currentActivity.runOnUiThread { - sessionGeneration++ - - if (mpvCore != null || fallbackInProgress) { - mpvCore?.dispose() - mpvCore = null - usingMpvFallback = false - fallbackInProgress = false - } - - try { - playerCore = ExoPlayerCore(currentActivity).apply { - delegate = this@ExoPlayerPlugin - this.debugLoggingEnabled = this@ExoPlayerPlugin.debugLoggingEnabled - } - val success = playerCore?.initialize( - bufferSizeBytes = bufferSizeBytes, - tunnelingEnabled = tunnelingEnabled, - ) ?: false - - // Start hidden - playerCore?.setVisible(false) - - Log.d(TAG, "Initialized: $success") - result.success(success) - } catch (e: Exception) { - Log.e(TAG, "Failed to initialize: ${e.message}", e) - result.error("INIT_FAILED", e.message, null) - } - } + // Only clear pending MPV properties when MPV is the active backend. + // When ExoPlayer is active, keep them for potential ExoPlayer→MPV fallback. + if (usingMpvFallback) { + pendingMpvProperties.clear() } - private fun handleDispose(result: MethodChannel.Result) { - activity?.runOnUiThread { - sessionGeneration++ - playerCore?.dispose() - playerCore = null - mpvCore?.dispose() - mpvCore = null - usingMpvFallback = false + activity?.runOnUiThread { + if (usingMpvFallback) { + // MPV: Build loadfile command with options + val startSeconds = startPositionMs / 1000.0 + val options = mutableListOf() + options.add("start=$startSeconds") + if (!autoPlay) options.add("pause=yes") + headers?.forEach { (key, value) -> + options.add("http-header-fields-append=$key: $value") + } + val optionsStr = options.joinToString(",") + // Convert content:// URIs to fdclose:// for MPV (SAF SD card downloads) + val mpvUri = openContentFd(uri)?.let { "fdclose://$it" } ?: uri + mpvCore?.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr)) + } else { + playerCore?.open(uri, headers, startPositionMs, autoPlay, isLive, externalSubtitles) + } + result.success(null) + } ?: result.error("NO_ACTIVITY", "Activity not available", null) + } + + private fun handlePlay(result: MethodChannel.Result) { + activity?.runOnUiThread { + if (usingMpvFallback) { + mpvCore?.setProperty("pause", "no") + } else { + playerCore?.play() + } + result.success(null) + } ?: result.success(null) + } + + private fun handlePause(result: MethodChannel.Result) { + activity?.runOnUiThread { + if (usingMpvFallback) { + mpvCore?.setProperty("pause", "yes") + } else { + playerCore?.pause() + } + result.success(null) + } ?: result.success(null) + } + + private fun handleStop(result: MethodChannel.Result) { + activity?.runOnUiThread { + if (usingMpvFallback) { + mpvCore?.command(arrayOf("stop")) + mpvCore?.setVisible(false) + } else { + playerCore?.stop() + } + result.success(null) + } ?: result.success(null) + } + + private fun handleSeek(call: MethodCall, result: MethodChannel.Result) { + val positionMs = call.argument("positionMs")?.toLong() + + if (positionMs == null) { + result.error("INVALID_ARGS", "Missing 'positionMs'", null) + return + } + + activity?.runOnUiThread { + if (usingMpvFallback) { + val positionSeconds = positionMs / 1000.0 + mpvCore?.command(arrayOf("seek", positionSeconds.toString(), "absolute")) + } else { + playerCore?.seekTo(positionMs) + } + result.success(null) + } ?: result.success(null) + } + + private fun handleSetVolume(call: MethodCall, result: MethodChannel.Result) { + val volume = call.argument("volume")?.toFloat() + + if (volume == null) { + result.error("INVALID_ARGS", "Missing 'volume'", null) + return + } + + activity?.runOnUiThread { + if (usingMpvFallback) { + mpvCore?.setProperty("volume", volume.toString()) + } else { + playerCore?.setVolume(volume / 100f) // Convert 0-100 to 0-1 + } + result.success(null) + } ?: result.success(null) + } + + private fun handleSetRate(call: MethodCall, result: MethodChannel.Result) { + val rate = call.argument("rate")?.toFloat() + + if (rate == null) { + result.error("INVALID_ARGS", "Missing 'rate'", null) + return + } + + activity?.runOnUiThread { + if (usingMpvFallback) { + mpvCore?.setProperty("speed", rate.toString()) + } else { + playerCore?.setPlaybackSpeed(rate) + } + result.success(null) + } ?: result.success(null) + } + + private fun handleSelectAudioTrack(call: MethodCall, result: MethodChannel.Result) { + val trackId = call.argument("trackId") + + if (trackId == null) { + result.error("INVALID_ARGS", "Missing 'trackId'", null) + return + } + + activity?.runOnUiThread { + if (usingMpvFallback) { + // After fallback, track IDs come from mpv's track-list (already 1-indexed) + mpvCore?.setProperty("aid", trackId) + } else { + playerCore?.selectAudioTrack(trackId) + } + result.success(null) + } ?: result.success(null) + } + + private fun handleSelectSubtitleTrack(call: MethodCall, result: MethodChannel.Result) { + val trackId = call.argument("trackId") + + // trackId can be null or "no" to disable subtitles + activity?.runOnUiThread { + if (usingMpvFallback) { + mpvCore?.setProperty("sid", trackId ?: "no") + } else { + playerCore?.selectSubtitleTrack(trackId) + } + result.success(null) + } ?: result.success(null) + } + + private fun handleAddSubtitleTrack(call: MethodCall, result: MethodChannel.Result) { + val uri = call.argument("uri") + val title = call.argument("title") + val language = call.argument("language") + val mimeType = call.argument("mimeType") + val select = call.argument("select") ?: false + + if (uri == null) { + result.error("INVALID_ARGS", "Missing 'uri'", null) + return + } + + activity?.runOnUiThread { + if (usingMpvFallback) { + val selectFlag = if (select) "select" else "auto" + mpvCore?.command(arrayOf("sub-add", uri, selectFlag, title ?: "External")) + } else { + playerCore?.addSubtitleTrack(uri, title, language, mimeType, select) + } + result.success(null) + } ?: result.success(null) + } + + private fun handleSetVisible(call: MethodCall, result: MethodChannel.Result) { + val visible = call.argument("visible") + + if (visible == null) { + result.error("INVALID_ARGS", "Missing 'visible'", null) + return + } + + if (usingMpvFallback) { + mpvCore?.setVisible(visible) + } else { + playerCore?.setVisible(visible) + } + result.success(null) + } + + private fun handleUpdateFrame(result: MethodChannel.Result) { + if (usingMpvFallback) { + mpvCore?.updateFrame() + } else { + playerCore?.updateFrame() + } + result.success(null) + } + + private fun handleSetVideoFrameRate(call: MethodCall, result: MethodChannel.Result) { + val fps = call.argument("fps")?.toFloat() ?: 0f + val duration = call.argument("duration")?.toLong() ?: 0L + val extraDelayMs = call.argument("extraDelayMs")?.toLong() ?: 0L + + Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs") + val onComplete: (Boolean) -> Unit = { switched -> result.success(switched) } + if (usingMpvFallback) { + val core = mpvCore + if (core == null) { + result.success(false) + } else { + core.setVideoFrameRate(fps, duration, extraDelayMs, onComplete) + } + } else { + val core = playerCore + if (core == null) { + result.success(false) + } else { + core.setVideoFrameRate(fps, duration, extraDelayMs, onComplete) + } + } + } + + private fun handleClearVideoFrameRate(result: MethodChannel.Result) { + Log.d(TAG, "clearVideoFrameRate") + if (usingMpvFallback) { + mpvCore?.clearVideoFrameRate() + } else { + playerCore?.clearVideoFrameRate() + } + result.success(null) + } + + private fun handleRequestAudioFocus(result: MethodChannel.Result) { + Log.d(TAG, "requestAudioFocus") + val granted = if (usingMpvFallback) { + mpvCore?.requestAudioFocus() ?: false + } else { + playerCore?.requestAudioFocus() ?: false + } + result.success(granted) + } + + private fun handleAbandonAudioFocus(result: MethodChannel.Result) { + Log.d(TAG, "abandonAudioFocus") + if (usingMpvFallback) { + mpvCore?.abandonAudioFocus() + } else { + playerCore?.abandonAudioFocus() + } + result.success(null) + } + + private fun handleObserveProperty(call: MethodCall, result: MethodChannel.Result) { + val name = call.argument("name") + val id = call.argument("id") + + if (name == null || id == null) { + result.error("INVALID_ARGS", "Missing 'name' or 'id'", null) + return + } + + nameToId[name] = id + result.success(null) + } + + private fun handleSetSubtitleStyle(call: MethodCall, result: MethodChannel.Result) { + val fontSize = call.argument("fontSize")?.toFloat() ?: 55f + val textColor = call.argument("textColor") ?: "#FFFFFF" + val borderSize = call.argument("borderSize")?.toFloat() ?: 3f + val borderColor = call.argument("borderColor") ?: "#000000" + val bgColor = call.argument("bgColor") ?: "#000000" + val bgOpacity = call.argument("bgOpacity")?.toInt() ?: 0 + val subtitlePosition = call.argument("subtitlePosition")?.toInt() ?: 100 + val bold = call.argument("bold") ?: false + val italic = call.argument("italic") ?: false + + if (usingMpvFallback) { + // MPV fallback handles styling via setProperty, no-op here + result.success(null) + return + } + + playerCore?.setSubtitleStyle(fontSize, textColor, borderSize, borderColor, bgColor, bgOpacity, subtitlePosition, bold, italic) + result.success(null) + } + + private fun handleSetBoxFitMode(call: MethodCall, result: MethodChannel.Result) { + val mode = call.argument("mode")?.toInt() + if (mode == null) { + result.error("INVALID_ARGS", "Missing 'mode'", null) + return + } + // The MPV-property side (panscan / sub-ass-force-margins / video-aspect-override) + // is driven from Dart via setProperty and routed through setMpvProperty, which + // already handles both the fallback and pendingMpvProperties cases. + if (usingMpvFallback) { + result.success(null) + return + } + activity?.runOnUiThread { + playerCore?.setBoxFitMode(mode) + result.success(null) + } ?: result.success(null) + } + + private fun handleSetMpvProperty(call: MethodCall, result: MethodChannel.Result) { + val name = call.argument("name") + val value = call.argument("value") + + if (name == null || value == null) { + result.error("INVALID_ARGS", "Missing 'name' or 'value'", null) + return + } + + // Apply sync offsets to ExoPlayer when active + if (!usingMpvFallback) { + when (name) { + "audio-delay" -> playerCore?.setAudioDelay(value.toDoubleOrNull() ?: 0.0) + "sub-delay" -> playerCore?.setSubtitleDelay(value.toDoubleOrNull() ?: 0.0) + } + } + + if (usingMpvFallback) { + mpvCore?.setProperty(name, value) + } else { + // Store for later application if ExoPlayer falls back to MPV + pendingMpvProperties.add(Pair(name, value)) + } + result.success(null) + } + + private fun handleGetStats(result: MethodChannel.Result) { + if (usingMpvFallback) { + Thread { + val stats = getMpvStats() + activity?.runOnUiThread { result.success(stats) } + }.start() + } else { + activity?.runOnUiThread { + val coreStats = playerCore?.getStats() ?: emptyMap() + result.success(coreStats + mapOf("playerType" to "exoplayer")) + } ?: result.success(mapOf("playerType" to "unknown")) + } + } + + /** + * Get playback stats from MPV when in fallback mode. + * Queries relevant MPV properties and returns them in a map format + * compatible with the performance overlay. + */ + private fun getMpvStats(): Map { + val mpv = mpvCore ?: return mapOf("playerType" to "mpv") + + val hasVideo = mpv.getProperty("video-params/w") != null + + val stats = mutableMapOf( + "playerType" to "mpv", + // Video metrics + "video-codec" to mpv.getProperty("video-codec"), + "video-params/w" to mpv.getProperty("video-params/w"), + "video-params/h" to mpv.getProperty("video-params/h"), + "videoWidth" to mpv.getProperty("dwidth"), + "videoHeight" to mpv.getProperty("dheight"), + "container-fps" to mpv.getProperty("container-fps"), + "estimated-vf-fps" to mpv.getProperty("estimated-vf-fps"), + "video-bitrate" to mpv.getProperty("video-bitrate"), + "hwdec-current" to mpv.getProperty("hwdec-current"), + // Audio metrics + "audio-codec-name" to mpv.getProperty("audio-codec-name"), + "audio-params/samplerate" to mpv.getProperty("audio-params/samplerate"), + "audio-params/hr-channels" to mpv.getProperty("audio-params/hr-channels"), + "audio-bitrate" to mpv.getProperty("audio-bitrate"), + // Performance metrics + "total-avsync-change" to mpv.getProperty("total-avsync-change"), + "cache-speed" to mpv.getProperty("cache-speed"), + "frame-drop-count" to mpv.getProperty("frame-drop-count"), + "decoder-frame-drop-count" to mpv.getProperty("decoder-frame-drop-count"), + "demuxer-cache-duration" to mpv.getProperty("demuxer-cache-duration") + ) + + // Only query properties that require an active video track + if (hasVideo) { + stats["display-fps"] = mpv.getProperty("display-fps") + // Color/Format properties + stats["video-params/pixelformat"] = mpv.getProperty("video-params/pixelformat") + stats["video-params/hw-pixelformat"] = mpv.getProperty("video-params/hw-pixelformat") + stats["video-params/colormatrix"] = mpv.getProperty("video-params/colormatrix") + stats["video-params/primaries"] = mpv.getProperty("video-params/primaries") + stats["video-params/gamma"] = mpv.getProperty("video-params/gamma") + // HDR metadata + stats["video-params/max-luma"] = mpv.getProperty("video-params/max-luma") + stats["video-params/min-luma"] = mpv.getProperty("video-params/min-luma") + stats["video-params/max-cll"] = mpv.getProperty("video-params/max-cll") + stats["video-params/max-fall"] = mpv.getProperty("video-params/max-fall") + // Other + stats["video-params/aspect-name"] = mpv.getProperty("video-params/aspect-name") + stats["video-params/rotate"] = mpv.getProperty("video-params/rotate") + } + + return stats + } + + // PiP Mode handling + + fun onPipModeChanged(isInPipMode: Boolean) { + activity?.runOnUiThread { + if (usingMpvFallback) { + mpvCore?.onPipModeChanged(isInPipMode) + } else { + playerCore?.onPipModeChanged(isInPipMode) + } + } + } + + // ExoPlayerDelegate + + override fun onPropertyChange(name: String, value: Any?) { + val propId = nameToId[name] ?: return + mainHandler.post { eventSink?.success(listOf(propId, value)) } + } + + override fun onEvent(name: String, data: Map?) { + val event = mutableMapOf( + "type" to "event", + "name" to name + ) + data?.let { event["data"] = it } + mainHandler.post { eventSink?.success(event) } + } + + /** + * Opens a content:// URI via ContentResolver and returns the raw FD number, + * or null if the URI is not a content:// scheme or opening fails. + * The returned FD is detached so MPV can own and close it via fdclose://. + */ + private fun openContentFd( + uriString: String, + resolver: ContentResolver? = activity?.contentResolver + ): Int? { + if (!uriString.startsWith("content://")) return null + return try { + val uri = Uri.parse(uriString) + val pfd = resolver?.openFileDescriptor(uri, "r") ?: return null + val fd = pfd.detachFd() + Log.d(TAG, "Opened content FD $fd for $uriString") + fd + } catch (e: Exception) { + Log.e(TAG, "Failed to open content FD: ${e.message}", e) + null + } + } + + override fun onFormatUnsupported( + uri: String, + headers: Map?, + positionMs: Long, + errorMessage: String + ): Boolean { + if (usingMpvFallback || fallbackInProgress) { + Log.w(TAG, "Fallback already active/in-progress, ignoring duplicate request") + return true + } + + val currentActivity = activity ?: return false + fallbackInProgress = true + + Log.i(TAG, "ExoPlayer error, switching to MPV fallback at ${positionMs}ms: $errorMessage") + if (debugLoggingEnabled) { + onEvent( + "log-message", + mapOf( + "prefix" to "fallback", + "level" to "warn", + "text" to "Switching to MPV at ${positionMs}ms: $errorMessage" + ) + ) + } + + currentActivity.runOnUiThread { + try { + // Dispose ExoPlayer + playerCore?.dispose() + playerCore = null + mpvCore?.dispose() + mpvCore = null + usingMpvFallback = false // Clear before handoff + + val generation = sessionGeneration + + Handler(Looper.getMainLooper()).post { + if (generation != sessionGeneration) { fallbackInProgress = false - Log.d(TAG, "Disposed") - result.success(null) - } ?: result.success(null) - } + return@post + } + val act = activity + if (act == null) { + fallbackInProgress = false + return@post + } - @Suppress("UNCHECKED_CAST") - private fun handleOpen(call: MethodCall, result: MethodChannel.Result) { - val uri = call.argument("uri") - val headers = call.argument>("headers") - val startPositionMs = call.argument("startPositionMs")?.toLong() ?: 0L - val autoPlay = call.argument("autoPlay") ?: true - val isLive = call.argument("isLive") ?: false - val externalSubtitles = call.argument>>("externalSubtitles") + try { + val core = MpvPlayerCore(act).apply { + delegate = this@ExoPlayerPlugin + } + mpvCore = core // publish so dispose/init can reach it - if (uri == null) { - result.error("INVALID_ARGS", "Missing 'uri'", null) - return - } - - // Only clear pending MPV properties when MPV is the active backend. - // When ExoPlayer is active, keep them for potential ExoPlayer→MPV fallback. - if (usingMpvFallback) { - pendingMpvProperties.clear() - } - - activity?.runOnUiThread { - if (usingMpvFallback) { - // MPV: Build loadfile command with options - val startSeconds = startPositionMs / 1000.0 - val options = mutableListOf() - options.add("start=$startSeconds") - if (!autoPlay) options.add("pause=yes") - headers?.forEach { (key, value) -> - options.add("http-header-fields-append=$key: $value") + core.initialize { success -> + if (generation != sessionGeneration) { + if (mpvCore === core) { + core.dispose() + mpvCore = null } - val optionsStr = options.joinToString(",") - // Convert content:// URIs to fdclose:// for MPV (SAF SD card downloads) - val mpvUri = openContentFd(uri)?.let { "fdclose://$it" } ?: uri - mpvCore?.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr)) - } else { - playerCore?.open(uri, headers, startPositionMs, autoPlay, isLive, externalSubtitles) - } - result.success(null) - } ?: result.error("NO_ACTIVITY", "Activity not available", null) - } - - private fun handlePlay(result: MethodChannel.Result) { - activity?.runOnUiThread { - if (usingMpvFallback) { - mpvCore?.setProperty("pause", "no") - } else { - playerCore?.play() - } - result.success(null) - } ?: result.success(null) - } - - private fun handlePause(result: MethodChannel.Result) { - activity?.runOnUiThread { - if (usingMpvFallback) { - mpvCore?.setProperty("pause", "yes") - } else { - playerCore?.pause() - } - result.success(null) - } ?: result.success(null) - } - - private fun handleStop(result: MethodChannel.Result) { - activity?.runOnUiThread { - if (usingMpvFallback) { - mpvCore?.command(arrayOf("stop")) - mpvCore?.setVisible(false) - } else { - playerCore?.stop() - } - result.success(null) - } ?: result.success(null) - } - - private fun handleSeek(call: MethodCall, result: MethodChannel.Result) { - val positionMs = call.argument("positionMs")?.toLong() - - if (positionMs == null) { - result.error("INVALID_ARGS", "Missing 'positionMs'", null) - return - } - - activity?.runOnUiThread { - if (usingMpvFallback) { - val positionSeconds = positionMs / 1000.0 - mpvCore?.command(arrayOf("seek", positionSeconds.toString(), "absolute")) - } else { - playerCore?.seekTo(positionMs) - } - result.success(null) - } ?: result.success(null) - } - - private fun handleSetVolume(call: MethodCall, result: MethodChannel.Result) { - val volume = call.argument("volume")?.toFloat() - - if (volume == null) { - result.error("INVALID_ARGS", "Missing 'volume'", null) - return - } - - activity?.runOnUiThread { - if (usingMpvFallback) { - mpvCore?.setProperty("volume", volume.toString()) - } else { - playerCore?.setVolume(volume / 100f) // Convert 0-100 to 0-1 - } - result.success(null) - } ?: result.success(null) - } - - private fun handleSetRate(call: MethodCall, result: MethodChannel.Result) { - val rate = call.argument("rate")?.toFloat() - - if (rate == null) { - result.error("INVALID_ARGS", "Missing 'rate'", null) - return - } - - activity?.runOnUiThread { - if (usingMpvFallback) { - mpvCore?.setProperty("speed", rate.toString()) - } else { - playerCore?.setPlaybackSpeed(rate) - } - result.success(null) - } ?: result.success(null) - } - - private fun handleSelectAudioTrack(call: MethodCall, result: MethodChannel.Result) { - val trackId = call.argument("trackId") - - if (trackId == null) { - result.error("INVALID_ARGS", "Missing 'trackId'", null) - return - } - - activity?.runOnUiThread { - if (usingMpvFallback) { - // After fallback, track IDs come from mpv's track-list (already 1-indexed) - mpvCore?.setProperty("aid", trackId) - } else { - playerCore?.selectAudioTrack(trackId) - } - result.success(null) - } ?: result.success(null) - } - - private fun handleSelectSubtitleTrack(call: MethodCall, result: MethodChannel.Result) { - val trackId = call.argument("trackId") - - // trackId can be null or "no" to disable subtitles - activity?.runOnUiThread { - if (usingMpvFallback) { - mpvCore?.setProperty("sid", trackId ?: "no") - } else { - playerCore?.selectSubtitleTrack(trackId) - } - result.success(null) - } ?: result.success(null) - } - - private fun handleAddSubtitleTrack(call: MethodCall, result: MethodChannel.Result) { - val uri = call.argument("uri") - val title = call.argument("title") - val language = call.argument("language") - val mimeType = call.argument("mimeType") - val select = call.argument("select") ?: false - - if (uri == null) { - result.error("INVALID_ARGS", "Missing 'uri'", null) - return - } - - activity?.runOnUiThread { - if (usingMpvFallback) { - val selectFlag = if (select) "select" else "auto" - mpvCore?.command(arrayOf("sub-add", uri, selectFlag, title ?: "External")) - } else { - playerCore?.addSubtitleTrack(uri, title, language, mimeType, select) - } - result.success(null) - } ?: result.success(null) - } - - private fun handleSetVisible(call: MethodCall, result: MethodChannel.Result) { - val visible = call.argument("visible") - - if (visible == null) { - result.error("INVALID_ARGS", "Missing 'visible'", null) - return - } - - if (usingMpvFallback) { - mpvCore?.setVisible(visible) - } else { - playerCore?.setVisible(visible) - } - result.success(null) - } - - private fun handleUpdateFrame(result: MethodChannel.Result) { - if (usingMpvFallback) { - mpvCore?.updateFrame() - } else { - playerCore?.updateFrame() - } - result.success(null) - } - - private fun handleSetVideoFrameRate(call: MethodCall, result: MethodChannel.Result) { - val fps = call.argument("fps")?.toFloat() ?: 0f - val duration = call.argument("duration")?.toLong() ?: 0L - val extraDelayMs = call.argument("extraDelayMs")?.toLong() ?: 0L - - Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs") - val onComplete: (Boolean) -> Unit = { switched -> result.success(switched) } - if (usingMpvFallback) { - val core = mpvCore - if (core == null) result.success(false) - else core.setVideoFrameRate(fps, duration, extraDelayMs, onComplete) - } else { - val core = playerCore - if (core == null) result.success(false) - else core.setVideoFrameRate(fps, duration, extraDelayMs, onComplete) - } - } - - private fun handleClearVideoFrameRate(result: MethodChannel.Result) { - Log.d(TAG, "clearVideoFrameRate") - if (usingMpvFallback) { - mpvCore?.clearVideoFrameRate() - } else { - playerCore?.clearVideoFrameRate() - } - result.success(null) - } - - private fun handleRequestAudioFocus(result: MethodChannel.Result) { - Log.d(TAG, "requestAudioFocus") - val granted = if (usingMpvFallback) { - mpvCore?.requestAudioFocus() ?: false - } else { - playerCore?.requestAudioFocus() ?: false - } - result.success(granted) - } - - private fun handleAbandonAudioFocus(result: MethodChannel.Result) { - Log.d(TAG, "abandonAudioFocus") - if (usingMpvFallback) { - mpvCore?.abandonAudioFocus() - } else { - playerCore?.abandonAudioFocus() - } - result.success(null) - } - - private fun handleObserveProperty(call: MethodCall, result: MethodChannel.Result) { - val name = call.argument("name") - val id = call.argument("id") - - if (name == null || id == null) { - result.error("INVALID_ARGS", "Missing 'name' or 'id'", null) - return - } - - nameToId[name] = id - result.success(null) - } - - private fun handleSetSubtitleStyle(call: MethodCall, result: MethodChannel.Result) { - val fontSize = call.argument("fontSize")?.toFloat() ?: 55f - val textColor = call.argument("textColor") ?: "#FFFFFF" - val borderSize = call.argument("borderSize")?.toFloat() ?: 3f - val borderColor = call.argument("borderColor") ?: "#000000" - val bgColor = call.argument("bgColor") ?: "#000000" - val bgOpacity = call.argument("bgOpacity")?.toInt() ?: 0 - val subtitlePosition = call.argument("subtitlePosition")?.toInt() ?: 100 - val bold = call.argument("bold") ?: false - val italic = call.argument("italic") ?: false - - if (usingMpvFallback) { - // MPV fallback handles styling via setProperty, no-op here - result.success(null) - return - } - - playerCore?.setSubtitleStyle(fontSize, textColor, borderSize, borderColor, bgColor, bgOpacity, subtitlePosition, bold, italic) - result.success(null) - } - - private fun handleSetBoxFitMode(call: MethodCall, result: MethodChannel.Result) { - val mode = call.argument("mode")?.toInt() - if (mode == null) { - result.error("INVALID_ARGS", "Missing 'mode'", null) - return - } - // The MPV-property side (panscan / sub-ass-force-margins / video-aspect-override) - // is driven from Dart via setProperty and routed through setMpvProperty, which - // already handles both the fallback and pendingMpvProperties cases. - if (usingMpvFallback) { - result.success(null) - return - } - activity?.runOnUiThread { - playerCore?.setBoxFitMode(mode) - result.success(null) - } ?: result.success(null) - } - - private fun handleSetMpvProperty(call: MethodCall, result: MethodChannel.Result) { - val name = call.argument("name") - val value = call.argument("value") - - if (name == null || value == null) { - result.error("INVALID_ARGS", "Missing 'name' or 'value'", null) - return - } - - // Apply sync offsets to ExoPlayer when active - if (!usingMpvFallback) { - when (name) { - "audio-delay" -> playerCore?.setAudioDelay(value.toDoubleOrNull() ?: 0.0) - "sub-delay" -> playerCore?.setSubtitleDelay(value.toDoubleOrNull() ?: 0.0) - } - } - - if (usingMpvFallback) { - mpvCore?.setProperty(name, value) - } else { - // Store for later application if ExoPlayer falls back to MPV - pendingMpvProperties.add(Pair(name, value)) - } - result.success(null) - } - - private fun handleGetStats(result: MethodChannel.Result) { - if (usingMpvFallback) { - Thread { - val stats = getMpvStats() - activity?.runOnUiThread { result.success(stats) } - }.start() - } else { - activity?.runOnUiThread { - val coreStats = playerCore?.getStats() ?: emptyMap() - result.success(coreStats + mapOf("playerType" to "exoplayer")) - } ?: result.success(mapOf("playerType" to "unknown")) - } - } - - /** - * Get playback stats from MPV when in fallback mode. - * Queries relevant MPV properties and returns them in a map format - * compatible with the performance overlay. - */ - private fun getMpvStats(): Map { - val mpv = mpvCore ?: return mapOf("playerType" to "mpv") - - val hasVideo = mpv.getProperty("video-params/w") != null - - val stats = mutableMapOf( - "playerType" to "mpv", - // Video metrics - "video-codec" to mpv.getProperty("video-codec"), - "video-params/w" to mpv.getProperty("video-params/w"), - "video-params/h" to mpv.getProperty("video-params/h"), - "videoWidth" to mpv.getProperty("dwidth"), - "videoHeight" to mpv.getProperty("dheight"), - "container-fps" to mpv.getProperty("container-fps"), - "estimated-vf-fps" to mpv.getProperty("estimated-vf-fps"), - "video-bitrate" to mpv.getProperty("video-bitrate"), - "hwdec-current" to mpv.getProperty("hwdec-current"), - // Audio metrics - "audio-codec-name" to mpv.getProperty("audio-codec-name"), - "audio-params/samplerate" to mpv.getProperty("audio-params/samplerate"), - "audio-params/hr-channels" to mpv.getProperty("audio-params/hr-channels"), - "audio-bitrate" to mpv.getProperty("audio-bitrate"), - // Performance metrics - "total-avsync-change" to mpv.getProperty("total-avsync-change"), - "cache-speed" to mpv.getProperty("cache-speed"), - "frame-drop-count" to mpv.getProperty("frame-drop-count"), - "decoder-frame-drop-count" to mpv.getProperty("decoder-frame-drop-count"), - "demuxer-cache-duration" to mpv.getProperty("demuxer-cache-duration"), - ) - - // Only query properties that require an active video track - if (hasVideo) { - stats["display-fps"] = mpv.getProperty("display-fps") - // Color/Format properties - stats["video-params/pixelformat"] = mpv.getProperty("video-params/pixelformat") - stats["video-params/hw-pixelformat"] = mpv.getProperty("video-params/hw-pixelformat") - stats["video-params/colormatrix"] = mpv.getProperty("video-params/colormatrix") - stats["video-params/primaries"] = mpv.getProperty("video-params/primaries") - stats["video-params/gamma"] = mpv.getProperty("video-params/gamma") - // HDR metadata - stats["video-params/max-luma"] = mpv.getProperty("video-params/max-luma") - stats["video-params/min-luma"] = mpv.getProperty("video-params/min-luma") - stats["video-params/max-cll"] = mpv.getProperty("video-params/max-cll") - stats["video-params/max-fall"] = mpv.getProperty("video-params/max-fall") - // Other - stats["video-params/aspect-name"] = mpv.getProperty("video-params/aspect-name") - stats["video-params/rotate"] = mpv.getProperty("video-params/rotate") - } - - return stats - } - - // PiP Mode handling - - fun onPipModeChanged(isInPipMode: Boolean) { - activity?.runOnUiThread { - if (usingMpvFallback) { - mpvCore?.onPipModeChanged(isInPipMode) - } else { - playerCore?.onPipModeChanged(isInPipMode) - } - } - } - - // ExoPlayerDelegate - - override fun onPropertyChange(name: String, value: Any?) { - val propId = nameToId[name] ?: return - mainHandler.post { eventSink?.success(listOf(propId, value)) } - } - - override fun onEvent(name: String, data: Map?) { - val event = mutableMapOf( - "type" to "event", - "name" to name - ) - data?.let { event["data"] = it } - mainHandler.post { eventSink?.success(event) } - } - - /** - * Opens a content:// URI via ContentResolver and returns the raw FD number, - * or null if the URI is not a content:// scheme or opening fails. - * The returned FD is detached so MPV can own and close it via fdclose://. - */ - private fun openContentFd( - uriString: String, - resolver: ContentResolver? = activity?.contentResolver - ): Int? { - if (!uriString.startsWith("content://")) return null - return try { - val uri = Uri.parse(uriString) - val pfd = resolver?.openFileDescriptor(uri, "r") ?: return null - val fd = pfd.detachFd() - Log.d(TAG, "Opened content FD $fd for $uriString") - fd - } catch (e: Exception) { - Log.e(TAG, "Failed to open content FD: ${e.message}", e) - null - } - } - - override fun onFormatUnsupported( - uri: String, - headers: Map?, - positionMs: Long, - errorMessage: String - ): Boolean { - if (usingMpvFallback || fallbackInProgress) { - Log.w(TAG, "Fallback already active/in-progress, ignoring duplicate request") - return true - } - - val currentActivity = activity ?: return false - fallbackInProgress = true - - Log.i(TAG, "ExoPlayer error, switching to MPV fallback at ${positionMs}ms: $errorMessage") - if (debugLoggingEnabled) { - onEvent("log-message", mapOf( - "prefix" to "fallback", "level" to "warn", - "text" to "Switching to MPV at ${positionMs}ms: $errorMessage" - )) - } - - currentActivity.runOnUiThread { - try { - // Dispose ExoPlayer - playerCore?.dispose() - playerCore = null - mpvCore?.dispose() - mpvCore = null - usingMpvFallback = false // Clear before handoff - - val generation = sessionGeneration - - Handler(Looper.getMainLooper()).post { - if (generation != sessionGeneration) { - fallbackInProgress = false - return@post - } - val act = activity - if (act == null) { - fallbackInProgress = false - return@post - } - - try { - val core = MpvPlayerCore(act).apply { - delegate = this@ExoPlayerPlugin - } - mpvCore = core // publish so dispose/init can reach it - - core.initialize { success -> - if (generation != sessionGeneration) { - if (mpvCore === core) { - core.dispose() - mpvCore = null - } - fallbackInProgress = false - return@initialize - } - if (!success) { - if (mpvCore === core) { - core.dispose() - mpvCore = null - } - fallbackInProgress = false - Log.e(TAG, "Failed to initialize MPV fallback") - onEvent("end-file", mapOf("reason" to "error", "message" to "Fallback failed: $errorMessage")) - return@initialize - } - - usingMpvFallback = true - fallbackInProgress = false - - // Snapshot pending properties on main thread before clearing - val pendingProps = pendingMpvProperties.toList() - pendingMpvProperties.clear() - - // Compute content FD on main thread (needs contentResolver) - val mpvUri = openContentFd(uri, act.contentResolver) - ?.let { "fdclose://$it" } ?: uri - - // Buffer size for closure - val bufferSize = configuredBufferSizeBytes - - if (mpvCore !== core) { - core.dispose() - fallbackInProgress = false - return@initialize - } - // Configure basic MPV properties for Plex playback - core.setProperty("hwdec", "mediacodec,mediacodec-copy") - core.setProperty("vo", "gpu") - core.setProperty("ao", "audiotrack") - - // Forward user's buffer config to MPV fallback - if (bufferSize != null && bufferSize > 0) { - core.setProperty("demuxer-max-bytes", bufferSize.toString()) - } - - // Apply pending MPV properties from Dart - for ((propName, propValue) in pendingProps) { - core.setProperty(propName, propValue) - } - - // Setup property observers - core.observeProperty("time-pos", "double") - core.observeProperty("duration", "double") - core.observeProperty("seekable", "flag") - core.observeProperty("pause", "flag") - core.observeProperty("paused-for-cache", "flag") - core.observeProperty("demuxer-cache-time", "double") - core.observeProperty("eof-reached", "flag") - core.observeProperty("track-list", "string") - core.observeProperty("aid", "string") - core.observeProperty("sid", "string") - core.observeProperty("volume", "double") - core.observeProperty("speed", "double") - - // Show the MPV surface (internally posts to UI) - core.setVisible(true) - - // Load media at the same position - val startSeconds = positionMs / 1000.0 - val options = mutableListOf() - options.add("start=$startSeconds") - headers?.forEach { (key, value) -> - options.add("http-header-fields-append=$key: $value") - } - val optionsStr = options.joinToString(",") - core.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr)) - - // On GPUs without compute shaders, MPV can't do dynamic peak detection - // and spline tone-mapping produces dim/washed-out results with extreme - // static HDR peak metadata. Use reinhard which handles this better. - Thread { - val peakDetection = core.getProperty("hdr-compute-peak") - if (peakDetection == "no") { - Log.i(TAG, "No compute shaders — overriding tone-mapping to reinhard") - core.setProperty("tone-mapping", "reinhard") - core.setProperty("tone-mapping-param", "0.7") - core.setProperty("tone-mapping-mode", "luma") - } - }.start() - - // Request audio focus - core.requestAudioFocus() - - // Emit backend-switched event on main thread - activity?.runOnUiThread { - onEvent("backend-switched", null) - } - - Log.i(TAG, "Successfully switched to MPV fallback") - } - } catch (e: Exception) { - fallbackInProgress = false - Log.e(TAG, "Failed to switch to MPV fallback", e) - onEvent("end-file", mapOf("reason" to "error", "message" to "Fallback failed: ${e.message}")) - } - } - } catch (e: Exception) { fallbackInProgress = false - Log.e(TAG, "Failed to switch to MPV fallback", e) - onEvent("end-file", mapOf("reason" to "error", "message" to "Fallback failed: ${e.message}")) - } - } + return@initialize + } + if (!success) { + if (mpvCore === core) { + core.dispose() + mpvCore = null + } + fallbackInProgress = false + Log.e(TAG, "Failed to initialize MPV fallback") + onEvent("end-file", mapOf("reason" to "error", "message" to "Fallback failed: $errorMessage")) + return@initialize + } - return true // Fallback is being handled + usingMpvFallback = true + fallbackInProgress = false + + // Snapshot pending properties on main thread before clearing + val pendingProps = pendingMpvProperties.toList() + pendingMpvProperties.clear() + + // Compute content FD on main thread (needs contentResolver) + val mpvUri = openContentFd(uri, act.contentResolver) + ?.let { "fdclose://$it" } ?: uri + + // Buffer size for closure + val bufferSize = configuredBufferSizeBytes + + if (mpvCore !== core) { + core.dispose() + fallbackInProgress = false + return@initialize + } + // Configure basic MPV properties for Plex playback + core.setProperty("hwdec", "mediacodec,mediacodec-copy") + core.setProperty("vo", "gpu") + core.setProperty("ao", "audiotrack") + + // Forward user's buffer config to MPV fallback + if (bufferSize != null && bufferSize > 0) { + core.setProperty("demuxer-max-bytes", bufferSize.toString()) + } + + // Apply pending MPV properties from Dart + for ((propName, propValue) in pendingProps) { + core.setProperty(propName, propValue) + } + + // Setup property observers + core.observeProperty("time-pos", "double") + core.observeProperty("duration", "double") + core.observeProperty("seekable", "flag") + core.observeProperty("pause", "flag") + core.observeProperty("paused-for-cache", "flag") + core.observeProperty("demuxer-cache-time", "double") + core.observeProperty("eof-reached", "flag") + core.observeProperty("track-list", "string") + core.observeProperty("aid", "string") + core.observeProperty("sid", "string") + core.observeProperty("volume", "double") + core.observeProperty("speed", "double") + + // Show the MPV surface (internally posts to UI) + core.setVisible(true) + + // Load media at the same position + val startSeconds = positionMs / 1000.0 + val options = mutableListOf() + options.add("start=$startSeconds") + headers?.forEach { (key, value) -> + options.add("http-header-fields-append=$key: $value") + } + val optionsStr = options.joinToString(",") + core.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr)) + + // On GPUs without compute shaders, MPV can't do dynamic peak detection + // and spline tone-mapping produces dim/washed-out results with extreme + // static HDR peak metadata. Use reinhard which handles this better. + Thread { + val peakDetection = core.getProperty("hdr-compute-peak") + if (peakDetection == "no") { + Log.i(TAG, "No compute shaders — overriding tone-mapping to reinhard") + core.setProperty("tone-mapping", "reinhard") + core.setProperty("tone-mapping-param", "0.7") + core.setProperty("tone-mapping-mode", "luma") + } + }.start() + + // Request audio focus + core.requestAudioFocus() + + // Emit backend-switched event on main thread + activity?.runOnUiThread { + onEvent("backend-switched", null) + } + + Log.i(TAG, "Successfully switched to MPV fallback") + } + } catch (e: Exception) { + fallbackInProgress = false + Log.e(TAG, "Failed to switch to MPV fallback", e) + onEvent("end-file", mapOf("reason" to "error", "message" to "Fallback failed: ${e.message}")) + } + } + } catch (e: Exception) { + fallbackInProgress = false + Log.e(TAG, "Failed to switch to MPV fallback", e) + onEvent("end-file", mapOf("reason" to "error", "message" to "Fallback failed: ${e.message}")) + } } + + return true // Fallback is being handled + } } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt index 7f9bfa11..859f0844 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt @@ -8,6 +8,7 @@ import androidx.media3.common.PlaybackParameters import androidx.media3.common.util.Clock import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.Renderer import androidx.media3.exoplayer.analytics.PlayerId import androidx.media3.exoplayer.audio.AudioOutput import androidx.media3.exoplayer.audio.AudioOutputProvider @@ -16,7 +17,6 @@ import androidx.media3.exoplayer.audio.AudioTrackAudioOutputProvider import androidx.media3.exoplayer.audio.DefaultAudioSink import androidx.media3.exoplayer.audio.DefaultAudioTrackBufferSizeProvider import androidx.media3.exoplayer.audio.ForwardingAudioSink -import androidx.media3.exoplayer.Renderer import java.nio.ByteBuffer import java.util.concurrent.atomic.AtomicLong import kotlin.math.abs @@ -24,38 +24,38 @@ import kotlin.math.abs @OptIn(UnstableApi::class) class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context) { - /** Audio delay in microseconds. Shared with PositionFixAudioSink for live updates. */ - val audioDelayUs = AtomicLong(0L) + /** Audio delay in microseconds. Shared with PositionFixAudioSink for live updates. */ + val audioDelayUs = AtomicLong(0L) - override fun buildAudioSink( - context: Context, - enableFloatOutput: Boolean, - enableAudioOutputPlaybackParams: Boolean - ): AudioSink { - AudioTrackAudioOutputProvider.failOnSpuriousAudioTimestamp = false + override fun buildAudioSink( + context: Context, + enableFloatOutput: Boolean, + enableAudioOutputPlaybackParams: Boolean + ): AudioSink { + AudioTrackAudioOutputProvider.failOnSpuriousAudioTimestamp = false - val bufferSizeProvider = DefaultAudioTrackBufferSizeProvider.Builder() - .setMinPcmBufferDurationUs(500_000) - .setMaxPcmBufferDurationUs(1_000_000) - .setPcmBufferMultiplicationFactor(4) - .build() + val bufferSizeProvider = DefaultAudioTrackBufferSizeProvider.Builder() + .setMinPcmBufferDurationUs(500_000) + .setMaxPcmBufferDurationUs(1_000_000) + .setPcmBufferMultiplicationFactor(4) + .build() - val realProvider = AudioTrackAudioOutputProvider.Builder(context) - .setAudioTrackBufferSizeProvider(bufferSizeProvider) - .build() + val realProvider = AudioTrackAudioOutputProvider.Builder(context) + .setAudioTrackBufferSizeProvider(bufferSizeProvider) + .build() - // Shared position: RawPositionAudioOutput writes the raw AudioTrack position, - // PositionFixAudioSink reads it to bypass DefaultAudioSink's writtenDuration clamp. - val rawPositionUs = AtomicLong(Long.MIN_VALUE) + // Shared position: RawPositionAudioOutput writes the raw AudioTrack position, + // PositionFixAudioSink reads it to bypass DefaultAudioSink's writtenDuration clamp. + val rawPositionUs = AtomicLong(Long.MIN_VALUE) - val defaultSink = DefaultAudioSink.Builder(context) - .setEnableFloatOutput(enableFloatOutput) - .setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParams) - .setAudioOutputProvider(RawPositionOutputProvider(realProvider, rawPositionUs)) - .build() + val defaultSink = DefaultAudioSink.Builder(context) + .setEnableFloatOutput(enableFloatOutput) + .setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParams) + .setAudioOutputProvider(RawPositionOutputProvider(realProvider, rawPositionUs)) + .build() - return PositionFixAudioSink(defaultSink, rawPositionUs, audioDelayUs) - } + return PositionFixAudioSink(defaultSink, rawPositionUs, audioDelayUs) + } } /** @@ -81,130 +81,132 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context) */ @OptIn(UnstableApi::class) private class PositionFixAudioSink( - sink: AudioSink, - private val rawPositionUs: AtomicLong, - private val audioDelayUs: AtomicLong + sink: AudioSink, + private val rawPositionUs: AtomicLong, + private val audioDelayUs: AtomicLong ) : ForwardingAudioSink(sink) { - private var startMediaTimeUs = Long.MIN_VALUE - private var suppressedErrorCount = 0 + private var startMediaTimeUs = Long.MIN_VALUE + private var suppressedErrorCount = 0 - // Speed tracking for position bypass - private var currentSpeed = 1.0f - private var refMediaTimeUs = Long.MIN_VALUE - private var refRawPositionUs = 0L + // Speed tracking for position bypass + private var currentSpeed = 1.0f + private var refMediaTimeUs = Long.MIN_VALUE + private var refRawPositionUs = 0L - // Transient counter-offset: after seek/flush, ramp offset from 0 to full - // over recoveryDurationUs to prevent video frame drops. - private var recoveryDurationUs = 0L + // Transient counter-offset: after seek/flush, ramp offset from 0 to full + // over recoveryDurationUs to prevent video frame drops. + private var recoveryDurationUs = 0L - override fun handleBuffer( - buffer: ByteBuffer, - presentationTimeUs: Long, - encodedAccessUnitCount: Int - ): Boolean { - if (startMediaTimeUs == Long.MIN_VALUE) { - startMediaTimeUs = presentationTimeUs - refMediaTimeUs = presentationTimeUs - refRawPositionUs = 0 - val delayUs = audioDelayUs.get() - recoveryDurationUs = if (delayUs == 0L) 0L else maxOf(200_000L, 2L * abs(delayUs)) + override fun handleBuffer( + buffer: ByteBuffer, + presentationTimeUs: Long, + encodedAccessUnitCount: Int + ): Boolean { + if (startMediaTimeUs == Long.MIN_VALUE) { + startMediaTimeUs = presentationTimeUs + refMediaTimeUs = presentationTimeUs + refRawPositionUs = 0 + val delayUs = audioDelayUs.get() + recoveryDurationUs = if (delayUs == 0L) 0L else maxOf(200_000L, 2L * abs(delayUs)) + } + return super.handleBuffer(buffer, presentationTimeUs, encodedAccessUnitCount) + } + + override fun setPlaybackParameters(playbackParameters: PlaybackParameters) { + // Capture reference point before speed changes + val rawPos = rawPositionUs.get() + if (rawPos > 0 && refMediaTimeUs != Long.MIN_VALUE) { + refMediaTimeUs = refMediaTimeUs + ((rawPos - refRawPositionUs) * currentSpeed).toLong() + refRawPositionUs = rawPos + } + currentSpeed = playbackParameters.speed + super.setPlaybackParameters(playbackParameters) + } + + override fun getCurrentPositionUs(sourceEnded: Boolean): Long { + val delegatePos = super.getCurrentPositionUs(sourceEnded) + if (delegatePos == Long.MIN_VALUE || refMediaTimeUs == Long.MIN_VALUE) { + return delegatePos + } + + val rawPos = rawPositionUs.get() + val basePos = if (rawPos <= 0) { + delegatePos + } else { + val expectedPos = refMediaTimeUs + ((rawPos - refRawPositionUs) * currentSpeed).toLong() + if (expectedPos > delegatePos + 30_000) expectedPos else delegatePos + } + + // Audio delay with transient counter-offset to prevent frame drops after seeks. + // After flush, offset ramps linearly from 0 to full over recoveryDurationUs. + val delayUs = audioDelayUs.get() + if (delayUs == 0L) return basePos + + val elapsedUs = basePos - startMediaTimeUs + val netOffsetUs = if (recoveryDurationUs <= 0L || elapsedUs >= recoveryDurationUs) { + delayUs + } else { + (delayUs * elapsedUs) / recoveryDurationUs + } + return basePos + netOffsetUs + } + + // --- Suppress timestamp discontinuity errors --- + + override fun setListener(listener: AudioSink.Listener) { + super.setListener( + @OptIn(UnstableApi::class) object : AudioSink.Listener { + override fun onPositionDiscontinuity() = listener.onPositionDiscontinuity() + override fun onPositionAdvancing(playoutStartSystemTimeUs: Long) = listener.onPositionAdvancing(playoutStartSystemTimeUs) + override fun onUnderrun(bufferSize: Int, bufferSizeMs: Long, elapsedSinceLastFeedMs: Long) = listener.onUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs) + override fun onSkipSilenceEnabledChanged(skipSilenceEnabled: Boolean) = listener.onSkipSilenceEnabledChanged(skipSilenceEnabled) + override fun onOffloadBufferEmptying() = listener.onOffloadBufferEmptying() + override fun onOffloadBufferFull() = listener.onOffloadBufferFull() + override fun onAudioCapabilitiesChanged() = listener.onAudioCapabilitiesChanged() + override fun onAudioTrackInitialized(audioTrackConfig: AudioSink.AudioTrackConfig) = listener.onAudioTrackInitialized(audioTrackConfig) + override fun onAudioTrackReleased(audioTrackConfig: AudioSink.AudioTrackConfig) = listener.onAudioTrackReleased(audioTrackConfig) + override fun onSilenceSkipped() = listener.onSilenceSkipped() + override fun onAudioSessionIdChanged(audioSessionId: Int) = listener.onAudioSessionIdChanged(audioSessionId) + + override fun onAudioSinkError(audioSinkError: Exception) { + if (isTimestampDiscontinuity(audioSinkError)) { + suppressedErrorCount++ + return + } + listener.onAudioSinkError(audioSinkError) } - return super.handleBuffer(buffer, presentationTimeUs, encodedAccessUnitCount) - } + } + ) + } - override fun setPlaybackParameters(playbackParameters: PlaybackParameters) { - // Capture reference point before speed changes - val rawPos = rawPositionUs.get() - if (rawPos > 0 && refMediaTimeUs != Long.MIN_VALUE) { - refMediaTimeUs = refMediaTimeUs + ((rawPos - refRawPositionUs) * currentSpeed).toLong() - refRawPositionUs = rawPos - } - currentSpeed = playbackParameters.speed - super.setPlaybackParameters(playbackParameters) - } + override fun flush() { + startMediaTimeUs = Long.MIN_VALUE + refMediaTimeUs = Long.MIN_VALUE + refRawPositionUs = 0 + recoveryDurationUs = 0L + rawPositionUs.set(Long.MIN_VALUE) + super.flush() + } - override fun getCurrentPositionUs(sourceEnded: Boolean): Long { - val delegatePos = super.getCurrentPositionUs(sourceEnded) - if (delegatePos == Long.MIN_VALUE || refMediaTimeUs == Long.MIN_VALUE) { - return delegatePos - } + override fun reset() { + startMediaTimeUs = Long.MIN_VALUE + refMediaTimeUs = Long.MIN_VALUE + refRawPositionUs = 0 + recoveryDurationUs = 0L + currentSpeed = 1.0f + rawPositionUs.set(Long.MIN_VALUE) + suppressedErrorCount = 0 + super.reset() + } - val rawPos = rawPositionUs.get() - val basePos = if (rawPos <= 0) { - delegatePos - } else { - val expectedPos = refMediaTimeUs + ((rawPos - refRawPositionUs) * currentSpeed).toLong() - if (expectedPos > delegatePos + 30_000) expectedPos else delegatePos - } - - // Audio delay with transient counter-offset to prevent frame drops after seeks. - // After flush, offset ramps linearly from 0 to full over recoveryDurationUs. - val delayUs = audioDelayUs.get() - if (delayUs == 0L) return basePos - - val elapsedUs = basePos - startMediaTimeUs - val netOffsetUs = if (recoveryDurationUs <= 0L || elapsedUs >= recoveryDurationUs) { - delayUs - } else { - (delayUs * elapsedUs) / recoveryDurationUs - } - return basePos + netOffsetUs - } - - // --- Suppress timestamp discontinuity errors --- - - override fun setListener(listener: AudioSink.Listener) { - super.setListener(@OptIn(UnstableApi::class) object : AudioSink.Listener { - override fun onPositionDiscontinuity() = listener.onPositionDiscontinuity() - override fun onPositionAdvancing(playoutStartSystemTimeUs: Long) = listener.onPositionAdvancing(playoutStartSystemTimeUs) - override fun onUnderrun(bufferSize: Int, bufferSizeMs: Long, elapsedSinceLastFeedMs: Long) = listener.onUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs) - override fun onSkipSilenceEnabledChanged(skipSilenceEnabled: Boolean) = listener.onSkipSilenceEnabledChanged(skipSilenceEnabled) - override fun onOffloadBufferEmptying() = listener.onOffloadBufferEmptying() - override fun onOffloadBufferFull() = listener.onOffloadBufferFull() - override fun onAudioCapabilitiesChanged() = listener.onAudioCapabilitiesChanged() - override fun onAudioTrackInitialized(audioTrackConfig: AudioSink.AudioTrackConfig) = listener.onAudioTrackInitialized(audioTrackConfig) - override fun onAudioTrackReleased(audioTrackConfig: AudioSink.AudioTrackConfig) = listener.onAudioTrackReleased(audioTrackConfig) - override fun onSilenceSkipped() = listener.onSilenceSkipped() - override fun onAudioSessionIdChanged(audioSessionId: Int) = listener.onAudioSessionIdChanged(audioSessionId) - - override fun onAudioSinkError(audioSinkError: Exception) { - if (isTimestampDiscontinuity(audioSinkError)) { - suppressedErrorCount++ - return - } - listener.onAudioSinkError(audioSinkError) - } - }) - } - - override fun flush() { - startMediaTimeUs = Long.MIN_VALUE - refMediaTimeUs = Long.MIN_VALUE - refRawPositionUs = 0 - recoveryDurationUs = 0L - rawPositionUs.set(Long.MIN_VALUE) - super.flush() - } - - override fun reset() { - startMediaTimeUs = Long.MIN_VALUE - refMediaTimeUs = Long.MIN_VALUE - refRawPositionUs = 0 - recoveryDurationUs = 0L - currentSpeed = 1.0f - rawPositionUs.set(Long.MIN_VALUE) - suppressedErrorCount = 0 - super.reset() - } - - private fun isTimestampDiscontinuity(e: Exception): Boolean { - val name = e.javaClass.simpleName - val msg = e.message ?: "" - return name == "InvalidAudioTrackTimestampException" || - name == "UnexpectedDiscontinuityException" || - msg.contains("timestamp discontinuity", ignoreCase = true) - } + private fun isTimestampDiscontinuity(e: Exception): Boolean { + val name = e.javaClass.simpleName + val msg = e.message ?: "" + return name == "InvalidAudioTrackTimestampException" || + name == "UnexpectedDiscontinuityException" || + msg.contains("timestamp discontinuity", ignoreCase = true) + } } /** @@ -215,12 +217,12 @@ private class PositionFixAudioSink( */ @OptIn(UnstableApi::class) internal class SubtitleDelayRenderer( - private val delegate: Renderer, - private val delayUs: AtomicLong + private val delegate: Renderer, + private val delayUs: AtomicLong ) : Renderer by delegate { - override fun render(positionUs: Long, elapsedRealtimeUs: Long) { - delegate.render(positionUs - delayUs.get(), elapsedRealtimeUs) - } + override fun render(positionUs: Long, elapsedRealtimeUs: Long) { + delegate.render(positionUs - delayUs.get(), elapsedRealtimeUs) + } } // --- AudioOutput wrapping: shares raw position with PositionFixAudioSink --- @@ -232,117 +234,109 @@ internal class SubtitleDelayRenderer( @OptIn(UnstableApi::class) private class RawPositionOutputProvider( - private val delegate: AudioOutputProvider, - private val rawPositionUs: AtomicLong + private val delegate: AudioOutputProvider, + private val rawPositionUs: AtomicLong ) : AudioOutputProvider { - private var cachedOutput: RawPositionAudioOutput? = null - private var cachedConfig: AudioOutputProvider.OutputConfig? = null + private var cachedOutput: RawPositionAudioOutput? = null + private var cachedConfig: AudioOutputProvider.OutputConfig? = null - override fun getFormatSupport(config: AudioOutputProvider.FormatConfig) = - delegate.getFormatSupport(config) + override fun getFormatSupport(config: AudioOutputProvider.FormatConfig) = delegate.getFormatSupport(config) - override fun getOutputConfig(config: AudioOutputProvider.FormatConfig) = - delegate.getOutputConfig(config) + override fun getOutputConfig(config: AudioOutputProvider.FormatConfig) = delegate.getOutputConfig(config) - override fun getAudioOutput(config: AudioOutputProvider.OutputConfig): AudioOutput { - val cached = cachedOutput - if (cached != null && cachedConfig == config) { - cachedOutput = null - return cached - } - cached?.forceRelease() - cachedOutput = null - - val realOutput = delegate.getAudioOutput(config) - cachedConfig = config - return RawPositionAudioOutput(realOutput, rawPositionUs, this) + override fun getAudioOutput(config: AudioOutputProvider.OutputConfig): AudioOutput { + val cached = cachedOutput + if (cached != null && cachedConfig == config) { + cachedOutput = null + return cached } + cached?.forceRelease() + cachedOutput = null - fun returnToCache(output: RawPositionAudioOutput) { - val existing = cachedOutput - if (existing != null && existing !== output) { - existing.forceRelease() - } - cachedOutput = output + val realOutput = delegate.getAudioOutput(config) + cachedConfig = config + return RawPositionAudioOutput(realOutput, rawPositionUs, this) + } + + fun returnToCache(output: RawPositionAudioOutput) { + val existing = cachedOutput + if (existing != null && existing !== output) { + existing.forceRelease() } + cachedOutput = output + } - override fun addListener(listener: AudioOutputProvider.Listener) = - delegate.addListener(listener) + override fun addListener(listener: AudioOutputProvider.Listener) = delegate.addListener(listener) - override fun removeListener(listener: AudioOutputProvider.Listener) = - delegate.removeListener(listener) + override fun removeListener(listener: AudioOutputProvider.Listener) = delegate.removeListener(listener) - override fun setClock(clock: Clock) = delegate.setClock(clock) + override fun setClock(clock: Clock) = delegate.setClock(clock) - override fun release() { - cachedOutput?.forceRelease() - cachedOutput = null - cachedConfig = null - delegate.release() - } + override fun release() { + cachedOutput?.forceRelease() + cachedOutput = null + cachedConfig = null + delegate.release() + } } @OptIn(UnstableApi::class) private class RawPositionAudioOutput( - private val delegate: AudioOutput, - private val rawPositionUs: AtomicLong, - private val provider: RawPositionOutputProvider + private val delegate: AudioOutput, + private val rawPositionUs: AtomicLong, + private val provider: RawPositionOutputProvider ) : AudioOutput { - override fun getPositionUs(): Long { - val pos = delegate.getPositionUs() - rawPositionUs.set(pos) - return pos + override fun getPositionUs(): Long { + val pos = delegate.getPositionUs() + rawPositionUs.set(pos) + return pos + } + + override fun play() = delegate.play() + override fun pause() = delegate.pause() + + @Throws(AudioOutput.WriteException::class) + override fun write(buffer: ByteBuffer, size: Int, presentationTimeUs: Long) = delegate.write(buffer, size, presentationTimeUs) + + override fun flush() { + rawPositionUs.set(Long.MIN_VALUE) + delegate.flush() + } + + override fun stop() = delegate.stop() + + override fun release() { + rawPositionUs.set(Long.MIN_VALUE) + if (Build.VERSION.SDK_INT >= 25) { + delegate.stop() + delegate.flush() + provider.returnToCache(this) + } else { + delegate.release() } + } - override fun play() = delegate.play() - override fun pause() = delegate.pause() + fun forceRelease() { + rawPositionUs.set(Long.MIN_VALUE) + delegate.release() + } - @Throws(AudioOutput.WriteException::class) - override fun write(buffer: ByteBuffer, size: Int, presentationTimeUs: Long) = - delegate.write(buffer, size, presentationTimeUs) - - override fun flush() { - rawPositionUs.set(Long.MIN_VALUE) - delegate.flush() - } - - override fun stop() = delegate.stop() - - override fun release() { - rawPositionUs.set(Long.MIN_VALUE) - if (Build.VERSION.SDK_INT >= 25) { - delegate.stop() - delegate.flush() - provider.returnToCache(this) - } else { - delegate.release() - } - } - - fun forceRelease() { - rawPositionUs.set(Long.MIN_VALUE) - delegate.release() - } - - override fun setVolume(volume: Float) = delegate.setVolume(volume) - override fun isOffloadedPlayback() = delegate.isOffloadedPlayback() - override fun getAudioSessionId() = delegate.getAudioSessionId() - override fun getSampleRate() = delegate.getSampleRate() - override fun getBufferSizeInFrames() = delegate.getBufferSizeInFrames() - override fun getPlaybackParameters() = delegate.getPlaybackParameters() - override fun isStalled() = delegate.isStalled() - override fun addListener(listener: AudioOutput.Listener) = delegate.addListener(listener) - override fun removeListener(listener: AudioOutput.Listener) = delegate.removeListener(listener) - override fun setPlaybackParameters(playbackParameters: PlaybackParameters) = - delegate.setPlaybackParameters(playbackParameters) - override fun setOffloadDelayPadding(delayInFrames: Int, paddingInFrames: Int) = - delegate.setOffloadDelayPadding(delayInFrames, paddingInFrames) - override fun setOffloadEndOfStream() = delegate.setOffloadEndOfStream() - override fun setPlayerId(playerId: PlayerId) = delegate.setPlayerId(playerId) - override fun attachAuxEffect(effectId: Int) = delegate.attachAuxEffect(effectId) - override fun setAuxEffectSendLevel(level: Float) = delegate.setAuxEffectSendLevel(level) - override fun setPreferredDevice(preferredDevice: AudioDeviceInfo?) = - delegate.setPreferredDevice(preferredDevice) + override fun setVolume(volume: Float) = delegate.setVolume(volume) + override fun isOffloadedPlayback() = delegate.isOffloadedPlayback() + override fun getAudioSessionId() = delegate.getAudioSessionId() + override fun getSampleRate() = delegate.getSampleRate() + override fun getBufferSizeInFrames() = delegate.getBufferSizeInFrames() + override fun getPlaybackParameters() = delegate.getPlaybackParameters() + override fun isStalled() = delegate.isStalled() + override fun addListener(listener: AudioOutput.Listener) = delegate.addListener(listener) + override fun removeListener(listener: AudioOutput.Listener) = delegate.removeListener(listener) + override fun setPlaybackParameters(playbackParameters: PlaybackParameters) = delegate.setPlaybackParameters(playbackParameters) + override fun setOffloadDelayPadding(delayInFrames: Int, paddingInFrames: Int) = delegate.setOffloadDelayPadding(delayInFrames, paddingInFrames) + override fun setOffloadEndOfStream() = delegate.setOffloadEndOfStream() + override fun setPlayerId(playerId: PlayerId) = delegate.setPlayerId(playerId) + override fun attachAuxEffect(effectId: Int) = delegate.attachAuxEffect(effectId) + override fun setAuxEffectSendLevel(level: Float) = delegate.setAuxEffectSendLevel(level) + override fun setPreferredDevice(preferredDevice: AudioDeviceInfo?) = delegate.setPreferredDevice(preferredDevice) } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutput.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutput.kt index 680be898..e6778edd 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutput.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutput.kt @@ -16,108 +16,115 @@ import java.util.zip.Inflater * All buffers are reused across samples to minimize GC pressure on the hot path. */ class ZlibInflatingTrackOutput( - private val delegate: TrackOutput, + private val delegate: TrackOutput ) : TrackOutput { - companion object { - private const val TAG = "ZlibTrackOutput" - private const val INITIAL_BUFFER_SIZE = 256 * 1024 - private const val INFLATE_CHUNK = 64 * 1024 + companion object { + private const val TAG = "ZlibTrackOutput" + private const val INITIAL_BUFFER_SIZE = 256 * 1024 + private const val INFLATE_CHUNK = 64 * 1024 + } + + var active = false + + private val inflater = Inflater() + + // Reusable buffers — grown as needed, never shrunk + private var compressedBuf = ByteArray(INITIAL_BUFFER_SIZE) + private var compressedLen = 0 + private var inflateBuf = ByteArray(INITIAL_BUFFER_SIZE) + private var readBuf = ByteArray(INFLATE_CHUNK) + private val outputParsable = ParsableByteArray() + private var buffering = false + + override fun format(format: Format) = delegate.format(format) + + override fun sampleData( + input: DataReader, + length: Int, + allowEndOfInput: Boolean, + sampleDataPart: Int + ): Int { + if (!active) return delegate.sampleData(input, length, allowEndOfInput, sampleDataPart) + + buffering = true + if (readBuf.size < length) readBuf = ByteArray(length) + val bytesRead = input.read(readBuf, 0, length) + if (bytesRead > 0) appendCompressed(readBuf, 0, bytesRead) + return bytesRead + } + + override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) { + if (!active) { + delegate.sampleData(data, length, sampleDataPart) + return } - var active = false + buffering = true + ensureCompressedCapacity(compressedLen + length) + data.readBytes(compressedBuf, compressedLen, length) + compressedLen += length + } - private val inflater = Inflater() - - // Reusable buffers — grown as needed, never shrunk - private var compressedBuf = ByteArray(INITIAL_BUFFER_SIZE) - private var compressedLen = 0 - private var inflateBuf = ByteArray(INITIAL_BUFFER_SIZE) - private var readBuf = ByteArray(INFLATE_CHUNK) - private val outputParsable = ParsableByteArray() - private var buffering = false - - override fun format(format: Format) = delegate.format(format) - - override fun sampleData( - input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int - ): Int { - if (!active) return delegate.sampleData(input, length, allowEndOfInput, sampleDataPart) - - buffering = true - if (readBuf.size < length) readBuf = ByteArray(length) - val bytesRead = input.read(readBuf, 0, length) - if (bytesRead > 0) appendCompressed(readBuf, 0, bytesRead) - return bytesRead + override fun sampleMetadata( + timeUs: Long, + flags: Int, + size: Int, + offset: Int, + cryptoData: TrackOutput.CryptoData? + ) { + if (!active || !buffering) { + delegate.sampleMetadata(timeUs, flags, size, offset, cryptoData) + return } - override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) { - if (!active) { - delegate.sampleData(data, length, sampleDataPart) - return - } + buffering = false + val srcLen = compressedLen + compressedLen = 0 - buffering = true - ensureCompressedCapacity(compressedLen + length) - data.readBytes(compressedBuf, compressedLen, length) - compressedLen += length + val inflatedLen = try { + inflater.reset() + inflater.setInput(compressedBuf, 0, srcLen) + var written = 0 + while (!inflater.finished()) { + if (written == inflateBuf.size) growInflateBuf() + val count = inflater.inflate(inflateBuf, written, inflateBuf.size - written) + if (count == 0 && !inflater.finished()) break + written += count + } + written + } catch (e: DataFormatException) { + Log.e(TAG, "Zlib inflate failed (${srcLen}B), passing raw", e) + // Fall back to raw compressed data + ensureInflateCapacity(srcLen) + System.arraycopy(compressedBuf, 0, inflateBuf, 0, srcLen) + srcLen } - override fun sampleMetadata( - timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData? - ) { - if (!active || !buffering) { - delegate.sampleMetadata(timeUs, flags, size, offset, cryptoData) - return - } + outputParsable.reset(inflateBuf, inflatedLen) + delegate.sampleData(outputParsable, inflatedLen, TrackOutput.SAMPLE_DATA_PART_MAIN) + delegate.sampleMetadata(timeUs, flags, inflatedLen, 0, cryptoData) + } - buffering = false - val srcLen = compressedLen - compressedLen = 0 + private fun appendCompressed(src: ByteArray, offset: Int, length: Int) { + ensureCompressedCapacity(compressedLen + length) + System.arraycopy(src, offset, compressedBuf, compressedLen, length) + compressedLen += length + } - val inflatedLen = try { - inflater.reset() - inflater.setInput(compressedBuf, 0, srcLen) - var written = 0 - while (!inflater.finished()) { - if (written == inflateBuf.size) growInflateBuf() - val count = inflater.inflate(inflateBuf, written, inflateBuf.size - written) - if (count == 0 && !inflater.finished()) break - written += count - } - written - } catch (e: DataFormatException) { - Log.e(TAG, "Zlib inflate failed (${srcLen}B), passing raw", e) - // Fall back to raw compressed data - ensureInflateCapacity(srcLen) - System.arraycopy(compressedBuf, 0, inflateBuf, 0, srcLen) - srcLen - } - - outputParsable.reset(inflateBuf, inflatedLen) - delegate.sampleData(outputParsable, inflatedLen, TrackOutput.SAMPLE_DATA_PART_MAIN) - delegate.sampleMetadata(timeUs, flags, inflatedLen, 0, cryptoData) + private fun ensureCompressedCapacity(needed: Int) { + if (compressedBuf.size < needed) { + compressedBuf = compressedBuf.copyOf(maxOf(needed, compressedBuf.size * 2)) } + } - private fun appendCompressed(src: ByteArray, offset: Int, length: Int) { - ensureCompressedCapacity(compressedLen + length) - System.arraycopy(src, offset, compressedBuf, compressedLen, length) - compressedLen += length + private fun ensureInflateCapacity(needed: Int) { + if (inflateBuf.size < needed) { + inflateBuf = ByteArray(maxOf(needed, inflateBuf.size * 2)) } + } - private fun ensureCompressedCapacity(needed: Int) { - if (compressedBuf.size < needed) { - compressedBuf = compressedBuf.copyOf(maxOf(needed, compressedBuf.size * 2)) - } - } - - private fun ensureInflateCapacity(needed: Int) { - if (inflateBuf.size < needed) { - inflateBuf = ByteArray(maxOf(needed, inflateBuf.size * 2)) - } - } - - private fun growInflateBuf() { - inflateBuf = inflateBuf.copyOf(inflateBuf.size * 2) - } + private fun growInflateBuf() { + inflateBuf = inflateBuf.copyOf(inflateBuf.size * 2) + } } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibMatroskaExtractor.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibMatroskaExtractor.kt index cb69e0be..5b00ed90 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibMatroskaExtractor.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibMatroskaExtractor.kt @@ -6,9 +6,9 @@ import androidx.media3.extractor.ExtractorOutput import androidx.media3.extractor.SeekMap import androidx.media3.extractor.TrackOutput import androidx.media3.extractor.mkv.MatroskaExtractor +import androidx.media3.extractor.text.SubtitleParser import io.github.peerless2012.ass.media.AssHandler import io.github.peerless2012.ass.media.extractor.AssMatroskaExtractor -import androidx.media3.extractor.text.SubtitleParser /** * Extends AssMatroskaExtractor to add support for MKV ContentCompAlgo 0 (zlib). @@ -20,97 +20,97 @@ import androidx.media3.extractor.text.SubtitleParser * - Skips ContentCompSettings for zlib tracks (not applicable) */ class ZlibMatroskaExtractor( - subtitleParserFactory: SubtitleParser.Factory, - assHandler: AssHandler, + subtitleParserFactory: SubtitleParser.Factory, + assHandler: AssHandler ) : AssMatroskaExtractor(subtitleParserFactory, assHandler) { - companion object { - private const val TAG = "ZlibMkvExtractor" + companion object { + private const val TAG = "ZlibMkvExtractor" - // Matroska EBML element IDs - private const val ID_SEGMENT = 0x18538067 - private const val ID_TRACK_ENTRY = 0xAE - private const val ID_CONTENT_COMPRESSION_ALGORITHM = 0x4254 - private const val ID_CONTENT_COMPRESSION_SETTINGS = 0x4255 + // Matroska EBML element IDs + private const val ID_SEGMENT = 0x18538067 + private const val ID_TRACK_ENTRY = 0xAE + private const val ID_CONTENT_COMPRESSION_ALGORITHM = 0x4254 + private const val ID_CONTENT_COMPRESSION_SETTINGS = 0x4255 - private val extractorOutputField by lazy { - MatroskaExtractor::class.java.getDeclaredField("extractorOutput").apply { - isAccessible = true - } - } + private val extractorOutputField by lazy { + MatroskaExtractor::class.java.getDeclaredField("extractorOutput").apply { + isAccessible = true + } + } + } + + private var zlibOutput: ZlibExtractorOutputWrapper? = null + private var currentTrackUsesZlib = false + + override fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) { + super.startMasterElement(id, contentPosition, contentSize) + + // After super installs AssSubtitleExtractorOutput, wrap it with our zlib layer + if (id == ID_SEGMENT && zlibOutput == null) { + val currentOutput = extractorOutputField.get(this) as ExtractorOutput + val wrapper = ZlibExtractorOutputWrapper(currentOutput) + zlibOutput = wrapper + extractorOutputField.set(this, wrapper) + Log.d(TAG, "Installed zlib ExtractorOutput wrapper") + } + } + + override fun integerElement(id: Int, value: Long) { + if (id == ID_CONTENT_COMPRESSION_ALGORITHM && value == 0L) { + currentTrackUsesZlib = true + Log.i(TAG, "Track uses ContentCompAlgo 0 (zlib), will inflate samples") + // Tell parent it's header stripping (algo 3) to avoid ParserException + super.integerElement(id, 3) + return + } + super.integerElement(id, value) + } + + override fun binaryElement(id: Int, contentSize: Int, input: ExtractorInput) { + if (id == ID_CONTENT_COMPRESSION_SETTINGS && currentTrackUsesZlib) { + // Skip ContentCompSettings for zlib tracks — parent would store these as + // sampleStrippedBytes and prepend them to every sample, corrupting output. + input.skipFully(contentSize) + return + } + super.binaryElement(id, contentSize, input) + } + + override fun endMasterElement(id: Int) { + val wasZlib = currentTrackUsesZlib + super.endMasterElement(id) + + if (id == ID_TRACK_ENTRY && wasZlib) { + zlibOutput?.activateLast() + currentTrackUsesZlib = false + Log.i(TAG, "Activated zlib inflation for track") + } + } + + /** + * ExtractorOutput wrapper that wraps all TrackOutputs with ZlibInflatingTrackOutput. + * Tracks are created inactive; activateLast() enables inflation for the most recently + * created track (called when we know a track uses zlib compression). + */ + private class ZlibExtractorOutputWrapper( + private val delegate: ExtractorOutput + ) : ExtractorOutput { + + private var lastCreatedWrapper: ZlibInflatingTrackOutput? = null + + override fun track(id: Int, type: Int): TrackOutput { + val original = delegate.track(id, type) + val wrapper = ZlibInflatingTrackOutput(original) + lastCreatedWrapper = wrapper + return wrapper } - private var zlibOutput: ZlibExtractorOutputWrapper? = null - private var currentTrackUsesZlib = false - - override fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) { - super.startMasterElement(id, contentPosition, contentSize) - - // After super installs AssSubtitleExtractorOutput, wrap it with our zlib layer - if (id == ID_SEGMENT && zlibOutput == null) { - val currentOutput = extractorOutputField.get(this) as ExtractorOutput - val wrapper = ZlibExtractorOutputWrapper(currentOutput) - zlibOutput = wrapper - extractorOutputField.set(this, wrapper) - Log.d(TAG, "Installed zlib ExtractorOutput wrapper") - } + fun activateLast() { + lastCreatedWrapper?.active = true } - override fun integerElement(id: Int, value: Long) { - if (id == ID_CONTENT_COMPRESSION_ALGORITHM && value == 0L) { - currentTrackUsesZlib = true - Log.i(TAG, "Track uses ContentCompAlgo 0 (zlib), will inflate samples") - // Tell parent it's header stripping (algo 3) to avoid ParserException - super.integerElement(id, 3) - return - } - super.integerElement(id, value) - } - - override fun binaryElement(id: Int, contentSize: Int, input: ExtractorInput) { - if (id == ID_CONTENT_COMPRESSION_SETTINGS && currentTrackUsesZlib) { - // Skip ContentCompSettings for zlib tracks — parent would store these as - // sampleStrippedBytes and prepend them to every sample, corrupting output. - input.skipFully(contentSize) - return - } - super.binaryElement(id, contentSize, input) - } - - override fun endMasterElement(id: Int) { - val wasZlib = currentTrackUsesZlib - super.endMasterElement(id) - - if (id == ID_TRACK_ENTRY && wasZlib) { - zlibOutput?.activateLast() - currentTrackUsesZlib = false - Log.i(TAG, "Activated zlib inflation for track") - } - } - - /** - * ExtractorOutput wrapper that wraps all TrackOutputs with ZlibInflatingTrackOutput. - * Tracks are created inactive; activateLast() enables inflation for the most recently - * created track (called when we know a track uses zlib compression). - */ - private class ZlibExtractorOutputWrapper( - private val delegate: ExtractorOutput, - ) : ExtractorOutput { - - private var lastCreatedWrapper: ZlibInflatingTrackOutput? = null - - override fun track(id: Int, type: Int): TrackOutput { - val original = delegate.track(id, type) - val wrapper = ZlibInflatingTrackOutput(original) - lastCreatedWrapper = wrapper - return wrapper - } - - fun activateLast() { - lastCreatedWrapper?.active = true - } - - override fun endTracks() = delegate.endTracks() - override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap) - } + override fun endTracks() = delegate.endTracks() + override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap) + } } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt index 6186f659..37b1e6f5 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt @@ -24,814 +24,842 @@ import kotlinx.coroutines.sync.withLock class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback { - companion object { - private const val TAG = "MpvPlayerCore" + companion object { + private const val TAG = "MpvPlayerCore" + } + + private var surfaceView: SurfaceView? = null + private var surfaceContainer: android.widget.FrameLayout? = null + private var overlayLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null + + @Volatile private var disposing: Boolean = false + + @Volatile private var pendingSurface: Surface? = null + + @Volatile private var attachedSurface: Surface? = null + private var placeholderImageReader: ImageReader? = null + + @Volatile private var placeholderSurface: Surface? = null + + @Volatile private var lastAppliedSurfaceSize: String? = null + + @Volatile private var lastKnownSurfaceWidth: Int = 0 + + @Volatile private var lastKnownSurfaceHeight: Int = 0 + var delegate: PlayerDelegate? = null + var isInitialized: Boolean = false + private set + + @Volatile private var player: MpvPlayer? = null + private var scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + // Frame rate matching + private var frameRateManager: FrameRateManager? = null + private val handler = Handler(Looper.getMainLooper()) + + // Audio focus + private var audioFocusManager: AudioFocusManager? = null + + @Volatile private var cachedPaused: Boolean = true + + @Volatile private var pausedForSurfaceLoss: Boolean = false + + @Volatile private var hasAttachedSurface: Boolean = false + + @Volatile private var attachedToPlaceholder: Boolean = false + + @Volatile private var videoOutputRestoring: Boolean = false + + @Volatile private var deferredResumeRequested: Boolean = false + + @Volatile private var resumeBlockedByPublicPause: Boolean = false + + @Volatile private var videoOutputEpoch: Long = 0L + private val videoOutputMutex = Mutex() + private var pendingVideoOutputDisableJob: Job? = null + private var pendingVideoOutputRefreshJob: Job? = null + + private var flutterOverlayApplied = false + + private fun ensureFlutterOverlayOnTop() { + if (disposing || flutterOverlayApplied) return + val contentView = activity.findViewById(android.R.id.content) + contentView.post { + if (disposing || !isInitialized) return@post + val container = FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer) + ?: return@post + if (contentView.getChildAt(contentView.childCount - 1) == container) { + flutterOverlayApplied = true + return@post + } + FlutterOverlayHelper.configureFlutterZOrder(contentView, container, compositionOrder = 1) + flutterOverlayApplied = true + } + } + + private fun ensurePlaceholderSurface() { + if (placeholderSurface?.isValid == true) return + placeholderImageReader?.close() + placeholderImageReader = ImageReader.newInstance(1, 1, PixelFormat.RGBA_8888, 2) + placeholderSurface = placeholderImageReader?.surface + Log.d(TAG, "Created MPV placeholder surface") + } + + fun initialize(onResult: (Boolean) -> Unit) { + if (isInitialized) { + Log.d(TAG, "Already initialized") + onResult(true) + return } - private var surfaceView: SurfaceView? = null - private var surfaceContainer: android.widget.FrameLayout? = null - private var overlayLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null - @Volatile private var disposing: Boolean = false - @Volatile private var pendingSurface: Surface? = null - @Volatile private var attachedSurface: Surface? = null - private var placeholderImageReader: ImageReader? = null - @Volatile private var placeholderSurface: Surface? = null - @Volatile private var lastAppliedSurfaceSize: String? = null - @Volatile private var lastKnownSurfaceWidth: Int = 0 - @Volatile private var lastKnownSurfaceHeight: Int = 0 - var delegate: PlayerDelegate? = null - var isInitialized: Boolean = false - private set + try { + disposing = false + cachedPaused = true + pausedForSurfaceLoss = false + pendingSurface = null + attachedSurface = null + attachedToPlaceholder = false + hasAttachedSurface = false + videoOutputRestoring = false + deferredResumeRequested = false + resumeBlockedByPublicPause = false + videoOutputEpoch = 0L + pendingVideoOutputDisableJob?.cancel() + pendingVideoOutputDisableJob = null + lastAppliedSurfaceSize = null + lastKnownSurfaceWidth = 0 + lastKnownSurfaceHeight = 0 + ensurePlaceholderSurface() - @Volatile private var player: MpvPlayer? = null - private var scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) - - // Frame rate matching - private var frameRateManager: FrameRateManager? = null - private val handler = Handler(Looper.getMainLooper()) - - // Audio focus - private var audioFocusManager: AudioFocusManager? = null - @Volatile private var cachedPaused: Boolean = true - @Volatile private var pausedForSurfaceLoss: Boolean = false - @Volatile private var hasAttachedSurface: Boolean = false - @Volatile private var attachedToPlaceholder: Boolean = false - @Volatile private var videoOutputRestoring: Boolean = false - @Volatile private var deferredResumeRequested: Boolean = false - @Volatile private var resumeBlockedByPublicPause: Boolean = false - @Volatile private var videoOutputEpoch: Long = 0L - private val videoOutputMutex = Mutex() - private var pendingVideoOutputDisableJob: Job? = null - private var pendingVideoOutputRefreshJob: Job? = null - - private var flutterOverlayApplied = false - - private fun ensureFlutterOverlayOnTop() { - if (disposing || flutterOverlayApplied) return - val contentView = activity.findViewById(android.R.id.content) - contentView.post { - if (disposing || !isInitialized) return@post - val container = FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer) - ?: return@post - if (contentView.getChildAt(contentView.childCount - 1) == container) { - flutterOverlayApplied = true - return@post + // Initialize audio focus handling + audioFocusManager = AudioFocusManager( + context = activity, + handler = handler, + onPause = { + scope.launch { + try { + player?.setProperty("pause", true) + } catch (e: Exception) { + Log.w(TAG, "Failed to pause on focus loss", e) } - FlutterOverlayHelper.configureFlutterZOrder(contentView, container, compositionOrder = 1) - flutterOverlayApplied = true - } - } + } + }, + onResume = { + requestAutoResume("audio focus gain") + }, + isPaused = { cachedPaused } + ) + frameRateManager = FrameRateManager( + activity = activity, + handler = handler + ) - private fun ensurePlaceholderSurface() { - if (placeholderSurface?.isValid == true) return - placeholderImageReader?.close() - placeholderImageReader = ImageReader.newInstance(1, 1, PixelFormat.RGBA_8888, 2) - placeholderSurface = placeholderImageReader?.surface - Log.d(TAG, "Created MPV placeholder surface") - } + // Create FrameLayout container for video + surfaceContainer = android.widget.FrameLayout(activity).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + setBackgroundColor(Color.BLACK) + } - fun initialize(onResult: (Boolean) -> Unit) { - if (isInitialized) { - Log.d(TAG, "Already initialized") - onResult(true) - return - } + // Create SurfaceView for video rendering + surfaceView = SurfaceView(activity).apply { + layoutParams = android.widget.FrameLayout.LayoutParams( + android.widget.FrameLayout.LayoutParams.MATCH_PARENT, + android.widget.FrameLayout.LayoutParams.MATCH_PARENT + ) + holder.addCallback(this@MpvPlayerCore) + setZOrderOnTop(false) + setZOrderMediaOverlay(false) + FlutterOverlayHelper.applyCompositionOrder(this, -2) + } + // Add SurfaceView to container + surfaceContainer!!.addView(surfaceView) + + // Insert container at bottom of view hierarchy (behind Flutter) + val contentView = activity.findViewById(android.R.id.content) + contentView.addView(surfaceContainer, 0) + + // Find FlutterView and set it on top of our video surface. + // compositionOrder maps directly to SurfaceView mSubLayer on API 36+: + // negative is hole-punched behind the parent canvas, non-negative is above. + // Stack (back → front): video (-2, hole-punched) → parent canvas → Flutter UI (+1). + FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer)?.let { container -> + FlutterOverlayHelper.configureFlutterZOrder(contentView, container, compositionOrder = 1) + flutterOverlayApplied = true + } + ensureFlutterOverlayOnTop() + overlayLayoutListener = ViewTreeObserver.OnGlobalLayoutListener { + ensureFlutterOverlayOnTop() + val sv = surfaceView + if (sv != null) applySurfaceSize(sv.width, sv.height) + } + contentView.viewTreeObserver.addOnGlobalLayoutListener(overlayLayoutListener) + + Log.d(TAG, "SurfaceView added to content view") + + // Create MpvPlayer on background thread via coroutine + scope.launch { try { - disposing = false - cachedPaused = true - pausedForSurfaceLoss = false - pendingSurface = null - attachedSurface = null - attachedToPlaceholder = false - hasAttachedSurface = false - videoOutputRestoring = false - deferredResumeRequested = false - resumeBlockedByPublicPause = false - videoOutputEpoch = 0L - pendingVideoOutputDisableJob?.cancel() - pendingVideoOutputDisableJob = null - lastAppliedSurfaceSize = null - lastKnownSurfaceWidth = 0 - lastKnownSurfaceHeight = 0 - ensurePlaceholderSurface() - - // Initialize audio focus handling - audioFocusManager = AudioFocusManager( - context = activity, - handler = handler, - onPause = { - scope.launch { - try { player?.setProperty("pause", true) } - catch (e: Exception) { Log.w(TAG, "Failed to pause on focus loss", e) } - } - }, - onResume = { - requestAutoResume("audio focus gain") - }, - isPaused = { cachedPaused } - ) - frameRateManager = FrameRateManager( - activity = activity, - handler = handler, - ) - - // Create FrameLayout container for video - surfaceContainer = android.widget.FrameLayout(activity).apply { - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - setBackgroundColor(Color.BLACK) - } - - // Create SurfaceView for video rendering - surfaceView = SurfaceView(activity).apply { - layoutParams = android.widget.FrameLayout.LayoutParams( - android.widget.FrameLayout.LayoutParams.MATCH_PARENT, - android.widget.FrameLayout.LayoutParams.MATCH_PARENT - ) - holder.addCallback(this@MpvPlayerCore) - setZOrderOnTop(false) - setZOrderMediaOverlay(false) - FlutterOverlayHelper.applyCompositionOrder(this, -2) - } - - // Add SurfaceView to container - surfaceContainer!!.addView(surfaceView) - - // Insert container at bottom of view hierarchy (behind Flutter) - val contentView = activity.findViewById(android.R.id.content) - contentView.addView(surfaceContainer, 0) - - // Find FlutterView and set it on top of our video surface. - // compositionOrder maps directly to SurfaceView mSubLayer on API 36+: - // negative is hole-punched behind the parent canvas, non-negative is above. - // Stack (back → front): video (-2, hole-punched) → parent canvas → Flutter UI (+1). - FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer)?.let { container -> - FlutterOverlayHelper.configureFlutterZOrder(contentView, container, compositionOrder = 1) - flutterOverlayApplied = true - } - ensureFlutterOverlayOnTop() - overlayLayoutListener = ViewTreeObserver.OnGlobalLayoutListener { - ensureFlutterOverlayOnTop() - val sv = surfaceView - if (sv != null) applySurfaceSize(sv.width, sv.height) - } - contentView.viewTreeObserver.addOnGlobalLayoutListener(overlayLayoutListener) - - Log.d(TAG, "SurfaceView added to content view") - - // Create MpvPlayer on background thread via coroutine - scope.launch { - try { - if (disposing) { - onResult(false) - return@launch - } - val p = MpvPlayer.create(activity.applicationContext) { - setOption("vo", "gpu") - setOption("gpu-context", "android") - setOption("opengl-es", "yes") - setOption("vd-lavc-film-grain", "cpu") - setOption("ao", "audiotrack,opensles") - } - - if (disposing) { - p.close() - onResult(false) - return@launch - } - - player = p - isInitialized = true - - refreshVideoOutput("initialize") - - // Start collecting events/properties/logs - collectEvents(p) - collectPropertyChanges(p) - collectLogMessages(p) - - Log.d(TAG, "Initialized successfully") - onResult(true) - } catch (e: Exception) { - Log.e(TAG, "Failed to initialize native: ${e.message}", e) - onResult(false) - } - } - } catch (e: Exception) { - Log.e(TAG, "Failed to initialize: ${e.message}", e) + if (disposing) { onResult(false) + return@launch + } + val p = MpvPlayer.create(activity.applicationContext) { + setOption("vo", "gpu") + setOption("gpu-context", "android") + setOption("opengl-es", "yes") + setOption("vd-lavc-film-grain", "cpu") + setOption("ao", "audiotrack,opensles") + } + + if (disposing) { + p.close() + onResult(false) + return@launch + } + + player = p + isInitialized = true + + refreshVideoOutput("initialize") + + // Start collecting events/properties/logs + collectEvents(p) + collectPropertyChanges(p) + collectLogMessages(p) + + Log.d(TAG, "Initialized successfully") + onResult(true) + } catch (e: Exception) { + Log.e(TAG, "Failed to initialize native: ${e.message}", e) + onResult(false) } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to initialize: ${e.message}", e) + onResult(false) } + } - // Flow collectors + // Flow collectors - private fun collectEvents(p: MpvPlayer) { - scope.launch(start = CoroutineStart.UNDISPATCHED) { - p.eventFlow.collect { event -> - when (event) { - is MpvEvent.EndFile -> { - val data = event.reason?.let { mapOf("reason" to it.id) } - delegate?.onEvent("end-file", data) - } - is MpvEvent.FileLoaded -> delegate?.onEvent("file-loaded", null) - is MpvEvent.PlaybackRestart -> delegate?.onEvent("playback-restart", null) - else -> {} - } - } + private fun collectEvents(p: MpvPlayer) { + scope.launch(start = CoroutineStart.UNDISPATCHED) { + p.eventFlow.collect { event -> + when (event) { + is MpvEvent.EndFile -> { + val data = event.reason?.let { mapOf("reason" to it.id) } + delegate?.onEvent("end-file", data) + } + is MpvEvent.FileLoaded -> delegate?.onEvent("file-loaded", null) + is MpvEvent.PlaybackRestart -> delegate?.onEvent("playback-restart", null) + else -> {} } + } } + } - private fun collectPropertyChanges(p: MpvPlayer) { - scope.launch(start = CoroutineStart.UNDISPATCHED) { - p.propertyFlow.collect { change -> - // Skip None — matches old MPVLib behavior where eventProperty(name) - // with no value was a no-op. Forwarding null would incorrectly clear - // track selections (aid/sid) before the file loads. - if (change is PropertyChange.None) return@collect - val value: Any? = when (change) { - is PropertyChange.Flag -> change.value - is PropertyChange.Int64 -> change.value - is PropertyChange.Double -> change.value - is PropertyChange.Str -> change.value - is PropertyChange.None -> null - } - if (change.name == "pause" && change is PropertyChange.Flag) { - cachedPaused = change.value - } - delegate?.onPropertyChange(change.name, value) - } + private fun collectPropertyChanges(p: MpvPlayer) { + scope.launch(start = CoroutineStart.UNDISPATCHED) { + p.propertyFlow.collect { change -> + // Skip None — matches old MPVLib behavior where eventProperty(name) + // with no value was a no-op. Forwarding null would incorrectly clear + // track selections (aid/sid) before the file loads. + if (change is PropertyChange.None) return@collect + val value: Any? = when (change) { + is PropertyChange.Flag -> change.value + is PropertyChange.Int64 -> change.value + is PropertyChange.Double -> change.value + is PropertyChange.Str -> change.value + is PropertyChange.None -> null } - } - - private fun collectLogMessages(p: MpvPlayer) { - scope.launch(start = CoroutineStart.UNDISPATCHED) { - p.logFlow.collect { msg -> - delegate?.onEvent("log-message", mapOf( - "prefix" to msg.prefix, - "level" to msg.level.name.lowercase(), - "text" to msg.text - )) - } + if (change.name == "pause" && change is PropertyChange.Flag) { + cachedPaused = change.value } + delegate?.onPropertyChange(change.name, value) + } + } + } + + private fun collectLogMessages(p: MpvPlayer) { + scope.launch(start = CoroutineStart.UNDISPATCHED) { + p.logFlow.collect { msg -> + delegate?.onEvent( + "log-message", + mapOf( + "prefix" to msg.prefix, + "level" to msg.level.name.lowercase(), + "text" to msg.text + ) + ) + } + } + } + + // Audio Focus + + fun requestAudioFocus(): Boolean = audioFocusManager?.requestAudioFocus() ?: false + + fun abandonAudioFocus() { + audioFocusManager?.abandonAudioFocus() + } + + // SurfaceHolder.Callback + + override fun surfaceCreated(holder: SurfaceHolder) { + Log.d(TAG, "Surface created") + if (disposing) return + + val surface = holder.surface + pendingSurface = surface.takeIf { it.isValid } + pendingVideoOutputDisableJob?.cancel() + videoOutputEpoch += 1L + rememberCurrentSurfaceSize() + if (player == null) { + Log.d(TAG, "Deferring video output refresh until MPV init completes") + return } - // Audio Focus + refreshVideoOutput("surfaceCreated") + } - fun requestAudioFocus(): Boolean = audioFocusManager?.requestAudioFocus() ?: false + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + Log.d(TAG, "Surface changed: ${width}x$height") + rememberSurfaceSize(width, height) + refreshVideoOutput("surfaceChanged") + } - fun abandonAudioFocus() { audioFocusManager?.abandonAudioFocus() } + override fun surfaceDestroyed(holder: SurfaceHolder) { + Log.d(TAG, "Surface destroyed") + pendingSurface = null + if (player == null || disposing) return + detachSurfaceInternal(reason = "surfaceDestroyed") + } - // SurfaceHolder.Callback + private fun rememberSurfaceSize(width: Int, height: Int) { + if (width <= 0 || height <= 0) return + lastKnownSurfaceWidth = width + lastKnownSurfaceHeight = height + } - override fun surfaceCreated(holder: SurfaceHolder) { - Log.d(TAG, "Surface created") - if (disposing) return + private fun rememberCurrentSurfaceSize() { + val sv = surfaceView ?: return + rememberSurfaceSize(sv.width, sv.height) + } - val surface = holder.surface - pendingSurface = surface.takeIf { it.isValid } - pendingVideoOutputDisableJob?.cancel() - videoOutputEpoch += 1L - rememberCurrentSurfaceSize() - if (player == null) { - Log.d(TAG, "Deferring video output refresh until MPV init completes") - return - } + private fun currentCandidateSurface(): Surface? = surfaceView?.holder?.surface?.takeIf { it.isValid } + ?: pendingSurface?.takeIf { it.isValid } - refreshVideoOutput("surfaceCreated") + private fun hasAttachedRealSurface(): Boolean = hasAttachedSurface && !attachedToPlaceholder && (attachedSurface?.isValid == true) + + private fun hasReadyVideoOutput(): Boolean = hasAttachedRealSurface() && !videoOutputRestoring + + private fun isCurrentVideoOutputEpoch(epoch: Long): Boolean = !disposing && epoch == videoOutputEpoch + + private fun isVideoOutputRefreshCurrent(epoch: Long): Boolean { + if (disposing) return false + if (epoch != videoOutputEpoch) return false + return hasAttachedRealSurface() + } + + private fun refreshVideoOutput(reason: String) { + if (disposing) return + + rememberCurrentSurfaceSize() + val p = player + val surface = currentCandidateSurface() + if (p == null) { + pendingSurface = surface?.takeIf { it.isValid } + Log.d(TAG, "refreshVideoOutput($reason): player not ready yet") + return } - override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { - Log.d(TAG, "Surface changed: ${width}x${height}") - rememberSurfaceSize(width, height) - refreshVideoOutput("surfaceChanged") + if (surface == null || !surface.isValid) { + hasAttachedSurface = false + attachedSurface = null + attachedToPlaceholder = false + pendingSurface = null + lastAppliedSurfaceSize = null + videoOutputRestoring = true + Log.d(TAG, "refreshVideoOutput($reason): no valid surface available") + return } - override fun surfaceDestroyed(holder: SurfaceHolder) { - Log.d(TAG, "Surface destroyed") - pendingSurface = null - if (player == null || disposing) return - detachSurfaceInternal(reason = "surfaceDestroyed") - } - - private fun rememberSurfaceSize(width: Int, height: Int) { - if (width <= 0 || height <= 0) return - lastKnownSurfaceWidth = width - lastKnownSurfaceHeight = height - } - - private fun rememberCurrentSurfaceSize() { - val sv = surfaceView ?: return - rememberSurfaceSize(sv.width, sv.height) - } - - private fun currentCandidateSurface(): Surface? = - surfaceView?.holder?.surface?.takeIf { it.isValid } - ?: pendingSurface?.takeIf { it.isValid } - - private fun hasAttachedRealSurface(): Boolean = - hasAttachedSurface && !attachedToPlaceholder && (attachedSurface?.isValid == true) - - private fun hasReadyVideoOutput(): Boolean = - hasAttachedRealSurface() && !videoOutputRestoring - - private fun isCurrentVideoOutputEpoch(epoch: Long): Boolean = - !disposing && epoch == videoOutputEpoch - - private fun isVideoOutputRefreshCurrent(epoch: Long): Boolean { - if (disposing) return false - if (epoch != videoOutputEpoch) return false - return hasAttachedRealSurface() - } - - private fun refreshVideoOutput(reason: String) { - if (disposing) return - - rememberCurrentSurfaceSize() - val p = player - val surface = currentCandidateSurface() - if (p == null) { - pendingSurface = surface?.takeIf { it.isValid } - Log.d(TAG, "refreshVideoOutput($reason): player not ready yet") - return - } - - if (surface == null || !surface.isValid) { + val refreshEpoch = videoOutputEpoch + pendingVideoOutputDisableJob?.cancel() + videoOutputRestoring = true + flutterOverlayApplied = false + ensureFlutterOverlayOnTop() + Log.d(TAG, "refreshVideoOutput($reason): scheduling async refresh (epoch=$refreshEpoch)") + pendingVideoOutputRefreshJob = scope.launch(Dispatchers.IO) { + try { + videoOutputMutex.withLock { + if (!isCurrentVideoOutputEpoch(refreshEpoch)) { + Log.d(TAG, "Skipping stale MPV video output refresh ($reason, epoch=$refreshEpoch)") + return@withLock + } + if (!surface.isValid) { hasAttachedSurface = false attachedSurface = null attachedToPlaceholder = false pendingSurface = null lastAppliedSurfaceSize = null videoOutputRestoring = true - Log.d(TAG, "refreshVideoOutput($reason): no valid surface available") - return - } + Log.d(TAG, "Skipping MPV video output refresh with invalid surface ($reason, epoch=$refreshEpoch)") + return@withLock + } - val refreshEpoch = videoOutputEpoch - pendingVideoOutputDisableJob?.cancel() - videoOutputRestoring = true + val needsAttach = !hasAttachedSurface || attachedSurface !== surface + val wasAttachedToPlaceholder = attachedToPlaceholder + val wasPausedForSurfaceLoss = pausedForSurfaceLoss + if (needsAttach) { + p.attachSurface(surface) + attachedSurface = surface + hasAttachedSurface = true + attachedToPlaceholder = false + pendingSurface = null + Log.d(TAG, "refreshVideoOutput($reason): attached surface") + } else { + Log.d(TAG, "refreshVideoOutput($reason): surface already attached, refreshing surface state") + } + + if (!isVideoOutputRefreshCurrent(refreshEpoch)) { + Log.d(TAG, "Skipping stale MPV video output refresh after attach ($reason, epoch=$refreshEpoch)") + return@withLock + } + applySurfaceSizeInternal(p, force = true) + if (!isVideoOutputRefreshCurrent(refreshEpoch)) { + Log.d(TAG, "Skipping stale MPV video output refresh after surface size ($reason, epoch=$refreshEpoch)") + return@withLock + } + videoOutputRestoring = false + applyDeferredResumeIfNeeded(p, reason) + if (wasPausedForSurfaceLoss) { + pausedForSurfaceLoss = false + Log.d(TAG, "Cleared surface-loss pause after $reason") + } + if (wasAttachedToPlaceholder) { + Log.d(TAG, "Restored MPV real surface after placeholder ($reason)") + } + Log.d(TAG, "Video output ready after $reason") + } + } catch (e: CancellationException) { + Log.d(TAG, "Canceled pending MPV video output refresh ($reason, epoch=$refreshEpoch)") + } catch (e: Exception) { + Log.w(TAG, "Failed to finalize MPV video output refresh ($reason)", e) + } + } + } + + private fun applySurfaceSize(width: Int, height: Int) { + val p = player ?: return + if (disposing || width <= 0 || height <= 0) return + rememberSurfaceSize(width, height) + if (!hasReadyVideoOutput()) return + scope.launch { + try { + applySurfaceSizeInternal(p) + } catch (e: Exception) { + Log.w(TAG, "Failed to apply surface size to MPV", e) + } + } + } + + private suspend fun applySurfaceSizeInternal(p: MpvPlayer, force: Boolean = false) { + if (disposing) return + val width = lastKnownSurfaceWidth + val height = lastKnownSurfaceHeight + if (width <= 0 || height <= 0) return + + val size = "${width}x$height" + if (!force && size == lastAppliedSurfaceSize) return + p.setProperty("android-surface-size", size) + lastAppliedSurfaceSize = size + Log.d(TAG, "Applied MPV surface size $size${if (force) " (forced)" else ""}") + } + + private fun schedulePlaceholderSurfaceAttach( + p: MpvPlayer, + reason: String, + epoch: Long + ) { + pendingVideoOutputDisableJob?.cancel() + pendingVideoOutputDisableJob = scope.launch(Dispatchers.IO) { + try { + videoOutputMutex.withLock { + if (!isCurrentVideoOutputEpoch(epoch)) { + Log.d(TAG, "Skipping stale MPV placeholder attach ($reason, epoch=$epoch)") + return@withLock + } + val wasPaused = try { + p.getFlag("pause") == true + } catch (e: Exception) { + cachedPaused + } + if (!wasPaused) { + try { + p.setProperty("pause", true) + cachedPaused = true + pausedForSurfaceLoss = true + Log.d(TAG, "Paused MPV for surface loss ($reason, epoch=$epoch)") + } catch (e: Exception) { + pausedForSurfaceLoss = false + Log.w(TAG, "Failed to pause MPV before placeholder attach ($reason)", e) + } + } else { + pausedForSurfaceLoss = false + } + val surface = placeholderSurface?.takeIf { it.isValid } ?: run { + Log.w(TAG, "No valid MPV placeholder surface available for $reason") + return@withLock + } + p.attachSurface(surface) + attachedSurface = surface + hasAttachedSurface = true + attachedToPlaceholder = true + lastAppliedSurfaceSize = null + Log.d(TAG, "Attached MPV placeholder surface ($reason, epoch=$epoch)") + } + } catch (e: CancellationException) { + Log.d(TAG, "Canceled pending MPV placeholder attach ($reason, epoch=$epoch)") + } catch (e: Exception) { + Log.w(TAG, "Failed to attach MPV placeholder surface ($reason)", e) + } + } + } + + private fun detachSurfaceInternal(reason: String) { + val hadAttachedSurface = hasAttachedSurface || attachedSurface != null + hasAttachedSurface = false + attachedSurface = null + attachedToPlaceholder = false + videoOutputRestoring = true + lastAppliedSurfaceSize = null + val detachEpoch = videoOutputEpoch + 1L + videoOutputEpoch = detachEpoch + + val p = player ?: return + if (!hadAttachedSurface) { + Log.d(TAG, "detachSurfaceInternal($reason): no attached surface to clear") + return + } + + schedulePlaceholderSurfaceAttach( + p = p, + reason = reason, + epoch = detachEpoch + ) + Log.d(TAG, "Cleared MPV surface attachment ($reason, epoch=$detachEpoch)") + } + + private fun normalizePauseValue(value: String): Boolean? = when (value.lowercase()) { + "yes", "true", "1" -> true + "no", "false", "0" -> false + else -> null + } + + private fun requestAutoResume(reason: String) { + val p = player ?: return + if (disposing) return + + if (resumeBlockedByPublicPause) { + deferredResumeRequested = false + Log.d(TAG, "Skipping auto-resume after $reason because playback is explicitly paused") + return + } + + if (!hasReadyVideoOutput()) { + deferredResumeRequested = true + Log.d(TAG, "Deferring auto-resume after $reason until video output is ready") + return + } + + scope.launch { + try { + if (p.getFlag("pause") == true) { + Log.d(TAG, "Auto-resuming playback after $reason") + p.setProperty("pause", false) + } else { + Log.d(TAG, "Skipping auto-resume after $reason because playback is already running") + } + } catch (e: Exception) { + Log.w(TAG, "Failed to resume after $reason", e) + } + } + } + + private suspend fun applyDeferredResumeIfNeeded(p: MpvPlayer, reason: String) { + if (!deferredResumeRequested) return + + if (resumeBlockedByPublicPause) { + deferredResumeRequested = false + Log.d(TAG, "Dropping deferred auto-resume after $reason because playback is explicitly paused") + return + } + + deferredResumeRequested = false + if (p.getFlag("pause") == true) { + Log.d(TAG, "Applying deferred auto-resume after $reason") + p.setProperty("pause", false) + } else { + Log.d(TAG, "Skipping deferred auto-resume after $reason because playback is already running") + } + } + + // Public API + + fun setProperty(name: String, value: String) { + if (!isInitialized || disposing) return + if (name == "pause") { + val paused = normalizePauseValue(value) + if (paused == true) { + cachedPaused = true + pausedForSurfaceLoss = false + resumeBlockedByPublicPause = true + deferredResumeRequested = false + Log.d(TAG, "Public pause state updated: paused=true") + } else if (paused == false) { + resumeBlockedByPublicPause = false + if (!hasReadyVideoOutput()) { + deferredResumeRequested = true + Log.d(TAG, "Deferring public resume until video output is ready") + return + } + cachedPaused = false + pausedForSurfaceLoss = false + Log.d(TAG, "Public pause state updated: paused=false") + } + } + scope.launch { + try { + player?.setProperty(name, value) + } catch (e: Exception) { + Log.w(TAG, "setProperty($name) failed", e) + } + } + } + + fun getProperty(name: String): String? { + if (!isInitialized || disposing) return null + return try { + runBlocking(Dispatchers.IO) { player?.getString(name) } + } catch (e: Exception) { + null + } + } + + fun observeProperty(name: String, format: String) { + val p = player ?: return + if (!isInitialized) return + val fmt = when (format) { + "double" -> PropertyFormat.Double + "flag" -> PropertyFormat.Flag + "string" -> PropertyFormat.String + else -> PropertyFormat.None + } + p.observeProperty(name, fmt) + } + + fun command(args: Array) { + if (!isInitialized || disposing || args.isEmpty()) return + scope.launch { + try { + player?.command(*args) + } catch (e: Exception) { + Log.w(TAG, "command failed", e) + } + } + } + + fun setVisible(visible: Boolean) { + if (disposing) return + activity.runOnUiThread { + if (disposing) return@runOnUiThread + surfaceContainer?.visibility = if (visible) View.VISIBLE else View.INVISIBLE + if (visible) { flutterOverlayApplied = false ensureFlutterOverlayOnTop() - Log.d(TAG, "refreshVideoOutput($reason): scheduling async refresh (epoch=$refreshEpoch)") - pendingVideoOutputRefreshJob = scope.launch(Dispatchers.IO) { - try { - videoOutputMutex.withLock { - if (!isCurrentVideoOutputEpoch(refreshEpoch)) { - Log.d(TAG, "Skipping stale MPV video output refresh ($reason, epoch=$refreshEpoch)") - return@withLock - } - if (!surface.isValid) { - hasAttachedSurface = false - attachedSurface = null - attachedToPlaceholder = false - pendingSurface = null - lastAppliedSurfaceSize = null - videoOutputRestoring = true - Log.d(TAG, "Skipping MPV video output refresh with invalid surface ($reason, epoch=$refreshEpoch)") - return@withLock - } - - val needsAttach = !hasAttachedSurface || attachedSurface !== surface - val wasAttachedToPlaceholder = attachedToPlaceholder - val wasPausedForSurfaceLoss = pausedForSurfaceLoss - if (needsAttach) { - p.attachSurface(surface) - attachedSurface = surface - hasAttachedSurface = true - attachedToPlaceholder = false - pendingSurface = null - Log.d(TAG, "refreshVideoOutput($reason): attached surface") - } else { - Log.d(TAG, "refreshVideoOutput($reason): surface already attached, refreshing surface state") - } - - if (!isVideoOutputRefreshCurrent(refreshEpoch)) { - Log.d(TAG, "Skipping stale MPV video output refresh after attach ($reason, epoch=$refreshEpoch)") - return@withLock - } - applySurfaceSizeInternal(p, force = true) - if (!isVideoOutputRefreshCurrent(refreshEpoch)) { - Log.d(TAG, "Skipping stale MPV video output refresh after surface size ($reason, epoch=$refreshEpoch)") - return@withLock - } - videoOutputRestoring = false - applyDeferredResumeIfNeeded(p, reason) - if (wasPausedForSurfaceLoss) { - pausedForSurfaceLoss = false - Log.d(TAG, "Cleared surface-loss pause after $reason") - } - if (wasAttachedToPlaceholder) { - Log.d(TAG, "Restored MPV real surface after placeholder ($reason)") - } - Log.d(TAG, "Video output ready after $reason") - } - } catch (e: CancellationException) { - Log.d(TAG, "Canceled pending MPV video output refresh ($reason, epoch=$refreshEpoch)") - } catch (e: Exception) { - Log.w(TAG, "Failed to finalize MPV video output refresh ($reason)", e) - } - } - } - - private fun applySurfaceSize(width: Int, height: Int) { - val p = player ?: return - if (disposing || width <= 0 || height <= 0) return - rememberSurfaceSize(width, height) - if (!hasReadyVideoOutput()) return - scope.launch { - try { applySurfaceSizeInternal(p) } - catch (e: Exception) { Log.w(TAG, "Failed to apply surface size to MPV", e) } - } - } - - private suspend fun applySurfaceSizeInternal(p: MpvPlayer, force: Boolean = false) { - if (disposing) return - val width = lastKnownSurfaceWidth - val height = lastKnownSurfaceHeight - if (width <= 0 || height <= 0) return - - val size = "${width}x${height}" - if (!force && size == lastAppliedSurfaceSize) return - p.setProperty("android-surface-size", size) - lastAppliedSurfaceSize = size - Log.d(TAG, "Applied MPV surface size $size${if (force) " (forced)" else ""}") - } - - private fun schedulePlaceholderSurfaceAttach( - p: MpvPlayer, - reason: String, - epoch: Long - ) { - pendingVideoOutputDisableJob?.cancel() - pendingVideoOutputDisableJob = scope.launch(Dispatchers.IO) { - try { - videoOutputMutex.withLock { - if (!isCurrentVideoOutputEpoch(epoch)) { - Log.d(TAG, "Skipping stale MPV placeholder attach ($reason, epoch=$epoch)") - return@withLock - } - val wasPaused = try { - p.getFlag("pause") == true - } catch (e: Exception) { - cachedPaused - } - if (!wasPaused) { - try { - p.setProperty("pause", true) - cachedPaused = true - pausedForSurfaceLoss = true - Log.d(TAG, "Paused MPV for surface loss ($reason, epoch=$epoch)") - } catch (e: Exception) { - pausedForSurfaceLoss = false - Log.w(TAG, "Failed to pause MPV before placeholder attach ($reason)", e) - } - } else { - pausedForSurfaceLoss = false - } - val surface = placeholderSurface?.takeIf { it.isValid } ?: run { - Log.w(TAG, "No valid MPV placeholder surface available for $reason") - return@withLock - } - p.attachSurface(surface) - attachedSurface = surface - hasAttachedSurface = true - attachedToPlaceholder = true - lastAppliedSurfaceSize = null - Log.d(TAG, "Attached MPV placeholder surface ($reason, epoch=$epoch)") - } - } catch (e: CancellationException) { - Log.d(TAG, "Canceled pending MPV placeholder attach ($reason, epoch=$epoch)") - } catch (e: Exception) { - Log.w(TAG, "Failed to attach MPV placeholder surface ($reason)", e) - } - } - } - - private fun detachSurfaceInternal(reason: String) { - val hadAttachedSurface = hasAttachedSurface || attachedSurface != null - hasAttachedSurface = false - attachedSurface = null - attachedToPlaceholder = false - videoOutputRestoring = true - lastAppliedSurfaceSize = null - val detachEpoch = videoOutputEpoch + 1L - videoOutputEpoch = detachEpoch - - val p = player ?: return - if (!hadAttachedSurface) { - Log.d(TAG, "detachSurfaceInternal($reason): no attached surface to clear") - return - } - - schedulePlaceholderSurfaceAttach( - p = p, - reason = reason, - epoch = detachEpoch - ) - Log.d(TAG, "Cleared MPV surface attachment ($reason, epoch=$detachEpoch)") - } - - private fun normalizePauseValue(value: String): Boolean? = when (value.lowercase()) { - "yes", "true", "1" -> true - "no", "false", "0" -> false - else -> null - } - - private fun requestAutoResume(reason: String) { - val p = player ?: return - if (disposing) return - - if (resumeBlockedByPublicPause) { - deferredResumeRequested = false - Log.d(TAG, "Skipping auto-resume after $reason because playback is explicitly paused") - return - } - - if (!hasReadyVideoOutput()) { - deferredResumeRequested = true - Log.d(TAG, "Deferring auto-resume after $reason until video output is ready") - return - } - - scope.launch { - try { - if (p.getFlag("pause") == true) { - Log.d(TAG, "Auto-resuming playback after $reason") - p.setProperty("pause", false) - } else { - Log.d(TAG, "Skipping auto-resume after $reason because playback is already running") - } - } catch (e: Exception) { - Log.w(TAG, "Failed to resume after $reason", e) - } - } - } - - private suspend fun applyDeferredResumeIfNeeded(p: MpvPlayer, reason: String) { - if (!deferredResumeRequested) return - - if (resumeBlockedByPublicPause) { - deferredResumeRequested = false - Log.d(TAG, "Dropping deferred auto-resume after $reason because playback is explicitly paused") - return - } - - deferredResumeRequested = false - if (p.getFlag("pause") == true) { - Log.d(TAG, "Applying deferred auto-resume after $reason") - p.setProperty("pause", false) + rememberCurrentSurfaceSize() + val surface = currentCandidateSurface() + if (surface != null) { + pendingSurface = surface + refreshVideoOutput("setVisible") } else { - Log.d(TAG, "Skipping deferred auto-resume after $reason because playback is already running") + val sv = surfaceView + if (sv != null) { + applySurfaceSize(sv.width, sv.height) + } } + } + Log.d(TAG, "setVisible($visible)") } + } - // Public API + fun onPipModeChanged(isInPipMode: Boolean) { + // MPV handles aspect ratio internally via its own surface management + } - fun setProperty(name: String, value: String) { - if (!isInitialized || disposing) return - if (name == "pause") { - val paused = normalizePauseValue(value) - if (paused == true) { - cachedPaused = true - pausedForSurfaceLoss = false - resumeBlockedByPublicPause = true - deferredResumeRequested = false - Log.d(TAG, "Public pause state updated: paused=true") - } else if (paused == false) { - resumeBlockedByPublicPause = false - if (!hasReadyVideoOutput()) { - deferredResumeRequested = true - Log.d(TAG, "Deferring public resume until video output is ready") - return - } - cachedPaused = false - pausedForSurfaceLoss = false - Log.d(TAG, "Public pause state updated: paused=false") - } + fun updateFrame() { + if (disposing) return + activity.runOnUiThread { + if (disposing) return@runOnUiThread + flutterOverlayApplied = false + ensureFlutterOverlayOnTop() + rememberCurrentSurfaceSize() + val p = player + if (p == null) { + Log.d(TAG, "updateFrame(): skipping Android MPV surface refresh because player is not ready") + return@runOnUiThread + } + if (!hasReadyVideoOutput()) { + val surface = currentCandidateSurface() + if (surface != null) { + pendingSurface = surface + refreshVideoOutput("updateFrame") + } else { + Log.d(TAG, "updateFrame(): skipping Android MPV surface refresh because no surface is attached") } - scope.launch { - try { player?.setProperty(name, value) } - catch (e: Exception) { Log.w(TAG, "setProperty($name) failed", e) } - } - } - - fun getProperty(name: String): String? { - if (!isInitialized || disposing) return null - return try { - runBlocking(Dispatchers.IO) { player?.getString(name) } + return@runOnUiThread + } + scope.launch { + try { + applySurfaceSizeInternal(p, force = true) } catch (e: Exception) { - null + Log.w(TAG, "Failed to update Android MPV surface frame", e) } + } + } + } + + // Frame Rate Matching + + fun setVideoFrameRate( + fps: Float, + videoDurationMs: Long, + extraDelayMs: Long, + onComplete: (switched: Boolean) -> Unit + ) { + val mgr = frameRateManager + if (mgr == null) { + onComplete(false) + return + } + mgr.setVideoFrameRate(fps, videoDurationMs, surfaceView?.holder?.surface, extraDelayMs, onComplete) + } + + fun clearVideoFrameRate() { + frameRateManager?.clearVideoFrameRate() + } + + // Cleanup + + fun dispose(onComplete: (() -> Unit)? = null) { + if (disposing) { + onComplete?.invoke() + return + } + disposing = true + check(Looper.myLooper() == Looper.getMainLooper()) + Log.d(TAG, "Disposing") + + handler.removeCallbacksAndMessages(null) + + // Clean up frame rate and audio focus + frameRateManager?.clearVideoFrameRate() + frameRateManager = null + audioFocusManager?.release() + audioFocusManager = null + + // Cancel all coroutines + scope.cancel() + pendingVideoOutputDisableJob?.cancel() + pendingVideoOutputDisableJob = null + pendingVideoOutputRefreshJob?.cancel() + pendingVideoOutputRefreshJob = null + + // Clear surface state flags (no native calls on main thread to avoid ANR) + val p = player + if (p != null) { + hasAttachedSurface = false + attachedSurface = null + pausedForSurfaceLoss = false + attachedToPlaceholder = false + videoOutputRestoring = false + lastAppliedSurfaceSize = null + videoOutputEpoch += 1L } - fun observeProperty(name: String, format: String) { - val p = player ?: return - if (!isInitialized) return - val fmt = when (format) { - "double" -> PropertyFormat.Double - "flag" -> PropertyFormat.Flag - "string" -> PropertyFormat.String - else -> PropertyFormat.None - } - p.observeProperty(name, fmt) - } + // Capture locals for deferred cleanup + val sv = surfaceView + val container = surfaceContainer + val contentView = activity.findViewById(android.R.id.content) - fun command(args: Array) { - if (!isInitialized || disposing || args.isEmpty()) return - scope.launch { - try { player?.command(*args) } - catch (e: Exception) { Log.w(TAG, "command failed", e) } - } - } + surfaceContainer = null + surfaceView = null - fun setVisible(visible: Boolean) { - if (disposing) return - activity.runOnUiThread { - if (disposing) return@runOnUiThread - surfaceContainer?.visibility = if (visible) View.VISIBLE else View.INVISIBLE - if (visible) { - flutterOverlayApplied = false - ensureFlutterOverlayOnTop() - rememberCurrentSurfaceSize() - val surface = currentCandidateSurface() - if (surface != null) { - pendingSurface = surface - refreshVideoOutput("setVisible") - } else { - val sv = surfaceView - if (sv != null) { - applySurfaceSize(sv.width, sv.height) - } - } + // Remove layout listener synchronously + overlayLayoutListener?.let { listener -> + contentView.viewTreeObserver.removeOnGlobalLayoutListener(listener) + } + overlayLayoutListener = null + + pendingSurface = null + placeholderSurface?.release() + placeholderSurface = null + placeholderImageReader?.close() + placeholderImageReader = null + pausedForSurfaceLoss = false + attachedToPlaceholder = false + videoOutputRestoring = false + deferredResumeRequested = false + resumeBlockedByPublicPause = false + videoOutputEpoch = 0L + pendingVideoOutputDisableJob = null + isInitialized = false + + // Detach surface and close player on background thread, then remove views + if (p != null) { + Thread { + try { + // Detach surface BEFORE close to prevent GPU mutex contention with view removal + try { + runBlocking { + p.setProperty("force-window", "no") + p.setProperty("vo", "null") } - Log.d(TAG, "setVisible($visible)") + p.detachSurface() + } catch (e: Exception) { + Log.w(TAG, "Failed to detach surface during dispose", e) + } + p.close() + } catch (e: Exception) { + Log.w(TAG, "MPV close failed", e) } + player = null + Log.d(TAG, "Disposed (native)") + Handler(Looper.getMainLooper()).post { + sv?.holder?.removeCallback(this) + if (container?.parent != null) { + contentView.removeView(container) + } + onComplete?.invoke() + } + }.start() + } else { + // No player — safe to remove views immediately + Handler(Looper.getMainLooper()).postAtFrontOfQueue { + sv?.holder?.removeCallback(this) + if (container?.parent != null) { + contentView.removeView(container) + } + } + onComplete?.invoke() } - fun onPipModeChanged(isInPipMode: Boolean) { - // MPV handles aspect ratio internally via its own surface management - } - - fun updateFrame() { - if (disposing) return - activity.runOnUiThread { - if (disposing) return@runOnUiThread - flutterOverlayApplied = false - ensureFlutterOverlayOnTop() - rememberCurrentSurfaceSize() - val p = player - if (p == null) { - Log.d(TAG, "updateFrame(): skipping Android MPV surface refresh because player is not ready") - return@runOnUiThread - } - if (!hasReadyVideoOutput()) { - val surface = currentCandidateSurface() - if (surface != null) { - pendingSurface = surface - refreshVideoOutput("updateFrame") - } else { - Log.d(TAG, "updateFrame(): skipping Android MPV surface refresh because no surface is attached") - } - return@runOnUiThread - } - scope.launch { - try { - applySurfaceSizeInternal(p, force = true) - } catch (e: Exception) { - Log.w(TAG, "Failed to update Android MPV surface frame", e) - } - } - } - } - - // Frame Rate Matching - - fun setVideoFrameRate( - fps: Float, - videoDurationMs: Long, - extraDelayMs: Long, - onComplete: (switched: Boolean) -> Unit, - ) { - val mgr = frameRateManager - if (mgr == null) { - onComplete(false) - return - } - mgr.setVideoFrameRate(fps, videoDurationMs, surfaceView?.holder?.surface, extraDelayMs, onComplete) - } - - fun clearVideoFrameRate() { - frameRateManager?.clearVideoFrameRate() - } - - // Cleanup - - fun dispose(onComplete: (() -> Unit)? = null) { - if (disposing) { - onComplete?.invoke() - return - } - disposing = true - check(Looper.myLooper() == Looper.getMainLooper()) - Log.d(TAG, "Disposing") - - handler.removeCallbacksAndMessages(null) - - // Clean up frame rate and audio focus - frameRateManager?.clearVideoFrameRate() - frameRateManager = null - audioFocusManager?.release() - audioFocusManager = null - - // Cancel all coroutines - scope.cancel() - pendingVideoOutputDisableJob?.cancel() - pendingVideoOutputDisableJob = null - pendingVideoOutputRefreshJob?.cancel() - pendingVideoOutputRefreshJob = null - - // Clear surface state flags (no native calls on main thread to avoid ANR) - val p = player - if (p != null) { - hasAttachedSurface = false - attachedSurface = null - pausedForSurfaceLoss = false - attachedToPlaceholder = false - videoOutputRestoring = false - lastAppliedSurfaceSize = null - videoOutputEpoch += 1L - } - - // Capture locals for deferred cleanup - val sv = surfaceView - val container = surfaceContainer - val contentView = activity.findViewById(android.R.id.content) - - surfaceContainer = null - surfaceView = null - - // Remove layout listener synchronously - overlayLayoutListener?.let { listener -> - contentView.viewTreeObserver.removeOnGlobalLayoutListener(listener) - } - overlayLayoutListener = null - - pendingSurface = null - placeholderSurface?.release() - placeholderSurface = null - placeholderImageReader?.close() - placeholderImageReader = null - pausedForSurfaceLoss = false - attachedToPlaceholder = false - videoOutputRestoring = false - deferredResumeRequested = false - resumeBlockedByPublicPause = false - videoOutputEpoch = 0L - pendingVideoOutputDisableJob = null - isInitialized = false - - // Detach surface and close player on background thread, then remove views - if (p != null) { - Thread { - try { - // Detach surface BEFORE close to prevent GPU mutex contention with view removal - try { - runBlocking { - p.setProperty("force-window", "no") - p.setProperty("vo", "null") - } - p.detachSurface() - } catch (e: Exception) { - Log.w(TAG, "Failed to detach surface during dispose", e) - } - p.close() - } catch (e: Exception) { - Log.w(TAG, "MPV close failed", e) - } - player = null - Log.d(TAG, "Disposed (native)") - Handler(Looper.getMainLooper()).post { - sv?.holder?.removeCallback(this) - if (container?.parent != null) { - contentView.removeView(container) - } - onComplete?.invoke() - } - }.start() - } else { - // No player — safe to remove views immediately - Handler(Looper.getMainLooper()).postAtFrontOfQueue { - sv?.holder?.removeCallback(this) - if (container?.parent != null) { - contentView.removeView(container) - } - } - onComplete?.invoke() - } - - // Reset scope for potential re-initialization - scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) - } + // Reset scope for potential re-initialization + scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + } } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt index 97c3e91d..f1a7c3b8 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt @@ -10,365 +10,370 @@ import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel -class MpvPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, - EventChannel.StreamHandler, ActivityAware, com.edde746.plezy.shared.PlayerDelegate { +class MpvPlayerPlugin : + FlutterPlugin, + MethodChannel.MethodCallHandler, + EventChannel.StreamHandler, + ActivityAware, + com.edde746.plezy.shared.PlayerDelegate { - companion object { - private const val TAG = "MpvPlayerPlugin" - private const val METHOD_CHANNEL = "com.plezy/mpv_player" - private const val EVENT_CHANNEL = "com.plezy/mpv_player/events" + companion object { + private const val TAG = "MpvPlayerPlugin" + private const val METHOD_CHANNEL = "com.plezy/mpv_player" + private const val EVENT_CHANNEL = "com.plezy/mpv_player/events" + } + + private lateinit var methodChannel: MethodChannel + private lateinit var eventChannel: EventChannel + private var eventSink: EventChannel.EventSink? = null + private var playerCore: MpvPlayerCore? = null + private var activity: Activity? = null + private var activityBinding: ActivityPluginBinding? = null + private val nameToId = mutableMapOf() + private var sessionGeneration = 0 + + // Pending `MethodChannel.Result`s for an init that is currently in flight. + // Concurrent `invoke('initialize')` calls share the same outcome instead + // of each tearing down the in-flight core and starting their own — which + // was the root cause of #930. + private val pendingInitResults = mutableListOf() + + @Volatile private var isInitializing = false + + // FlutterPlugin + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL) + methodChannel.setMethodCallHandler(this) + + eventChannel = EventChannel(binding.binaryMessenger, EVENT_CHANNEL) + eventChannel.setStreamHandler(this) + + Log.d(TAG, "Attached to engine") + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + methodChannel.setMethodCallHandler(null) + eventChannel.setStreamHandler(null) + Log.d(TAG, "Detached from engine") + } + + // ActivityAware + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + activity = binding.activity + activityBinding = binding + Log.d(TAG, "Attached to activity") + } + + override fun onDetachedFromActivity() { + ++sessionGeneration + playerCore?.dispose() + playerCore = null + // Any in-flight init callback would never fire (its scope is cancelled + // by dispose), so close out queued callers explicitly. + completePendingInits(success = false) + activity = null + activityBinding = null + Log.d(TAG, "Detached from activity") + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + activity = binding.activity + activityBinding = binding + Log.d(TAG, "Reattached to activity for config changes") + } + + override fun onDetachedFromActivityForConfigChanges() { + activity = null + activityBinding = null + Log.d(TAG, "Detached from activity for config changes") + } + + // EventChannel.StreamHandler + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + eventSink = events + Log.d(TAG, "Event stream connected") + } + + override fun onCancel(arguments: Any?) { + eventSink = null + Log.d(TAG, "Event stream disconnected") + } + + // MethodChannel.MethodCallHandler + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "initialize" -> handleInitialize(result) + "dispose" -> handleDispose(result) + "setProperty" -> handleSetProperty(call, result) + "getProperty" -> handleGetProperty(call, result) + "observeProperty" -> handleObserveProperty(call, result) + "command" -> handleCommand(call, result) + "setVisible" -> handleSetVisible(call, result) + "updateFrame" -> handleUpdateFrame(result) + "setVideoFrameRate" -> handleSetVideoFrameRate(call, result) + "clearVideoFrameRate" -> handleClearVideoFrameRate(result) + "requestAudioFocus" -> handleRequestAudioFocus(result) + "abandonAudioFocus" -> handleAbandonAudioFocus(result) + "openContentFd" -> handleOpenContentFd(call, result) + "isInitialized" -> result.success(playerCore?.isInitialized ?: false) + "setLogLevel" -> result.success(null) + else -> result.notImplemented() + } + } + + private fun handleInitialize(result: MethodChannel.Result) { + val currentActivity = activity + if (currentActivity == null) { + result.error("NO_ACTIVITY", "Activity not available", null) + return } - private lateinit var methodChannel: MethodChannel - private lateinit var eventChannel: EventChannel - private var eventSink: EventChannel.EventSink? = null - private var playerCore: MpvPlayerCore? = null - private var activity: Activity? = null - private var activityBinding: ActivityPluginBinding? = null - private val nameToId = mutableMapOf() - private var sessionGeneration = 0 - - // Pending `MethodChannel.Result`s for an init that is currently in flight. - // Concurrent `invoke('initialize')` calls share the same outcome instead - // of each tearing down the in-flight core and starting their own — which - // was the root cause of #930. - private val pendingInitResults = mutableListOf() - @Volatile private var isInitializing = false - - // FlutterPlugin - - override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { - methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL) - methodChannel.setMethodCallHandler(this) - - eventChannel = EventChannel(binding.binaryMessenger, EVENT_CHANNEL) - eventChannel.setStreamHandler(this) - - Log.d(TAG, "Attached to engine") + if (playerCore?.isInitialized == true) { + Log.d(TAG, "Already initialized") + result.success(true) + return } - override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { - methodChannel.setMethodCallHandler(null) - eventChannel.setStreamHandler(null) - Log.d(TAG, "Detached from engine") + // Coalesce concurrent inits: the second caller waits for the first + // call's outcome instead of disposing the in-flight core. The Dart + // side memoizes too, but this is defense in depth for any direct + // `invoke('initialize')` that bypasses _ensureInitialized. + synchronized(pendingInitResults) { + pendingInitResults += result + if (isInitializing) { + Log.d(TAG, "Init already in flight, queuing caller") + return + } + isInitializing = true } - // ActivityAware - - override fun onAttachedToActivity(binding: ActivityPluginBinding) { - activity = binding.activity - activityBinding = binding - Log.d(TAG, "Attached to activity") - } - - override fun onDetachedFromActivity() { - ++sessionGeneration - playerCore?.dispose() - playerCore = null - // Any in-flight init callback would never fire (its scope is cancelled - // by dispose), so close out queued callers explicitly. - completePendingInits(success = false) - activity = null - activityBinding = null - Log.d(TAG, "Detached from activity") - } - - override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { - activity = binding.activity - activityBinding = binding - Log.d(TAG, "Reattached to activity for config changes") - } - - override fun onDetachedFromActivityForConfigChanges() { - activity = null - activityBinding = null - Log.d(TAG, "Detached from activity for config changes") - } - - // EventChannel.StreamHandler - - override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { - eventSink = events - Log.d(TAG, "Event stream connected") - } - - override fun onCancel(arguments: Any?) { - eventSink = null - Log.d(TAG, "Event stream disconnected") - } - - // MethodChannel.MethodCallHandler - - override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { - when (call.method) { - "initialize" -> handleInitialize(result) - "dispose" -> handleDispose(result) - "setProperty" -> handleSetProperty(call, result) - "getProperty" -> handleGetProperty(call, result) - "observeProperty" -> handleObserveProperty(call, result) - "command" -> handleCommand(call, result) - "setVisible" -> handleSetVisible(call, result) - "updateFrame" -> handleUpdateFrame(result) - "setVideoFrameRate" -> handleSetVideoFrameRate(call, result) - "clearVideoFrameRate" -> handleClearVideoFrameRate(result) - "requestAudioFocus" -> handleRequestAudioFocus(result) - "abandonAudioFocus" -> handleAbandonAudioFocus(result) - "openContentFd" -> handleOpenContentFd(call, result) - "isInitialized" -> result.success(playerCore?.isInitialized ?: false) - "setLogLevel" -> result.success(null) - else -> result.notImplemented() - } - } - - private fun handleInitialize(result: MethodChannel.Result) { - val currentActivity = activity - if (currentActivity == null) { - result.error("NO_ACTIVITY", "Activity not available", null) - return + currentActivity.runOnUiThread { + val gen: Int + val core: MpvPlayerCore + try { + // Caller invariant: dispose() was already called explicitly, + // OR `playerCore?.isInitialized == true` and we early-exited + // above. We never tear down a core that's mid-initialization. + if (playerCore != null && playerCore?.isInitialized != true) { + Log.w(TAG, "Discarding stale uninitialized core before re-init") + playerCore?.dispose() + playerCore = null } - if (playerCore?.isInitialized == true) { - Log.d(TAG, "Already initialized") - result.success(true) - return + gen = ++sessionGeneration + core = MpvPlayerCore(currentActivity).apply { + delegate = this@MpvPlayerPlugin } + playerCore = core + } catch (e: Exception) { + Log.e(TAG, "Failed to initialize: ${e.message}", e) + completePendingInits(success = false, errorMessage = e.message) + return@runOnUiThread + } - // Coalesce concurrent inits: the second caller waits for the first - // call's outcome instead of disposing the in-flight core. The Dart - // side memoizes too, but this is defense in depth for any direct - // `invoke('initialize')` that bypasses _ensureInitialized. - synchronized(pendingInitResults) { - pendingInitResults += result - if (isInitializing) { - Log.d(TAG, "Init already in flight, queuing caller") - return - } - isInitializing = true - } - - currentActivity.runOnUiThread { - val gen: Int - val core: MpvPlayerCore - try { - // Caller invariant: dispose() was already called explicitly, - // OR `playerCore?.isInitialized == true` and we early-exited - // above. We never tear down a core that's mid-initialization. - if (playerCore != null && playerCore?.isInitialized != true) { - Log.w(TAG, "Discarding stale uninitialized core before re-init") - playerCore?.dispose() - playerCore = null - } - - gen = ++sessionGeneration - core = MpvPlayerCore(currentActivity).apply { - delegate = this@MpvPlayerPlugin - } - playerCore = core - } catch (e: Exception) { - Log.e(TAG, "Failed to initialize: ${e.message}", e) - completePendingInits(success = false, errorMessage = e.message) - return@runOnUiThread - } - - core.initialize { success -> - val stale = gen != sessionGeneration || playerCore !== core - if (stale) { - Log.d(TAG, "Stale init callback (gen=$gen, current=$sessionGeneration)") - } else { - // Start hidden - now safe because setVisible operates on the container, - // not the SurfaceView directly (matching ExoPlayer's approach) - core.setVisible(false) - Log.d(TAG, "Initialized: $success") - } - completePendingInits(success = !stale && success) - } + core.initialize { success -> + val stale = gen != sessionGeneration || playerCore !== core + if (stale) { + Log.d(TAG, "Stale init callback (gen=$gen, current=$sessionGeneration)") + } else { + // Start hidden - now safe because setVisible operates on the container, + // not the SurfaceView directly (matching ExoPlayer's approach) + core.setVisible(false) + Log.d(TAG, "Initialized: $success") } + completePendingInits(success = !stale && success) + } } + } - private fun completePendingInits(success: Boolean, errorMessage: String? = null) { - val pending = synchronized(pendingInitResults) { - isInitializing = false - val copy = pendingInitResults.toList() - pendingInitResults.clear() - copy - } - for (r in pending) { - if (errorMessage != null) { - r.error("INIT_FAILED", errorMessage, null) - } else { - r.success(success) - } - } + private fun completePendingInits(success: Boolean, errorMessage: String? = null) { + val pending = synchronized(pendingInitResults) { + isInitializing = false + val copy = pendingInitResults.toList() + pendingInitResults.clear() + copy } - - private fun handleDispose(result: MethodChannel.Result) { - activity?.runOnUiThread { - val core = playerCore - ++sessionGeneration - playerCore = null - - // Any in-flight init callback is cancelled with the scope, so - // close out queued callers here instead of leaking them. - completePendingInits(success = false) - - core?.dispose { - Log.d(TAG, "Disposed") - result.success(null) - } ?: result.success(null) - } ?: result.success(null) + for (r in pending) { + if (errorMessage != null) { + r.error("INIT_FAILED", errorMessage, null) + } else { + r.success(success) + } } + } - private fun handleSetProperty(call: MethodCall, result: MethodChannel.Result) { - val name = call.argument("name") - val value = call.argument("value") + private fun handleDispose(result: MethodChannel.Result) { + activity?.runOnUiThread { + val core = playerCore + ++sessionGeneration + playerCore = null - if (name == null || value == null) { - result.error("INVALID_ARGS", "Missing 'name' or 'value'", null) - return - } + // Any in-flight init callback is cancelled with the scope, so + // close out queued callers here instead of leaking them. + completePendingInits(success = false) - playerCore?.setProperty(name, value) + core?.dispose { + Log.d(TAG, "Disposed") result.success(null) + } ?: result.success(null) + } ?: result.success(null) + } + + private fun handleSetProperty(call: MethodCall, result: MethodChannel.Result) { + val name = call.argument("name") + val value = call.argument("value") + + if (name == null || value == null) { + result.error("INVALID_ARGS", "Missing 'name' or 'value'", null) + return } - private fun handleGetProperty(call: MethodCall, result: MethodChannel.Result) { - val name = call.argument("name") + playerCore?.setProperty(name, value) + result.success(null) + } - if (name == null) { - result.error("INVALID_ARGS", "Missing 'name'", null) - return + private fun handleGetProperty(call: MethodCall, result: MethodChannel.Result) { + val name = call.argument("name") + + if (name == null) { + result.error("INVALID_ARGS", "Missing 'name'", null) + return + } + + val value = playerCore?.getProperty(name) + result.success(value) + } + + private fun handleObserveProperty(call: MethodCall, result: MethodChannel.Result) { + val name = call.argument("name") + val format = call.argument("format") + val id = call.argument("id") + + if (name == null || format == null || id == null) { + result.error("INVALID_ARGS", "Missing 'name', 'format', or 'id'", null) + return + } + + nameToId[name] = id + playerCore?.observeProperty(name, format) + result.success(null) + } + + private fun handleCommand(call: MethodCall, result: MethodChannel.Result) { + val args = call.argument>("args") + + if (args == null) { + result.error("INVALID_ARGS", "Missing 'args'", null) + return + } + + playerCore?.command(args.toTypedArray()) + result.success(null) + } + + private fun handleSetVisible(call: MethodCall, result: MethodChannel.Result) { + val visible = call.argument("visible") + + if (visible == null) { + result.error("INVALID_ARGS", "Missing 'visible'", null) + return + } + + playerCore?.setVisible(visible) + result.success(null) + } + + private fun handleUpdateFrame(result: MethodChannel.Result) { + playerCore?.updateFrame() + result.success(null) + } + + private fun handleSetVideoFrameRate(call: MethodCall, result: MethodChannel.Result) { + val fps = call.argument("fps")?.toFloat() ?: 0f + val duration = call.argument("duration")?.toLong() ?: 0L + val extraDelayMs = call.argument("extraDelayMs")?.toLong() ?: 0L + + Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs") + val core = playerCore + if (core == null) { + result.success(false) + return + } + core.setVideoFrameRate(fps, duration, extraDelayMs) { switched -> + result.success(switched) + } + } + + private fun handleClearVideoFrameRate(result: MethodChannel.Result) { + Log.d(TAG, "clearVideoFrameRate") + playerCore?.clearVideoFrameRate() + result.success(null) + } + + private fun handleRequestAudioFocus(result: MethodChannel.Result) { + Log.d(TAG, "requestAudioFocus") + val granted = playerCore?.requestAudioFocus() ?: false + result.success(granted) + } + + private fun handleAbandonAudioFocus(result: MethodChannel.Result) { + Log.d(TAG, "abandonAudioFocus") + playerCore?.abandonAudioFocus() + result.success(null) + } + + private fun handleOpenContentFd(call: MethodCall, result: MethodChannel.Result) { + val uriString = call.argument("uri") + if (uriString == null) { + result.error("INVALID_ARGS", "Missing 'uri'", null) + return + } + + val contentResolver = activity?.contentResolver + if (contentResolver == null) { + result.error("NO_ACTIVITY", "Activity not available", null) + return + } + + // Open file descriptor off UI thread to prevent ANR on slow storage + Thread { + try { + val uri = Uri.parse(uriString) + val pfd = contentResolver.openFileDescriptor(uri, "r") + if (pfd == null) { + activity?.runOnUiThread { + result.error("OPEN_FAILED", "Failed to open file descriptor for $uriString", null) + } + return@Thread } - val value = playerCore?.getProperty(name) - result.success(value) - } + val fd = pfd.detachFd() + Log.d(TAG, "Opened content FD $fd for $uriString") + activity?.runOnUiThread { result.success(fd) } + } catch (e: Exception) { + Log.e(TAG, "Failed to open content FD: ${e.message}", e) + activity?.runOnUiThread { result.error("OPEN_FAILED", e.message, null) } + } + }.start() + } - private fun handleObserveProperty(call: MethodCall, result: MethodChannel.Result) { - val name = call.argument("name") - val format = call.argument("format") - val id = call.argument("id") + // PlayerDelegate - if (name == null || format == null || id == null) { - result.error("INVALID_ARGS", "Missing 'name', 'format', or 'id'", null) - return - } + override fun onPropertyChange(name: String, value: Any?) { + val propId = nameToId[name] ?: return + eventSink?.success(listOf(propId, value)) + } - nameToId[name] = id - playerCore?.observeProperty(name, format) - result.success(null) - } - - private fun handleCommand(call: MethodCall, result: MethodChannel.Result) { - val args = call.argument>("args") - - if (args == null) { - result.error("INVALID_ARGS", "Missing 'args'", null) - return - } - - playerCore?.command(args.toTypedArray()) - result.success(null) - } - - private fun handleSetVisible(call: MethodCall, result: MethodChannel.Result) { - val visible = call.argument("visible") - - if (visible == null) { - result.error("INVALID_ARGS", "Missing 'visible'", null) - return - } - - playerCore?.setVisible(visible) - result.success(null) - } - - private fun handleUpdateFrame(result: MethodChannel.Result) { - playerCore?.updateFrame() - result.success(null) - } - - private fun handleSetVideoFrameRate(call: MethodCall, result: MethodChannel.Result) { - val fps = call.argument("fps")?.toFloat() ?: 0f - val duration = call.argument("duration")?.toLong() ?: 0L - val extraDelayMs = call.argument("extraDelayMs")?.toLong() ?: 0L - - Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs") - val core = playerCore - if (core == null) { - result.success(false) - return - } - core.setVideoFrameRate(fps, duration, extraDelayMs) { switched -> - result.success(switched) - } - } - - private fun handleClearVideoFrameRate(result: MethodChannel.Result) { - Log.d(TAG, "clearVideoFrameRate") - playerCore?.clearVideoFrameRate() - result.success(null) - } - - private fun handleRequestAudioFocus(result: MethodChannel.Result) { - Log.d(TAG, "requestAudioFocus") - val granted = playerCore?.requestAudioFocus() ?: false - result.success(granted) - } - - private fun handleAbandonAudioFocus(result: MethodChannel.Result) { - Log.d(TAG, "abandonAudioFocus") - playerCore?.abandonAudioFocus() - result.success(null) - } - - private fun handleOpenContentFd(call: MethodCall, result: MethodChannel.Result) { - val uriString = call.argument("uri") - if (uriString == null) { - result.error("INVALID_ARGS", "Missing 'uri'", null) - return - } - - val contentResolver = activity?.contentResolver - if (contentResolver == null) { - result.error("NO_ACTIVITY", "Activity not available", null) - return - } - - // Open file descriptor off UI thread to prevent ANR on slow storage - Thread { - try { - val uri = Uri.parse(uriString) - val pfd = contentResolver.openFileDescriptor(uri, "r") - if (pfd == null) { - activity?.runOnUiThread { - result.error("OPEN_FAILED", "Failed to open file descriptor for $uriString", null) - } - return@Thread - } - - val fd = pfd.detachFd() - Log.d(TAG, "Opened content FD $fd for $uriString") - activity?.runOnUiThread { result.success(fd) } - } catch (e: Exception) { - Log.e(TAG, "Failed to open content FD: ${e.message}", e) - activity?.runOnUiThread { result.error("OPEN_FAILED", e.message, null) } - } - }.start() - } - - // PlayerDelegate - - override fun onPropertyChange(name: String, value: Any?) { - val propId = nameToId[name] ?: return - eventSink?.success(listOf(propId, value)) - } - - override fun onEvent(name: String, data: Map?) { - val event = mutableMapOf( - "type" to "event", - "name" to name - ) - data?.let { event["data"] = it } - eventSink?.success(event) - } + override fun onEvent(name: String, data: Map?) { + val event = mutableMapOf( + "type" to "event", + "name" to name + ) + data?.let { event["data"] = it } + eventSink?.success(event) + } } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/shared/AudioFocusManager.kt b/android/app/src/main/kotlin/com/edde746/plezy/shared/AudioFocusManager.kt index c5446615..a9c387dc 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/shared/AudioFocusManager.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/shared/AudioFocusManager.kt @@ -9,97 +9,97 @@ import android.os.Handler import android.util.Log class AudioFocusManager( - context: Context, - private val handler: Handler, - private val onPause: () -> Unit, - private val onResume: () -> Unit, - private val isPaused: () -> Boolean, - private val log: (String) -> Unit = { Log.d(TAG, it) } + context: Context, + private val handler: Handler, + private val onPause: () -> Unit, + private val onResume: () -> Unit, + private val isPaused: () -> Boolean, + private val log: (String) -> Unit = { Log.d(TAG, it) } ) { - companion object { - private const val TAG = "AudioFocusManager" - } + companion object { + private const val TAG = "AudioFocusManager" + } - private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - private var audioFocusRequest: AudioFocusRequest? = null - private var hasAudioFocus: Boolean = false - var wasPlayingBeforeFocusLoss: Boolean = false - private set + private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + private var audioFocusRequest: AudioFocusRequest? = null + private var hasAudioFocus: Boolean = false + var wasPlayingBeforeFocusLoss: Boolean = false + private set - private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange -> - when (focusChange) { - AudioManager.AUDIOFOCUS_GAIN -> { - log("Focus gained") - hasAudioFocus = true - if (wasPlayingBeforeFocusLoss) { - onResume() - wasPlayingBeforeFocusLoss = false - } - } - AudioManager.AUDIOFOCUS_LOSS -> { - log("Focus lost permanently") - hasAudioFocus = false - wasPlayingBeforeFocusLoss = !isPaused() - onPause() - } - AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { - log("Focus lost transiently") - hasAudioFocus = false - wasPlayingBeforeFocusLoss = !isPaused() - onPause() - } - AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { - log("Focus lost transiently (can duck)") - } + private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange -> + when (focusChange) { + AudioManager.AUDIOFOCUS_GAIN -> { + log("Focus gained") + hasAudioFocus = true + if (wasPlayingBeforeFocusLoss) { + onResume() + wasPlayingBeforeFocusLoss = false } - } - - fun requestAudioFocus(): Boolean { - Log.d(TAG, "Requesting audio focus") - - val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) - .setAudioAttributes( - AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_MEDIA) - .setContentType(AudioAttributes.CONTENT_TYPE_MOVIE) - .build() - ) - .setOnAudioFocusChangeListener(audioFocusChangeListener, handler) - .build() - - audioFocusRequest = focusRequest - audioManager.requestAudioFocus(focusRequest) - } else { - @Suppress("DEPRECATION") - audioManager.requestAudioFocus( - audioFocusChangeListener, - AudioManager.STREAM_MUSIC, - AudioManager.AUDIOFOCUS_GAIN - ) - } - - hasAudioFocus = (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) - Log.d(TAG, "Audio focus request result: $result, granted: $hasAudioFocus") - return hasAudioFocus - } - - fun abandonAudioFocus() { - Log.d(TAG, "Abandoning audio focus") - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) } - audioFocusRequest = null - } else { - @Suppress("DEPRECATION") - audioManager.abandonAudioFocus(audioFocusChangeListener) - } - + } + AudioManager.AUDIOFOCUS_LOSS -> { + log("Focus lost permanently") hasAudioFocus = false - wasPlayingBeforeFocusLoss = false + wasPlayingBeforeFocusLoss = !isPaused() + onPause() + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { + log("Focus lost transiently") + hasAudioFocus = false + wasPlayingBeforeFocusLoss = !isPaused() + onPause() + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { + log("Focus lost transiently (can duck)") + } + } + } + + fun requestAudioFocus(): Boolean { + Log.d(TAG, "Requesting audio focus") + + val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MOVIE) + .build() + ) + .setOnAudioFocusChangeListener(audioFocusChangeListener, handler) + .build() + + audioFocusRequest = focusRequest + audioManager.requestAudioFocus(focusRequest) + } else { + @Suppress("DEPRECATION") + audioManager.requestAudioFocus( + audioFocusChangeListener, + AudioManager.STREAM_MUSIC, + AudioManager.AUDIOFOCUS_GAIN + ) } - fun release() { - abandonAudioFocus() + hasAudioFocus = (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) + Log.d(TAG, "Audio focus request result: $result, granted: $hasAudioFocus") + return hasAudioFocus + } + + fun abandonAudioFocus() { + Log.d(TAG, "Abandoning audio focus") + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) } + audioFocusRequest = null + } else { + @Suppress("DEPRECATION") + audioManager.abandonAudioFocus(audioFocusChangeListener) } + + hasAudioFocus = false + wasPlayingBeforeFocusLoss = false + } + + fun release() { + abandonAudioFocus() + } } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/shared/FlutterOverlayHelper.kt b/android/app/src/main/kotlin/com/edde746/plezy/shared/FlutterOverlayHelper.kt index 6ac44513..9ff7dbb4 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/shared/FlutterOverlayHelper.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/shared/FlutterOverlayHelper.kt @@ -9,84 +9,84 @@ import android.view.ViewGroup object FlutterOverlayHelper { - /** - * Find the top-level container holding the Flutter render surface. Returns the - * direct child of [contentView] that contains a FlutterSurfaceView/FlutterTextureView - * at any depth — `bringChildToFront` only works for direct children, so this is the - * node to pass to [configureFlutterZOrder]. - * - * Searches any depth because an app may wrap Flutter (e.g. for key-event dispatch) - * and push it below the direct-child level. - */ - fun findFlutterContainer(contentView: ViewGroup, excludeView: View? = null): ViewGroup? { - for (i in contentView.childCount - 1 downTo 0) { - val child = contentView.getChildAt(i) - if (child === excludeView || child !is ViewGroup) continue - if (findRenderSurface(child) != null) return child - } - return null + /** + * Find the top-level container holding the Flutter render surface. Returns the + * direct child of [contentView] that contains a FlutterSurfaceView/FlutterTextureView + * at any depth — `bringChildToFront` only works for direct children, so this is the + * node to pass to [configureFlutterZOrder]. + * + * Searches any depth because an app may wrap Flutter (e.g. for key-event dispatch) + * and push it below the direct-child level. + */ + fun findFlutterContainer(contentView: ViewGroup, excludeView: View? = null): ViewGroup? { + for (i in contentView.childCount - 1 downTo 0) { + val child = contentView.getChildAt(i) + if (child === excludeView || child !is ViewGroup) continue + if (findRenderSurface(child) != null) return child } + return null + } - private fun findRenderSurface(root: ViewGroup): View? { - for (i in 0 until root.childCount) { - val child = root.getChildAt(i) - if (child is SurfaceView || child is TextureView) return child - if (child is ViewGroup) findRenderSurface(child)?.let { return it } - } - return null + private fun findRenderSurface(root: ViewGroup): View? { + for (i in 0 until root.childCount) { + val child = root.getChildAt(i) + if (child is SurfaceView || child is TextureView) return child + if (child is ViewGroup) findRenderSurface(child)?.let { return it } } + return null + } - /** - * Apply [SurfaceView.setCompositionOrder] on API 36+; no-op on older APIs where - * the legacy [SurfaceView.setZOrderOnTop]/[SurfaceView.setZOrderMediaOverlay] - * bucket settings govern Z-order instead. - * - * The value maps directly to the SurfaceView's `mSubLayer`. Per AOSP semantics: - * `mSubLayer >= 0` (non-negative `order`) is composited above the parent window - * and skips the hole-punch in `draw()`; `mSubLayer < 0` (negative `order`) is - * behind the parent window and the SurfaceView is made visible by punching a - * transparent hole in the parent canvas. Valid range is `[-2, 2]`. Picking the - * right sign matters: any view that draws on the parent canvas (e.g. Media3's - * `CanvasSubtitleOutput` for SRT/VTT/SDH) is hidden by SurfaceViews with - * non-negative orders that sit on top of the same area. - */ - fun applyCompositionOrder(view: SurfaceView, order: Int) { - if (Build.VERSION.SDK_INT >= 36) view.compositionOrder = order - } + /** + * Apply [SurfaceView.setCompositionOrder] on API 36+; no-op on older APIs where + * the legacy [SurfaceView.setZOrderOnTop]/[SurfaceView.setZOrderMediaOverlay] + * bucket settings govern Z-order instead. + * + * The value maps directly to the SurfaceView's `mSubLayer`. Per AOSP semantics: + * `mSubLayer >= 0` (non-negative `order`) is composited above the parent window + * and skips the hole-punch in `draw()`; `mSubLayer < 0` (negative `order`) is + * behind the parent window and the SurfaceView is made visible by punching a + * transparent hole in the parent canvas. Valid range is `[-2, 2]`. Picking the + * right sign matters: any view that draws on the parent canvas (e.g. Media3's + * `CanvasSubtitleOutput` for SRT/VTT/SDH) is hidden by SurfaceViews with + * non-negative orders that sit on top of the same area. + */ + fun applyCompositionOrder(view: SurfaceView, order: Int) { + if (Build.VERSION.SDK_INT >= 36) view.compositionOrder = order + } - /** - * Configure z-ordering so the Flutter UI renders above the video/subtitle surfaces. - * - * On API 36+ the value is applied via [SurfaceView.setCompositionOrder] and maps - * directly to `mSubLayer` (see [applyCompositionOrder] for the sign semantics). - * On pre-36 SurfaceView builds the value is mapped to the legacy on-top bucket - * when positive. On TextureView builds the value is unused (view hierarchy order - * handles it). - * - * Pass a non-negative value (e.g. `1`) so Flutter renders above the parent canvas - * and lets the legacy transparent-mode `setZOrderOnTop(true)` semantics carry over. - */ - fun configureFlutterZOrder(contentView: ViewGroup, container: ViewGroup, compositionOrder: Int) { - contentView.bringChildToFront(container) - when (val surface = findRenderSurface(container)) { - is SurfaceView -> { - if (Build.VERSION.SDK_INT >= 36) { - // Clear legacy bucket hints so compositionOrder is authoritative. - // Flutter's FlutterSurfaceView sets setZOrderOnTop(true) in its - // transparent-mode constructor, which otherwise pins it to z=1. - surface.setZOrderOnTop(false) - surface.setZOrderMediaOverlay(false) - surface.compositionOrder = compositionOrder - } else { - // Pre-36 has 3 coarse sublayer buckets. Put Flutter in the on-top - // bucket so it renders above the video (default) and libass subtitle - // (media overlay) SurfaceViews. NB: setZOrderMediaOverlay overwrites - // mSubLayer internally, so don't call it here or it cancels setZOrderOnTop. - surface.setZOrderOnTop(compositionOrder > 0) - } - surface.holder.setFormat(PixelFormat.TRANSLUCENT) - } - is TextureView -> surface.isOpaque = false + /** + * Configure z-ordering so the Flutter UI renders above the video/subtitle surfaces. + * + * On API 36+ the value is applied via [SurfaceView.setCompositionOrder] and maps + * directly to `mSubLayer` (see [applyCompositionOrder] for the sign semantics). + * On pre-36 SurfaceView builds the value is mapped to the legacy on-top bucket + * when positive. On TextureView builds the value is unused (view hierarchy order + * handles it). + * + * Pass a non-negative value (e.g. `1`) so Flutter renders above the parent canvas + * and lets the legacy transparent-mode `setZOrderOnTop(true)` semantics carry over. + */ + fun configureFlutterZOrder(contentView: ViewGroup, container: ViewGroup, compositionOrder: Int) { + contentView.bringChildToFront(container) + when (val surface = findRenderSurface(container)) { + is SurfaceView -> { + if (Build.VERSION.SDK_INT >= 36) { + // Clear legacy bucket hints so compositionOrder is authoritative. + // Flutter's FlutterSurfaceView sets setZOrderOnTop(true) in its + // transparent-mode constructor, which otherwise pins it to z=1. + surface.setZOrderOnTop(false) + surface.setZOrderMediaOverlay(false) + surface.compositionOrder = compositionOrder + } else { + // Pre-36 has 3 coarse sublayer buckets. Put Flutter in the on-top + // bucket so it renders above the video (default) and libass subtitle + // (media overlay) SurfaceViews. NB: setZOrderMediaOverlay overwrites + // mSubLayer internally, so don't call it here or it cancels setZOrderOnTop. + surface.setZOrderOnTop(compositionOrder > 0) } + surface.holder.setFormat(PixelFormat.TRANSLUCENT) + } + is TextureView -> surface.isOpaque = false } + } } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/shared/FrameRateManager.kt b/android/app/src/main/kotlin/com/edde746/plezy/shared/FrameRateManager.kt index fff3d49e..fd2d109a 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/shared/FrameRateManager.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/shared/FrameRateManager.kt @@ -13,267 +13,269 @@ import java.math.BigDecimal import java.math.RoundingMode class FrameRateManager( - private val activity: Activity, - private val handler: Handler, - private val log: (String) -> Unit = { Log.d(TAG, it) } + private val activity: Activity, + private val handler: Handler, + private val log: (String) -> Unit = { Log.d(TAG, it) } ) { - companion object { - private const val TAG = "FrameRateManager" - private const val SHORT_VIDEO_LENGTH_MS = 300000L // 5 minutes - private const val DISPLAY_SETTLE_MS = 2000L - private const val WATCHDOG_MARGIN_MS = 3000L + companion object { + private const val TAG = "FrameRateManager" + private const val SHORT_VIDEO_LENGTH_MS = 300000L // 5 minutes + private const val DISPLAY_SETTLE_MS = 2000L + private const val WATCHDOG_MARGIN_MS = 3000L + } + + private var currentVideoFps: Float = 0f + private var displayListener: DisplayManager.DisplayListener? = null + private var pendingSettleRunnable: Runnable? = null + private var watchdogRunnable: Runnable? = null + private var pendingCompletion: ((switched: Boolean) -> Unit)? = null + + private fun getDisplayManager(): DisplayManager = activity.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager + + // / Request a display frame-rate switch. Invokes [onComplete] once, either: + // / - immediately with `switched=false` when no switch is needed (invalid + // / fps, no matching mode, seamless fallback); or + // / - after the real DisplayListener event + [DISPLAY_SETTLE_MS] + the + // / caller's [extraDelayMs], with `switched=true`; or + // / - via a watchdog with `switched=true` if the real event never arrives, + // / so the caller doesn't hang. + // / + // / The caller is responsible for pausing playback before calling and + // / resuming it after [onComplete] fires. + fun setVideoFrameRate( + fps: Float, + videoDurationMs: Long, + surface: Surface?, + extraDelayMs: Long, + onComplete: (switched: Boolean) -> Unit + ) { + currentVideoFps = fps + if (fps <= 0f) { + Log.d(TAG, "setVideoFrameRate: Invalid fps ($fps), skipping") + onComplete(false) + return } - private var currentVideoFps: Float = 0f - private var displayListener: DisplayManager.DisplayListener? = null - private var pendingSettleRunnable: Runnable? = null - private var watchdogRunnable: Runnable? = null - private var pendingCompletion: ((switched: Boolean) -> Unit)? = null + log("fps=$fps, duration=${videoDurationMs}ms, extraDelayMs=$extraDelayMs, API=${Build.VERSION.SDK_INT}") - private fun getDisplayManager(): DisplayManager { - return activity.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + if (surface == null) { + Log.d(TAG, "setVideoFrameRate: Surface not available") + onComplete(false) + return + } + setFrameRateS(fps, surface, videoDurationMs, extraDelayMs, onComplete) + } + // API R's Surface.setFrameRate() only supports seamless switching (no + // CHANGE_FRAME_RATE_ALWAYS), so 60→24Hz won't switch. Fall through to + // preferredDisplayModeId which directly sets the display mode. + Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> setFrameRateM(fps, extraDelayMs, onComplete) + else -> onComplete(false) + } + } + + fun clearVideoFrameRate() { + Log.d(TAG, "clearVideoFrameRate") + currentVideoFps = 0f + // Resolve any pending setVideoFrameRate future as "not switched" so + // the Dart caller's await doesn't hang on player dispose. + firePendingCompletion("clear", switched = false) + // Restore default display mode on API M (preferredDisplayModeId persists) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + activity.window?.attributes?.let { attrs -> + attrs.preferredDisplayModeId = 0 + activity.window?.attributes = attrs + } + } + } + + private fun cancelPendingCallbacks() { + pendingSettleRunnable?.let { handler.removeCallbacks(it) } + watchdogRunnable?.let { handler.removeCallbacks(it) } + pendingSettleRunnable = null + watchdogRunnable = null + } + + private fun firePendingCompletion(reason: String, switched: Boolean) { + cancelPendingCallbacks() + displayListener?.let { + getDisplayManager().unregisterDisplayListener(it) + displayListener = null + } + val cb = pendingCompletion ?: return + pendingCompletion = null + Log.d(TAG, "FrameRateManager complete ($reason, switched=$switched)") + cb(switched) + } + + private fun registerDisplayListener(extraDelayMs: Long, onComplete: (switched: Boolean) -> Unit) { + // Resolve any previous pending op before starting a new one. + firePendingCompletion("superseded", switched = false) + pendingCompletion = onComplete + + displayListener = object : DisplayManager.DisplayListener { + override fun onDisplayAdded(displayId: Int) = Unit + override fun onDisplayRemoved(displayId: Int) = Unit + override fun onDisplayChanged(displayId: Int) { + // Unregister immediately so a chatty display (e.g. several + // onDisplayChanged events during HDMI renegotiation) doesn't + // queue multiple settle callbacks. + getDisplayManager().unregisterDisplayListener(this) + displayListener = null + + val settle = Runnable { firePendingCompletion("display settled", switched = true) } + pendingSettleRunnable = settle + handler.postDelayed(settle, DISPLAY_SETTLE_MS + extraDelayMs) + } + } + getDisplayManager().registerDisplayListener(displayListener, handler) + + // Watchdog: if the TV never signals a display change (silently ignoring + // the mode request), still complete after a bounded wait so the caller + // doesn't hang. + val watchdog = Runnable { firePendingCompletion("watchdog", switched = true) } + watchdogRunnable = watchdog + handler.postDelayed(watchdog, DISPLAY_SETTLE_MS + extraDelayMs + WATCHDOG_MARGIN_MS) + } + + private fun currentRateMatchesFps(fps: Float): Boolean { + val current = activity.display?.mode?.refreshRate ?: return false + if (current <= 0f) return false + // Treat "equal within a frame" and "clean multiple" as a match — + // same tolerance the API M matcher uses below. + if (kotlin.math.abs(current - fps) < 0.1f) return true + val mod = current % fps + return mod < 0.1f || (fps - mod) < 0.1f + } + + @RequiresApi(Build.VERSION_CODES.S) + private fun setFrameRateS( + fps: Float, + surface: Surface, + videoDurationMs: Long, + extraDelayMs: Long, + onComplete: (switched: Boolean) -> Unit + ) { + Log.d(TAG, "setFrameRateS: fps=$fps, duration=${videoDurationMs}ms") + + // If the current display rate already satisfies the video fps, issue + // the hint for book-keeping but skip the listener — otherwise we'd + // wait for an onDisplayChanged event that never fires and end up + // burning the watchdog timeout for no reason. + if (currentRateMatchesFps(fps)) { + Log.d(TAG, "Current display rate already matches ${fps}fps, no switch needed") + surface.setFrameRate( + fps, + Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, + Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS + ) + onComplete(false) + return } - /// Request a display frame-rate switch. Invokes [onComplete] once, either: - /// - immediately with `switched=false` when no switch is needed (invalid - /// fps, no matching mode, seamless fallback); or - /// - after the real DisplayListener event + [DISPLAY_SETTLE_MS] + the - /// caller's [extraDelayMs], with `switched=true`; or - /// - via a watchdog with `switched=true` if the real event never arrives, - /// so the caller doesn't hang. - /// - /// The caller is responsible for pausing playback before calling and - /// resuming it after [onComplete] fires. - fun setVideoFrameRate( - fps: Float, - videoDurationMs: Long, - surface: Surface?, - extraDelayMs: Long, - onComplete: (switched: Boolean) -> Unit, - ) { - currentVideoFps = fps - if (fps <= 0f) { - Log.d(TAG, "setVideoFrameRate: Invalid fps ($fps), skipping") - onComplete(false) - return - } - - log("fps=$fps, duration=${videoDurationMs}ms, extraDelayMs=${extraDelayMs}, API=${Build.VERSION.SDK_INT}") - - when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - if (surface == null) { - Log.d(TAG, "setVideoFrameRate: Surface not available") - onComplete(false) - return - } - setFrameRateS(fps, surface, videoDurationMs, extraDelayMs, onComplete) - } - // API R's Surface.setFrameRate() only supports seamless switching (no - // CHANGE_FRAME_RATE_ALWAYS), so 60→24Hz won't switch. Fall through to - // preferredDisplayModeId which directly sets the display mode. - Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> setFrameRateM(fps, extraDelayMs, onComplete) - else -> onComplete(false) - } + if (videoDurationMs < SHORT_VIDEO_LENGTH_MS) { + Log.d(TAG, "Short video, using seamless-only switching") + surface.setFrameRate( + fps, + Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, + Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS + ) + onComplete(false) + return } - fun clearVideoFrameRate() { - Log.d(TAG, "clearVideoFrameRate") - currentVideoFps = 0f - // Resolve any pending setVideoFrameRate future as "not switched" so - // the Dart caller's await doesn't hang on player dispose. - firePendingCompletion("clear", switched = false) - // Restore default display mode on API M (preferredDisplayModeId persists) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - activity.window?.attributes?.let { attrs -> - attrs.preferredDisplayModeId = 0 - activity.window?.attributes = attrs - } + var seamless = false + activity.display?.mode?.alternativeRefreshRates?.let { refreshRates -> + for (rate in refreshRates) { + if (fps.toString().startsWith(rate.toString()) || + rate.toString().startsWith(fps.toString()) || + rate % fps == 0f + ) { + seamless = true + break } + } } - private fun cancelPendingCallbacks() { - pendingSettleRunnable?.let { handler.removeCallbacks(it) } - watchdogRunnable?.let { handler.removeCallbacks(it) } - pendingSettleRunnable = null - watchdogRunnable = null - } - - private fun firePendingCompletion(reason: String, switched: Boolean) { - cancelPendingCallbacks() - displayListener?.let { - getDisplayManager().unregisterDisplayListener(it) - displayListener = null - } - val cb = pendingCompletion ?: return - pendingCompletion = null - Log.d(TAG, "FrameRateManager complete ($reason, switched=$switched)") - cb(switched) - } - - private fun registerDisplayListener(extraDelayMs: Long, onComplete: (switched: Boolean) -> Unit) { - // Resolve any previous pending op before starting a new one. - firePendingCompletion("superseded", switched = false) - pendingCompletion = onComplete - - displayListener = object : DisplayManager.DisplayListener { - override fun onDisplayAdded(displayId: Int) = Unit - override fun onDisplayRemoved(displayId: Int) = Unit - override fun onDisplayChanged(displayId: Int) { - // Unregister immediately so a chatty display (e.g. several - // onDisplayChanged events during HDMI renegotiation) doesn't - // queue multiple settle callbacks. - getDisplayManager().unregisterDisplayListener(this) - displayListener = null - - val settle = Runnable { firePendingCompletion("display settled", switched = true) } - pendingSettleRunnable = settle - handler.postDelayed(settle, DISPLAY_SETTLE_MS + extraDelayMs) - } - } - getDisplayManager().registerDisplayListener(displayListener, handler) - - // Watchdog: if the TV never signals a display change (silently ignoring - // the mode request), still complete after a bounded wait so the caller - // doesn't hang. - val watchdog = Runnable { firePendingCompletion("watchdog", switched = true) } - watchdogRunnable = watchdog - handler.postDelayed(watchdog, DISPLAY_SETTLE_MS + extraDelayMs + WATCHDOG_MARGIN_MS) - } - - private fun currentRateMatchesFps(fps: Float): Boolean { - val current = activity.display?.mode?.refreshRate ?: return false - if (current <= 0f) return false - // Treat "equal within a frame" and "clean multiple" as a match — - // same tolerance the API M matcher uses below. - if (kotlin.math.abs(current - fps) < 0.1f) return true - val mod = current % fps - return mod < 0.1f || (fps - mod) < 0.1f - } - - @RequiresApi(Build.VERSION_CODES.S) - private fun setFrameRateS( - fps: Float, - surface: Surface, - videoDurationMs: Long, - extraDelayMs: Long, - onComplete: (switched: Boolean) -> Unit, - ) { - Log.d(TAG, "setFrameRateS: fps=$fps, duration=${videoDurationMs}ms") - - // If the current display rate already satisfies the video fps, issue - // the hint for book-keeping but skip the listener — otherwise we'd - // wait for an onDisplayChanged event that never fires and end up - // burning the watchdog timeout for no reason. - if (currentRateMatchesFps(fps)) { - Log.d(TAG, "Current display rate already matches ${fps}fps, no switch needed") - surface.setFrameRate( - fps, - Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, - Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS - ) - onComplete(false) - return - } - - if (videoDurationMs < SHORT_VIDEO_LENGTH_MS) { - Log.d(TAG, "Short video, using seamless-only switching") - surface.setFrameRate( - fps, - Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, - Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS - ) - onComplete(false) - return - } - - var seamless = false - activity.display?.mode?.alternativeRefreshRates?.let { refreshRates -> - for (rate in refreshRates) { - if (fps.toString().startsWith(rate.toString()) || - rate.toString().startsWith(fps.toString()) || - rate % fps == 0f) { - seamless = true - break - } - } - } - - if (seamless) { - log("Seamless switch available for ${fps}fps") - surface.setFrameRate( - fps, - Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, - Surface.CHANGE_FRAME_RATE_ALWAYS - ) - registerDisplayListener(extraDelayMs, onComplete) - } else { - val userPreference = getDisplayManager().matchContentFrameRateUserPreference - if (userPreference == DisplayManager.MATCH_CONTENT_FRAMERATE_ALWAYS) { - Log.d(TAG, "User preference allows non-seamless switch") - surface.setFrameRate( - fps, - Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, - Surface.CHANGE_FRAME_RATE_ALWAYS - ) - registerDisplayListener(extraDelayMs, onComplete) - } else { - Log.d(TAG, "Non-seamless switch not allowed, using seamless-only") - surface.setFrameRate( - fps, - Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, - Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS - ) - onComplete(false) - } - } - } - - @RequiresApi(Build.VERSION_CODES.M) - private fun setFrameRateM(fps: Float, extraDelayMs: Long, onComplete: (switched: Boolean) -> Unit) { - Log.d(TAG, "setFrameRateM: fps=$fps") - val wm = activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager - @Suppress("DEPRECATION") - val display = wm.defaultDisplay - if (display == null) { - onComplete(false) - return - } - - val supportedModes = display.supportedModes - if (supportedModes == null) { - onComplete(false) - return - } - val currentMode = display.mode - var modeToUse = currentMode - - for (mode in supportedModes) { - if (mode.physicalHeight != currentMode.physicalHeight || - mode.physicalWidth != currentMode.physicalWidth) { - continue - } - - if (BigDecimal(fps.toString()).setScale(1, RoundingMode.FLOOR) == - BigDecimal(mode.refreshRate.toString()).setScale(1, RoundingMode.FLOOR)) { - modeToUse = mode - break - } else if ((mode.refreshRate % fps).let { it < 0.1f || (fps - it) < 0.1f }) { - modeToUse = mode - break - } - } - - if (modeToUse == currentMode) { - onComplete(false) - return - } - - Log.d(TAG, "Switching to mode ${modeToUse.modeId} (${modeToUse.refreshRate}Hz)") - activity.window?.attributes?.let { attrs -> - attrs.preferredDisplayModeId = modeToUse.modeId - activity.window?.attributes = attrs - } + if (seamless) { + log("Seamless switch available for ${fps}fps") + surface.setFrameRate( + fps, + Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, + Surface.CHANGE_FRAME_RATE_ALWAYS + ) + registerDisplayListener(extraDelayMs, onComplete) + } else { + val userPreference = getDisplayManager().matchContentFrameRateUserPreference + if (userPreference == DisplayManager.MATCH_CONTENT_FRAMERATE_ALWAYS) { + Log.d(TAG, "User preference allows non-seamless switch") + surface.setFrameRate( + fps, + Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, + Surface.CHANGE_FRAME_RATE_ALWAYS + ) registerDisplayListener(extraDelayMs, onComplete) + } else { + Log.d(TAG, "Non-seamless switch not allowed, using seamless-only") + surface.setFrameRate( + fps, + Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, + Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS + ) + onComplete(false) + } } + } + + @RequiresApi(Build.VERSION_CODES.M) + private fun setFrameRateM(fps: Float, extraDelayMs: Long, onComplete: (switched: Boolean) -> Unit) { + Log.d(TAG, "setFrameRateM: fps=$fps") + val wm = activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager + + @Suppress("DEPRECATION") + val display = wm.defaultDisplay + if (display == null) { + onComplete(false) + return + } + + val supportedModes = display.supportedModes + if (supportedModes == null) { + onComplete(false) + return + } + val currentMode = display.mode + var modeToUse = currentMode + + for (mode in supportedModes) { + if (mode.physicalHeight != currentMode.physicalHeight || + mode.physicalWidth != currentMode.physicalWidth + ) { + continue + } + + if (BigDecimal(fps.toString()).setScale(1, RoundingMode.FLOOR) == + BigDecimal(mode.refreshRate.toString()).setScale(1, RoundingMode.FLOOR) + ) { + modeToUse = mode + break + } else if ((mode.refreshRate % fps).let { it < 0.1f || (fps - it) < 0.1f }) { + modeToUse = mode + break + } + } + + if (modeToUse == currentMode) { + onComplete(false) + return + } + + Log.d(TAG, "Switching to mode ${modeToUse.modeId} (${modeToUse.refreshRate}Hz)") + activity.window?.attributes?.let { attrs -> + attrs.preferredDisplayModeId = modeToUse.modeId + activity.window?.attributes = attrs + } + registerDisplayListener(extraDelayMs, onComplete) + } } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/shared/PlayerDelegate.kt b/android/app/src/main/kotlin/com/edde746/plezy/shared/PlayerDelegate.kt index a847eb52..a023ac7d 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/shared/PlayerDelegate.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/shared/PlayerDelegate.kt @@ -1,6 +1,6 @@ package com.edde746.plezy.shared interface PlayerDelegate { - fun onPropertyChange(name: String, value: Any?) - fun onEvent(name: String, data: Map?) + fun onPropertyChange(name: String, value: Any?) + fun onEvent(name: String, data: Map?) } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/shared/ThemeHelper.kt b/android/app/src/main/kotlin/com/edde746/plezy/shared/ThemeHelper.kt index 584475f6..bf01098f 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/shared/ThemeHelper.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/shared/ThemeHelper.kt @@ -3,10 +3,10 @@ package com.edde746.plezy.shared import android.graphics.Color object ThemeHelper { - fun themeColor(mode: String?): Int? = when (mode) { - "oled" -> Color.BLACK - "dark" -> Color.parseColor("#0E0F12") - "light" -> Color.parseColor("#F7F7F8") - else -> null - } + fun themeColor(mode: String?): Int? = when (mode) { + "oled" -> Color.BLACK + "dark" -> Color.parseColor("#0E0F12") + "light" -> Color.parseColor("#F7F7F8") + else -> null + } } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextPlugin.kt index f7e4dd95..b4bb31d9 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextPlugin.kt @@ -16,167 +16,169 @@ import java.util.concurrent.Executors * Flutter plugin for Android TV Watch Next integration. * Syncs Plex "On Deck" content to the Android TV launcher's Watch Next row. */ -class WatchNextPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { +class WatchNextPlugin : + FlutterPlugin, + MethodChannel.MethodCallHandler { - companion object { - private const val TAG = "WatchNextPlugin" - private const val METHOD_CHANNEL = "com.plezy/watch_next" + companion object { + private const val TAG = "WatchNextPlugin" + private const val METHOD_CHANNEL = "com.plezy/watch_next" - private var pendingDeepLink: String? = null - - /** - * Parse a Watch Next deep link intent. - * Returns the content ID if this was a Watch Next intent, null otherwise. - */ - fun handleIntent(intent: Intent?): String? { - val data = intent?.data ?: return null - if (data.scheme == "plezy" && data.authority == "play") { - return data.getQueryParameter("content_id") - } - return null - } - } - - private lateinit var methodChannel: MethodChannel - private var applicationContext: Context? = null - private var watchNextProvider: WatchNextProvider? = null - private val ioExecutor by lazy { Executors.newSingleThreadExecutor() } - private val mainHandler = Handler(Looper.getMainLooper()) - - override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { - applicationContext = binding.applicationContext - watchNextProvider = WatchNextProvider(binding.applicationContext) - methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL) - methodChannel.setMethodCallHandler(this) - } - - override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { - methodChannel.setMethodCallHandler(null) - applicationContext = null - watchNextProvider = null - ioExecutor.shutdown() - } - - override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { - when (call.method) { - "isSupported" -> handleIsSupported(result) - "sync" -> handleSync(call, result) - "clear" -> handleClear(result) - "remove" -> handleRemove(call, result) - "getInitialDeepLink" -> handleGetInitialDeepLink(result) - else -> result.notImplemented() - } - } - - private fun handleIsSupported(result: MethodChannel.Result) { - val context = applicationContext - if (context == null) { - result.success(false) - return - } - result.success(context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)) - } - - private fun handleSync(call: MethodCall, result: MethodChannel.Result) { - val provider = watchNextProvider - if (provider == null) { - result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null) - return - } - - val itemsData = call.argument>>("items") - if (itemsData == null) { - result.error("INVALID_ARGS", "Missing 'items' argument", null) - return - } - - val items = itemsData.mapNotNull { parseWatchNextItem(it) } - executeOnIo(result) { provider.syncWatchNextPrograms(items) } - } - - private fun handleClear(result: MethodChannel.Result) { - val provider = watchNextProvider - if (provider == null) { - result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null) - return - } - executeOnIo(result) { provider.clearAll() } - } - - private fun handleRemove(call: MethodCall, result: MethodChannel.Result) { - val provider = watchNextProvider - if (provider == null) { - result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null) - return - } - - val contentId = call.argument("contentId") - if (contentId == null) { - result.error("INVALID_ARGS", "Missing 'contentId' argument", null) - return - } - executeOnIo(result) { provider.removeItem(contentId) } - } - - private fun executeOnIo(result: MethodChannel.Result, block: () -> Any?) { - try { - ioExecutor.execute { - try { - val value = block() - mainHandler.post { result.success(value) } - } catch (e: Exception) { - Log.e(TAG, "IO operation failed: ${e.message}", e) - mainHandler.post { result.error("IO_ERROR", e.message, null) } - } - } - } catch (e: java.util.concurrent.RejectedExecutionException) { - result.error("SHUTDOWN", "Plugin is shutting down", null) - } - } - - private fun handleGetInitialDeepLink(result: MethodChannel.Result) { - val contentId = pendingDeepLink - pendingDeepLink = null - result.success(contentId) - } - - private fun parseWatchNextItem(data: Map): WatchNextProvider.WatchNextItem? { - val contentId = data["contentId"] as? String ?: return null - val title = data["title"] as? String ?: return null - - val typeString = data["type"] as? String ?: "movie" - val type = when (typeString.lowercase()) { - "episode" -> TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE - "movie" -> TvContractCompat.WatchNextPrograms.TYPE_MOVIE - else -> TvContractCompat.WatchNextPrograms.TYPE_MOVIE - } - - return WatchNextProvider.WatchNextItem( - contentId = contentId, - title = title, - episodeTitle = data["episodeTitle"] as? String, - description = data["description"] as? String, - posterUri = data["posterUri"] as? String, - type = type, - duration = (data["duration"] as? Number)?.toLong() ?: 0L, - lastPlaybackPosition = (data["lastPlaybackPosition"] as? Number)?.toLong() ?: 0L, - lastEngagementTime = (data["lastEngagementTime"] as? Number)?.toLong() ?: System.currentTimeMillis(), - seriesTitle = data["seriesTitle"] as? String, - seasonNumber = (data["seasonNumber"] as? Number)?.toInt(), - episodeNumber = (data["episodeNumber"] as? Number)?.toInt() - ) - } + private var pendingDeepLink: String? = null /** - * Store a deep link content ID for delivery to Flutter. - * Called from MainActivity on intent receipt. + * Parse a Watch Next deep link intent. + * Returns the content ID if this was a Watch Next intent, null otherwise. */ - fun notifyDeepLink(contentId: String) { - pendingDeepLink = contentId - try { - methodChannel.invokeMethod("onWatchNextTap", mapOf("contentId" to contentId)) - } catch (e: Exception) { - Log.d(TAG, "Method channel not ready, stored as pending deep link") - } + fun handleIntent(intent: Intent?): String? { + val data = intent?.data ?: return null + if (data.scheme == "plezy" && data.authority == "play") { + return data.getQueryParameter("content_id") + } + return null } + } + + private lateinit var methodChannel: MethodChannel + private var applicationContext: Context? = null + private var watchNextProvider: WatchNextProvider? = null + private val ioExecutor by lazy { Executors.newSingleThreadExecutor() } + private val mainHandler = Handler(Looper.getMainLooper()) + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + applicationContext = binding.applicationContext + watchNextProvider = WatchNextProvider(binding.applicationContext) + methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL) + methodChannel.setMethodCallHandler(this) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + methodChannel.setMethodCallHandler(null) + applicationContext = null + watchNextProvider = null + ioExecutor.shutdown() + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "isSupported" -> handleIsSupported(result) + "sync" -> handleSync(call, result) + "clear" -> handleClear(result) + "remove" -> handleRemove(call, result) + "getInitialDeepLink" -> handleGetInitialDeepLink(result) + else -> result.notImplemented() + } + } + + private fun handleIsSupported(result: MethodChannel.Result) { + val context = applicationContext + if (context == null) { + result.success(false) + return + } + result.success(context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)) + } + + private fun handleSync(call: MethodCall, result: MethodChannel.Result) { + val provider = watchNextProvider + if (provider == null) { + result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null) + return + } + + val itemsData = call.argument>>("items") + if (itemsData == null) { + result.error("INVALID_ARGS", "Missing 'items' argument", null) + return + } + + val items = itemsData.mapNotNull { parseWatchNextItem(it) } + executeOnIo(result) { provider.syncWatchNextPrograms(items) } + } + + private fun handleClear(result: MethodChannel.Result) { + val provider = watchNextProvider + if (provider == null) { + result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null) + return + } + executeOnIo(result) { provider.clearAll() } + } + + private fun handleRemove(call: MethodCall, result: MethodChannel.Result) { + val provider = watchNextProvider + if (provider == null) { + result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null) + return + } + + val contentId = call.argument("contentId") + if (contentId == null) { + result.error("INVALID_ARGS", "Missing 'contentId' argument", null) + return + } + executeOnIo(result) { provider.removeItem(contentId) } + } + + private fun executeOnIo(result: MethodChannel.Result, block: () -> Any?) { + try { + ioExecutor.execute { + try { + val value = block() + mainHandler.post { result.success(value) } + } catch (e: Exception) { + Log.e(TAG, "IO operation failed: ${e.message}", e) + mainHandler.post { result.error("IO_ERROR", e.message, null) } + } + } + } catch (e: java.util.concurrent.RejectedExecutionException) { + result.error("SHUTDOWN", "Plugin is shutting down", null) + } + } + + private fun handleGetInitialDeepLink(result: MethodChannel.Result) { + val contentId = pendingDeepLink + pendingDeepLink = null + result.success(contentId) + } + + private fun parseWatchNextItem(data: Map): WatchNextProvider.WatchNextItem? { + val contentId = data["contentId"] as? String ?: return null + val title = data["title"] as? String ?: return null + + val typeString = data["type"] as? String ?: "movie" + val type = when (typeString.lowercase()) { + "episode" -> TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE + "movie" -> TvContractCompat.WatchNextPrograms.TYPE_MOVIE + else -> TvContractCompat.WatchNextPrograms.TYPE_MOVIE + } + + return WatchNextProvider.WatchNextItem( + contentId = contentId, + title = title, + episodeTitle = data["episodeTitle"] as? String, + description = data["description"] as? String, + posterUri = data["posterUri"] as? String, + type = type, + duration = (data["duration"] as? Number)?.toLong() ?: 0L, + lastPlaybackPosition = (data["lastPlaybackPosition"] as? Number)?.toLong() ?: 0L, + lastEngagementTime = (data["lastEngagementTime"] as? Number)?.toLong() ?: System.currentTimeMillis(), + seriesTitle = data["seriesTitle"] as? String, + seasonNumber = (data["seasonNumber"] as? Number)?.toInt(), + episodeNumber = (data["episodeNumber"] as? Number)?.toInt() + ) + } + + /** + * Store a deep link content ID for delivery to Flutter. + * Called from MainActivity on intent receipt. + */ + fun notifyDeepLink(contentId: String) { + pendingDeepLink = contentId + try { + methodChannel.invokeMethod("onWatchNextTap", mapOf("contentId" to contentId)) + } catch (e: Exception) { + Log.d(TAG, "Method channel not ready, stored as pending deep link") + } + } } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextProvider.kt b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextProvider.kt index c65558c1..1caef1e8 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextProvider.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextProvider.kt @@ -14,154 +14,151 @@ import androidx.tvprovider.media.tv.WatchNextProgram */ class WatchNextProvider(private val context: Context) { - companion object { - private const val TAG = "WatchNextProvider" - } + companion object { + private const val TAG = "WatchNextProvider" + } - data class WatchNextItem( - val contentId: String, - val title: String, - val episodeTitle: String?, - val description: String?, - val posterUri: String?, - val type: Int, - val duration: Long, - val lastPlaybackPosition: Long, - val lastEngagementTime: Long, - val seriesTitle: String?, - val seasonNumber: Int?, - val episodeNumber: Int? + data class WatchNextItem( + val contentId: String, + val title: String, + val episodeTitle: String?, + val description: String?, + val posterUri: String?, + val type: Int, + val duration: Long, + val lastPlaybackPosition: Long, + val lastEngagementTime: Long, + val seriesTitle: String?, + val seasonNumber: Int?, + val episodeNumber: Int? + ) + + /** + * Sync items to Watch Next row. + * Uses applyBatch to delete + insert in a single transaction so the + * launcher receives one content-change notification with the full set. + */ + fun syncWatchNextPrograms(items: List): Boolean = try { + val ops = ArrayList() + + ops.add( + ContentProviderOperation.newDelete( + TvContractCompat.WatchNextPrograms.CONTENT_URI + ).build() ) - /** - * Sync items to Watch Next row. - * Uses applyBatch to delete + insert in a single transaction so the - * launcher receives one content-change notification with the full set. - */ - fun syncWatchNextPrograms(items: List): Boolean { - return try { - val ops = ArrayList() + for (item in items) { + val program = buildProgram(item) + ops.add( + ContentProviderOperation.newInsert( + TvContractCompat.WatchNextPrograms.CONTENT_URI + ).withValues(program.toContentValues()).build() + ) + } - ops.add( - ContentProviderOperation.newDelete( - TvContractCompat.WatchNextPrograms.CONTENT_URI - ).build() + context.contentResolver.applyBatch(TvContractCompat.AUTHORITY, ops) + Log.d(TAG, "Synced ${items.size} Watch Next entries") + true + } catch (e: Exception) { + Log.e(TAG, "Failed to sync Watch Next programs", e) + false + } + + fun clearAll(): Boolean = try { + context.contentResolver.delete( + TvContractCompat.WatchNextPrograms.CONTENT_URI, + null, + null + ) + true + } catch (e: Exception) { + Log.e(TAG, "Failed to clear Watch Next entries", e) + false + } + + fun removeItem(contentId: String): Boolean { + return try { + val cursor = context.contentResolver.query( + TvContractCompat.WatchNextPrograms.CONTENT_URI, + arrayOf( + TvContractCompat.WatchNextPrograms._ID, + TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID + ), + null, + null, + null + ) + + cursor?.use { + val idIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms._ID) + val providerIdIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID) + + if (idIndex < 0 || providerIdIndex < 0) return false + + while (it.moveToNext()) { + if (it.getString(providerIdIndex) == contentId) { + val id = it.getLong(idIndex) + val deleteUri = ContentUris.withAppendedId( + TvContractCompat.WatchNextPrograms.CONTENT_URI, + id ) - - for (item in items) { - val program = buildProgram(item) - ops.add( - ContentProviderOperation.newInsert( - TvContractCompat.WatchNextPrograms.CONTENT_URI - ).withValues(program.toContentValues()).build() - ) - } - - context.contentResolver.applyBatch(TvContractCompat.AUTHORITY, ops) - Log.d(TAG, "Synced ${items.size} Watch Next entries") - true - } catch (e: Exception) { - Log.e(TAG, "Failed to sync Watch Next programs", e) - false + context.contentResolver.delete(deleteUri, null, null) + return true + } } + } + false + } catch (e: Exception) { + Log.e(TAG, "Failed to remove Watch Next item: $contentId", e) + false + } + } + + private fun buildProgram(item: WatchNextItem): WatchNextProgram { + val watchNextType = if (item.lastPlaybackPosition > 0) { + TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE + } else { + TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_NEXT } - fun clearAll(): Boolean { - return try { - context.contentResolver.delete( - TvContractCompat.WatchNextPrograms.CONTENT_URI, - null, - null - ) - true - } catch (e: Exception) { - Log.e(TAG, "Failed to clear Watch Next entries", e) - false - } + val builder = WatchNextProgram.Builder() + .setType(item.type) + .setWatchNextType(watchNextType) + .setTitle(item.title) + .setInternalProviderId(item.contentId) + .setLastEngagementTimeUtcMillis(item.lastEngagementTime) + + item.description?.let { builder.setDescription(it) } + + item.posterUri?.let { uri -> + try { + builder.setPosterArtUri(Uri.parse(uri)) + builder.setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9) + } catch (e: Exception) { + Log.w(TAG, "Failed to parse poster URI: $uri", e) + } } - fun removeItem(contentId: String): Boolean { - return try { - val cursor = context.contentResolver.query( - TvContractCompat.WatchNextPrograms.CONTENT_URI, - arrayOf( - TvContractCompat.WatchNextPrograms._ID, - TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID - ), - null, - null, - null - ) - - cursor?.use { - val idIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms._ID) - val providerIdIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID) - - if (idIndex < 0 || providerIdIndex < 0) return false - - while (it.moveToNext()) { - if (it.getString(providerIdIndex) == contentId) { - val id = it.getLong(idIndex) - val deleteUri = ContentUris.withAppendedId( - TvContractCompat.WatchNextPrograms.CONTENT_URI, - id - ) - context.contentResolver.delete(deleteUri, null, null) - return true - } - } - } - false - } catch (e: Exception) { - Log.e(TAG, "Failed to remove Watch Next item: $contentId", e) - false - } + if (item.duration > 0) { + builder.setDurationMillis(item.duration.toInt()) + if (item.lastPlaybackPosition > 0) { + builder.setLastPlaybackPositionMillis(item.lastPlaybackPosition.toInt()) + } } - private fun buildProgram(item: WatchNextItem): WatchNextProgram { - val watchNextType = if (item.lastPlaybackPosition > 0) - TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE - else - TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_NEXT - - val builder = WatchNextProgram.Builder() - .setType(item.type) - .setWatchNextType(watchNextType) - .setTitle(item.title) - .setInternalProviderId(item.contentId) - .setLastEngagementTimeUtcMillis(item.lastEngagementTime) - - item.description?.let { builder.setDescription(it) } - - item.posterUri?.let { uri -> - try { - builder.setPosterArtUri(Uri.parse(uri)) - builder.setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9) - } catch (e: Exception) { - Log.w(TAG, "Failed to parse poster URI: $uri", e) - } - } - - if (item.duration > 0) { - builder.setDurationMillis(item.duration.toInt()) - if (item.lastPlaybackPosition > 0) { - builder.setLastPlaybackPositionMillis(item.lastPlaybackPosition.toInt()) - } - } - - if (item.type == TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE) { - item.episodeTitle?.let { builder.setEpisodeTitle(it) } - item.seasonNumber?.let { builder.setSeasonNumber(it) } - item.episodeNumber?.let { builder.setEpisodeNumber(it) } - } - - val intentUri = Uri.Builder() - .scheme("plezy") - .authority("play") - .appendQueryParameter("content_id", item.contentId) - .build() - builder.setIntentUri(intentUri) - - return builder.build() + if (item.type == TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE) { + item.episodeTitle?.let { builder.setEpisodeTitle(it) } + item.seasonNumber?.let { builder.setSeasonNumber(it) } + item.episodeNumber?.let { builder.setEpisodeNumber(it) } } + + val intentUri = Uri.Builder() + .scheme("plezy") + .authority("play") + .appendQueryParameter("content_id", item.contentId) + .build() + builder.setIntentUri(intentUri) + + return builder.build() + } } diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 4b4cd9b0..b6bc39ff 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -125,7 +125,7 @@ SPEC CHECKSUMS: file_picker: 8fc6fe5e42585a217d44d22f79ec046cb8d81140 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 in_app_review: 7dd1ea365263f834b8464673f9df72c80c17c937 - os_media_controls: 86dceab6245a5325af90fc0fdebe243c42d789b4 + os_media_controls: 94cc278f5802b82b2d6373003aeb511f96718b27 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 Sentry: d587a8fe91ca13503ecd69a1905f3e8a0fcf61be sentry_flutter: 31101687061fb85211ebab09ce6eb8db4e9ba74f @@ -133,7 +133,7 @@ SPEC CHECKSUMS: sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 sqlite3: a51c07cf16e023d6c48abd5e5791a61a47354921 sqlite3_flutter_libs: b3e120efe9a82017e5552a620f696589ed4f62ab - universal_gamepad: e10172778a8a399cce234494968f38724974919e + universal_gamepad: 838bbb70d37d8c7c719038aa397214f2c4c4f866 url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 diff --git a/ios/Runner/MpvPlayer/MpvPipController.swift b/ios/Runner/MpvPlayer/MpvPipController.swift index e6b6b9c4..27d9a095 100644 --- a/ios/Runner/MpvPlayer/MpvPipController.swift +++ b/ios/Runner/MpvPlayer/MpvPipController.swift @@ -2,11 +2,11 @@ import AVKit import UIKit #if os(tvOS) -// tvOS stub: AVPictureInPictureController has different constraints on tvOS -// and is not supported by the Plezy flow. Provide a no-op shell so callers -// in MpvPlayerPlugin compile unchanged; isSupported reports false so PiP is -// never attempted at runtime. -protocol MpvPipDelegate: AnyObject { + // tvOS stub: AVPictureInPictureController has different constraints on tvOS + // and is not supported by the Plezy flow. Provide a no-op shell so callers + // in MpvPlayerPlugin compile unchanged; isSupported reports false so PiP is + // never attempted at runtime. + protocol MpvPipDelegate: AnyObject { func pipWillStart() func pipDidStart() func pipDidStop(restored: Bool) @@ -15,35 +15,35 @@ protocol MpvPipDelegate: AnyObject { func pipSkip(byInterval seconds: Double) var isPipPlaying: Bool { get } var pipDuration: Double { get } -} + } -class MpvPipController: NSObject { + class MpvPipController: NSObject { static var isSupported: Bool { false } weak var delegate: MpvPipDelegate? var isPipActive: Bool { false } var autoStartEnabled: Bool { false } var layerPointer: UnsafeMutableRawPointer { - // Return a dummy non-null pointer — layerPointer is handed to mpv for - // rendering into PiP, which never activates on tvOS. - UnsafeMutableRawPointer(bitPattern: 0x1)! + // Return a dummy non-null pointer — layerPointer is handed to mpv for + // rendering into PiP, which never activates on tvOS. + UnsafeMutableRawPointer(bitPattern: 0x1)! } func setup(with layer: CALayer, containerView: UIView) {} func setAutoStart(_ enabled: Bool) {} func warmLayer(currentTime: Double, isPlaying: Bool) {} func pushBlankFrame(width: Int32 = 1920, height: Int32 = 1080) {} func startPip(waitForFrame: Bool = true, completion: @escaping (Bool) -> Void) { - completion(false) + completion(false) } func stopPip() {} func invalidatePlaybackState() {} func flushLayer() {} func syncTimebase(currentTime: Double, isPlaying: Bool) {} func teardown() {} -} + } #else -/// Delegate to notify the plugin of PiP lifecycle events -protocol MpvPipDelegate: AnyObject { + /// Delegate to notify the plugin of PiP lifecycle events + protocol MpvPipDelegate: AnyObject { /// Called when PiP is about to start (system or app-initiated) func pipWillStart() func pipDidStart() @@ -58,11 +58,11 @@ protocol MpvPipDelegate: AnyObject { var isPipPlaying: Bool { get } /// Get total duration in seconds var pipDuration: Double { get } -} + } -/// Encapsulates all iOS Picture-in-Picture logic using AVSampleBufferDisplayLayer. -/// Requires iOS 15+ for the ContentSource API; on older versions, `setup()` is a no-op. -class MpvPipController: NSObject { + /// Encapsulates all iOS Picture-in-Picture logic using AVSampleBufferDisplayLayer. + /// Requires iOS 15+ for the ContentSource API; on older versions, `setup()` is a no-op. + class MpvPipController: NSObject { // MARK: - Properties @@ -73,340 +73,346 @@ class MpvPipController: NSObject { /// Pointer to the sample buffer layer for passing to mpv as `wid` var layerPointer: UnsafeMutableRawPointer { - Unmanaged.passUnretained(sampleBufferLayer).toOpaque() + Unmanaged.passUnretained(sampleBufferLayer).toOpaque() } // MARK: - Initialization override init() { - super.init() - setup() + super.init() + setup() } private func setup() { - guard #available(iOS 15.0, *) else { return } + guard #available(iOS 15.0, *) else { return } - do { - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback) - try AVAudioSession.sharedInstance().setActive(true) - } catch { - print("[MpvPipController] Failed to configure audio session: \(error)") - } + do { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback) + try AVAudioSession.sharedInstance().setActive(true) + } catch { + print("[MpvPipController] Failed to configure audio session: \(error)") + } - // The sample buffer layer must be in a visible view hierarchy for - // isPictureInPicturePossible to become true. - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first(where: { $0.isKeyWindow }) { - let view = UIView(frame: window.bounds) - view.clipsToBounds = true - view.isUserInteractionEnabled = false - sampleBufferLayer.frame = view.bounds - view.layer.addSublayer(sampleBufferLayer) - window.addSubview(view) - containerView = view - } + // The sample buffer layer must be in a visible view hierarchy for + // isPictureInPicturePossible to become true. + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first(where: { $0.isKeyWindow }) + { + let view = UIView(frame: window.bounds) + view.clipsToBounds = true + view.isUserInteractionEnabled = false + sampleBufferLayer.frame = view.bounds + view.layer.addSublayer(sampleBufferLayer) + window.addSubview(view) + containerView = view + } - createPipController() + createPipController() } /// Helper that conforms to the iOS 15+ delegate protocols private var delegateHelper: AnyObject? private func createPipController() { - guard #available(iOS 15.0, *) else { return } - let helper = PipDelegateHelper(controller: self) - let contentSource = AVPictureInPictureController.ContentSource( - sampleBufferDisplayLayer: sampleBufferLayer, - playbackDelegate: helper - ) - self.delegateHelper = helper - pipController = AVPictureInPictureController(contentSource: contentSource) - pipController?.delegate = helper - if #available(iOS 14.2, *) { - pipController?.canStartPictureInPictureAutomaticallyFromInline = false - } + guard #available(iOS 15.0, *) else { return } + let helper = PipDelegateHelper(controller: self) + let contentSource = AVPictureInPictureController.ContentSource( + sampleBufferDisplayLayer: sampleBufferLayer, + playbackDelegate: helper + ) + self.delegateHelper = helper + pipController = AVPictureInPictureController(contentSource: contentSource) + pipController?.delegate = helper + if #available(iOS 14.2, *) { + pipController?.canStartPictureInPictureAutomaticallyFromInline = false + } } /// Enable/disable system auto-PiP (starts PiP automatically on background transition) func setAutoStart(_ enabled: Bool) { - guard #available(iOS 14.2, *) else { return } - pipController?.canStartPictureInPictureAutomaticallyFromInline = enabled + guard #available(iOS 14.2, *) else { return } + pipController?.canStartPictureInPictureAutomaticallyFromInline = enabled } /// Push a black frame to the sample buffer layer so PiP has content /// to display immediately (before vo_pip decodes the first real frame). func pushBlankFrame(width: Int32 = 1920, height: Int32 = 1080) { - var pixelBuffer: CVPixelBuffer? - let attrs: [String: Any] = [ - kCVPixelBufferIOSurfacePropertiesKey as String: [:], - ] - let status = CVPixelBufferCreate( - kCFAllocatorDefault, Int(width), Int(height), - kCVPixelFormatType_32BGRA, attrs as CFDictionary, &pixelBuffer - ) - guard status == kCVReturnSuccess, let pb = pixelBuffer else { return } + var pixelBuffer: CVPixelBuffer? + let attrs: [String: Any] = [ + kCVPixelBufferIOSurfacePropertiesKey as String: [:] + ] + let status = CVPixelBufferCreate( + kCFAllocatorDefault, Int(width), Int(height), + kCVPixelFormatType_32BGRA, attrs as CFDictionary, &pixelBuffer + ) + guard status == kCVReturnSuccess, let pb = pixelBuffer else { return } - // Fill with black - CVPixelBufferLockBaseAddress(pb, []) - if let base = CVPixelBufferGetBaseAddress(pb) { - memset(base, 0, CVPixelBufferGetDataSize(pb)) - } - CVPixelBufferUnlockBaseAddress(pb, []) + // Fill with black + CVPixelBufferLockBaseAddress(pb, []) + if let base = CVPixelBufferGetBaseAddress(pb) { + memset(base, 0, CVPixelBufferGetDataSize(pb)) + } + CVPixelBufferUnlockBaseAddress(pb, []) - // Use current timebase time for PTS (if available) so the frame isn't stale - let pts: CMTime - if let tb = sampleBufferLayer.controlTimebase { - pts = CMTimebaseGetTime(tb) - } else { - pts = CMTime(value: 0, timescale: 30) - } + // Use current timebase time for PTS (if available) so the frame isn't stale + let pts: CMTime + if let tb = sampleBufferLayer.controlTimebase { + pts = CMTimebaseGetTime(tb) + } else { + pts = CMTime(value: 0, timescale: 30) + } - var timing = CMSampleTimingInfo( - duration: CMTime(value: 1, timescale: 30), - presentationTimeStamp: pts, - decodeTimeStamp: .invalid - ) + var timing = CMSampleTimingInfo( + duration: CMTime(value: 1, timescale: 30), + presentationTimeStamp: pts, + decodeTimeStamp: .invalid + ) - // Create format description and sample buffer - var formatDesc: CMVideoFormatDescription? - CMVideoFormatDescriptionCreateForImageBuffer( - allocator: kCFAllocatorDefault, - imageBuffer: pb, - formatDescriptionOut: &formatDesc - ) - guard let fmt = formatDesc else { return } + // Create format description and sample buffer + var formatDesc: CMVideoFormatDescription? + CMVideoFormatDescriptionCreateForImageBuffer( + allocator: kCFAllocatorDefault, + imageBuffer: pb, + formatDescriptionOut: &formatDesc + ) + guard let fmt = formatDesc else { return } - var sampleBuffer: CMSampleBuffer? - CMSampleBufferCreateReadyWithImageBuffer( - allocator: kCFAllocatorDefault, - imageBuffer: pb, - formatDescription: fmt, - sampleTiming: &timing, - sampleBufferOut: &sampleBuffer - ) - guard let sb = sampleBuffer else { return } + var sampleBuffer: CMSampleBuffer? + CMSampleBufferCreateReadyWithImageBuffer( + allocator: kCFAllocatorDefault, + imageBuffer: pb, + formatDescription: fmt, + sampleTiming: &timing, + sampleBufferOut: &sampleBuffer + ) + guard let sb = sampleBuffer else { return } - // Set DisplayImmediately so it shows regardless of timebase timing - if let attachments = CMSampleBufferGetSampleAttachmentsArray(sb, createIfNecessary: true) as? [NSMutableDictionary], - let dict = attachments.first { - dict[kCMSampleAttachmentKey_DisplayImmediately] = true - } + // Set DisplayImmediately so it shows regardless of timebase timing + if let attachments = CMSampleBufferGetSampleAttachmentsArray( + sb, createIfNecessary: true) as? [NSMutableDictionary], + let dict = attachments.first + { + dict[kCMSampleAttachmentKey_DisplayImmediately] = true + } - sampleBufferLayer.enqueue(sb) + sampleBufferLayer.enqueue(sb) } /// Sync the layer's controlTimebase with the actual playback position. /// This makes the PiP progress bar show the correct time. func syncTimebase(currentTime: Double, isPlaying: Bool) { - guard let timebase = sampleBufferLayer.controlTimebase else { return } - let cmTime = CMTime(seconds: currentTime, preferredTimescale: 1000) - CMTimebaseSetTime(timebase, time: cmTime) - CMTimebaseSetRate(timebase, rate: isPlaying ? 1.0 : 0.0) + guard let timebase = sampleBufferLayer.controlTimebase else { return } + let cmTime = CMTime(seconds: currentTime, preferredTimescale: 1000) + CMTimebaseSetTime(timebase, time: cmTime) + CMTimebaseSetRate(timebase, rate: isPlaying ? 1.0 : 0.0) } /// Ensure the layer has a timebase and blank frame so the system considers /// PiP possible (required for canStartPictureInPictureAutomaticallyFromInline). func warmLayer(currentTime: Double, isPlaying: Bool) { - if sampleBufferLayer.controlTimebase == nil { - var timebase: CMTimebase? - CMTimebaseCreateWithSourceClock( - allocator: kCFAllocatorDefault, - sourceClock: CMClockGetHostTimeClock(), - timebaseOut: &timebase - ) - if let tb = timebase { - sampleBufferLayer.controlTimebase = tb - } + if sampleBufferLayer.controlTimebase == nil { + var timebase: CMTimebase? + CMTimebaseCreateWithSourceClock( + allocator: kCFAllocatorDefault, + sourceClock: CMClockGetHostTimeClock(), + timebaseOut: &timebase + ) + if let tb = timebase { + sampleBufferLayer.controlTimebase = tb } - syncTimebase(currentTime: currentTime, isPlaying: isPlaying) - pushBlankFrame() + } + syncTimebase(currentTime: currentTime, isPlaying: isPlaying) + pushBlankFrame() } // MARK: - Public API static var isSupported: Bool { - guard #available(iOS 15.0, *) else { return false } - return AVPictureInPictureController.isPictureInPictureSupported() + guard #available(iOS 15.0, *) else { return false } + return AVPictureInPictureController.isPictureInPictureSupported() } /// Start PiP. When `waitForFrame` is false (auto-PiP), skips the frame /// readiness check since the scene is about to deactivate. func startPip(waitForFrame: Bool = true, completion: @escaping (Bool) -> Void) { - guard let pipController = pipController else { - completion(false) - return + guard let pipController = pipController else { + completion(false) + return + } + + var attempts = 0 + func tryStart() { + let possible = pipController.isPictureInPicturePossible + let hasTimebase = sampleBufferLayer.controlTimebase != nil + + let hasFrame: Bool + if !waitForFrame { + hasFrame = true // Skip frame check for auto-PiP + } else if #available(iOS 17.4, *) { + hasFrame = sampleBufferLayer.isReadyForDisplay + } else { + hasFrame = true } - var attempts = 0 - func tryStart() { - let possible = pipController.isPictureInPicturePossible - let hasTimebase = sampleBufferLayer.controlTimebase != nil - - let hasFrame: Bool - if !waitForFrame { - hasFrame = true // Skip frame check for auto-PiP - } else if #available(iOS 17.4, *) { - hasFrame = sampleBufferLayer.isReadyForDisplay - } else { - hasFrame = true - } - - if possible && hasTimebase && hasFrame { - print("[MpvPipController] vo_pip ready after \(attempts) retries, starting PiP") - pipController.startPictureInPicture() - completion(true) - } else if attempts < 40 { - attempts += 1 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { tryStart() } - } else { - print("[MpvPipController] PiP not ready after \(attempts) retries (possible=\(possible), timebase=\(hasTimebase))") - completion(false) - } + if possible && hasTimebase && hasFrame { + print("[MpvPipController] vo_pip ready after \(attempts) retries, starting PiP") + pipController.startPictureInPicture() + completion(true) + } else if attempts < 40 { + attempts += 1 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { tryStart() } + } else { + print( + "[MpvPipController] PiP not ready after \(attempts) retries (possible=\(possible), timebase=\(hasTimebase))" + ) + completion(false) } - tryStart() + } + tryStart() } func stopPip() { - pipController?.stopPictureInPicture() + pipController?.stopPictureInPicture() } /// Invalidate the playback state so PiP updates its UI (play/pause button) func invalidatePlaybackState() { - pipController?.invalidatePlaybackState() + pipController?.invalidatePlaybackState() } /// Fully tear down PiP — removes the container view from the window and /// destroys the AVPictureInPictureController so the system can no longer /// trigger auto-PiP after the player is disposed. func teardown() { - pipController?.stopPictureInPicture() - if #available(iOS 14.2, *) { - pipController?.canStartPictureInPictureAutomaticallyFromInline = false - } - pipController = nil - delegateHelper = nil - sampleBufferLayer.flushAndRemoveImage() - sampleBufferLayer.controlTimebase = nil - sampleBufferLayer.removeFromSuperlayer() - containerView?.removeFromSuperview() - containerView = nil + pipController?.stopPictureInPicture() + if #available(iOS 14.2, *) { + pipController?.canStartPictureInPictureAutomaticallyFromInline = false + } + pipController = nil + delegateHelper = nil + sampleBufferLayer.flushAndRemoveImage() + sampleBufferLayer.controlTimebase = nil + sampleBufferLayer.removeFromSuperlayer() + containerView?.removeFromSuperview() + containerView = nil } /// Flush enqueued sample buffers from the layer to free video frame memory func flushLayer() { - sampleBufferLayer.flushAndRemoveImage() + sampleBufferLayer.flushAndRemoveImage() } -} + } -// MARK: - PiP Delegate Helper (iOS 15+) + // MARK: - PiP Delegate Helper (iOS 15+) -/// Separate class conforming to AVPictureInPictureControllerDelegate and -/// AVPictureInPictureSampleBufferPlaybackDelegate since these require iOS 15+ -/// availability for the ContentSource-based delegate methods. -@available(iOS 15.0, *) -private class PipDelegateHelper: NSObject, AVPictureInPictureControllerDelegate, + /// Separate class conforming to AVPictureInPictureControllerDelegate and + /// AVPictureInPictureSampleBufferPlaybackDelegate since these require iOS 15+ + /// availability for the ContentSource-based delegate methods. + @available(iOS 15.0, *) + private class PipDelegateHelper: NSObject, AVPictureInPictureControllerDelegate, AVPictureInPictureSampleBufferPlaybackDelegate -{ + { weak var controller: MpvPipController? private var isRestoring = false init(controller: MpvPipController) { - self.controller = controller - super.init() + self.controller = controller + super.init() } // MARK: - AVPictureInPictureControllerDelegate func pictureInPictureControllerWillStartPictureInPicture( - _ pictureInPictureController: AVPictureInPictureController + _ pictureInPictureController: AVPictureInPictureController ) { - print("[MpvPipController] PiP will start") - controller?.delegate?.pipWillStart() + print("[MpvPipController] PiP will start") + controller?.delegate?.pipWillStart() } func pictureInPictureControllerDidStartPictureInPicture( - _ pictureInPictureController: AVPictureInPictureController + _ pictureInPictureController: AVPictureInPictureController ) { - print("[MpvPipController] PiP did start") - controller?.delegate?.pipDidStart() + print("[MpvPipController] PiP did start") + controller?.delegate?.pipDidStart() } func pictureInPictureControllerDidStopPictureInPicture( - _ pictureInPictureController: AVPictureInPictureController + _ pictureInPictureController: AVPictureInPictureController ) { - let restored = isRestoring - isRestoring = false - print("[MpvPipController] PiP did stop (restored: \(restored))") - controller?.delegate?.pipDidStop(restored: restored) + let restored = isRestoring + isRestoring = false + print("[MpvPipController] PiP did stop (restored: \(restored))") + controller?.delegate?.pipDidStop(restored: restored) } func pictureInPictureController( - _ pictureInPictureController: AVPictureInPictureController, - failedToStartPictureInPictureWithError error: Error + _ pictureInPictureController: AVPictureInPictureController, + failedToStartPictureInPictureWithError error: Error ) { - print("[MpvPipController] PiP failed to start: \(error)") - controller?.delegate?.pipDidFailToStart(error: error) + print("[MpvPipController] PiP failed to start: \(error)") + controller?.delegate?.pipDidFailToStart(error: error) } func pictureInPictureController( - _ pictureInPictureController: AVPictureInPictureController, - restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void + _ pictureInPictureController: AVPictureInPictureController, + restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: + @escaping (Bool) -> Void ) { - print("[MpvPipController] PiP restore user interface") - isRestoring = true - completionHandler(true) + print("[MpvPipController] PiP restore user interface") + isRestoring = true + completionHandler(true) } func pictureInPictureControllerWillStopPictureInPicture( - _ pictureInPictureController: AVPictureInPictureController + _ pictureInPictureController: AVPictureInPictureController ) { - print("[MpvPipController] PiP will stop") + print("[MpvPipController] PiP will stop") } // MARK: - AVPictureInPictureSampleBufferPlaybackDelegate func pictureInPictureController( - _ pictureInPictureController: AVPictureInPictureController, - setPlaying playing: Bool + _ pictureInPictureController: AVPictureInPictureController, + setPlaying playing: Bool ) { - print("[MpvPipController] PiP setPlaying: \(playing)") - controller?.delegate?.pipSetPlaying(playing) + print("[MpvPipController] PiP setPlaying: \(playing)") + controller?.delegate?.pipSetPlaying(playing) } func pictureInPictureControllerTimeRangeForPlayback( - _ pictureInPictureController: AVPictureInPictureController + _ pictureInPictureController: AVPictureInPictureController ) -> CMTimeRange { - let duration = controller?.delegate?.pipDuration ?? 0 - if duration > 0 { - return CMTimeRange( - start: .zero, - duration: CMTime(seconds: duration, preferredTimescale: 1000) - ) - } - return CMTimeRange(start: .zero, duration: CMTime(seconds: 1, preferredTimescale: 1)) + let duration = controller?.delegate?.pipDuration ?? 0 + if duration > 0 { + return CMTimeRange( + start: .zero, + duration: CMTime(seconds: duration, preferredTimescale: 1000) + ) + } + return CMTimeRange(start: .zero, duration: CMTime(seconds: 1, preferredTimescale: 1)) } func pictureInPictureControllerIsPlaybackPaused( - _ pictureInPictureController: AVPictureInPictureController + _ pictureInPictureController: AVPictureInPictureController ) -> Bool { - return !(controller?.delegate?.isPipPlaying ?? false) + return !(controller?.delegate?.isPipPlaying ?? false) } func pictureInPictureController( - _ pictureInPictureController: AVPictureInPictureController, - didTransitionToRenderSize newRenderSize: CMVideoDimensions + _ pictureInPictureController: AVPictureInPictureController, + didTransitionToRenderSize newRenderSize: CMVideoDimensions ) {} func pictureInPictureController( - _ pictureInPictureController: AVPictureInPictureController, - skipByInterval skipInterval: CMTime, - completion completionHandler: @escaping () -> Void + _ pictureInPictureController: AVPictureInPictureController, + skipByInterval skipInterval: CMTime, + completion completionHandler: @escaping () -> Void ) { - let seconds = CMTimeGetSeconds(skipInterval) - print("[MpvPipController] PiP skip by \(seconds)s") - controller?.delegate?.pipSkip(byInterval: seconds) - completionHandler() + let seconds = CMTimeGetSeconds(skipInterval) + print("[MpvPipController] PiP skip by \(seconds)s") + controller?.delegate?.pipSkip(byInterval: seconds) + completionHandler() } -} + } #endif // !os(tvOS) diff --git a/ios/Runner/MpvPlayer/MpvPlayerCore.swift b/ios/Runner/MpvPlayer/MpvPlayerCore.swift index e1f84281..9abea1e3 100644 --- a/ios/Runner/MpvPlayer/MpvPlayerCore.swift +++ b/ios/Runner/MpvPlayer/MpvPlayerCore.swift @@ -4,195 +4,195 @@ import UIKit /// Core MPV player using Metal rendering for iOS. class MpvPlayerCore: MpvPlayerCoreBase { - private var containerView: UIView? - private weak var window: UIWindow? + private var containerView: UIView? + private weak var window: UIWindow? - var isPipStarting = false + var isPipStarting = false - func initialize(in window: UIWindow) -> Bool { - guard !isInitialized else { - print("[MpvPlayerCore] Already initialized") - return true - } - - self.window = window - - let container = UIView(frame: window.bounds) - container.backgroundColor = .clear - container.isUserInteractionEnabled = false - - let layer = MpvMetalLayer() - layer.frame = container.bounds - layer.contentsScale = UIScreen.main.nativeScale - layer.framebufferOnly = true - layer.backgroundColor = UIColor.black.cgColor - - container.layer.addSublayer(layer) - containerView = container - metalLayer = layer - - window.insertSubview(container, at: 0) - - guard setupMpv() else { - print("[MpvPlayerCore] Failed to setup MPV") - layer.removeFromSuperlayer() - container.removeFromSuperview() - metalLayer = nil - containerView = nil - return false - } - - setupNotifications() - - isInitialized = true - print("[MpvPlayerCore] Initialized successfully with MPV") - return true + func initialize(in window: UIWindow) -> Bool { + guard !isInitialized else { + print("[MpvPlayerCore] Already initialized") + return true } - func switchToPipVO(layerPtr: UnsafeMutableRawPointer) -> Bool { - guard let mpv else { return false } + self.window = window - print("[MpvPlayerCore] Switching to pip VO for PiP") + let container = UIView(frame: window.bounds) + container.backgroundColor = .clear + container.isUserInteractionEnabled = false - metalLayer?.removeFromSuperlayer() + let layer = MpvMetalLayer() + layer.frame = container.bounds + layer.contentsScale = UIScreen.main.nativeScale + layer.framebufferOnly = true + layer.backgroundColor = UIColor.black.cgColor - mpv_set_property_string(mpv, "vid", "no") + container.layer.addSublayer(layer) + containerView = container + metalLayer = layer - var pointer = Int64(Int(bitPattern: layerPtr)) - mpv_set_property(mpv, "wid", MPV_FORMAT_INT64, &pointer) + window.insertSubview(container, at: 0) - mpv_set_property_string(mpv, "vo", "pip") - mpv_set_property_string(mpv, "vid", "auto") - - print("[MpvPlayerCore] Switched to pip VO successfully") - return true + guard setupMpv() else { + print("[MpvPlayerCore] Failed to setup MPV") + layer.removeFromSuperlayer() + container.removeFromSuperview() + metalLayer = nil + containerView = nil + return false } - func switchToGpuNextVO() -> Bool { - guard let mpv, let metalLayer else { return false } + setupNotifications() - print("[MpvPlayerCore] Switching back to gpu-next VO") + isInitialized = true + print("[MpvPlayerCore] Initialized successfully with MPV") + return true + } - mpv_set_property_string(mpv, "vid", "no") + func switchToPipVO(layerPtr: UnsafeMutableRawPointer) -> Bool { + guard let mpv else { return false } - var layer = metalLayer - mpv_set_property(mpv, "wid", MPV_FORMAT_INT64, &layer) + print("[MpvPlayerCore] Switching to pip VO for PiP") - applyGpuNextOptions() - mpv_set_property_string(mpv, "vid", "auto") + metalLayer?.removeFromSuperlayer() - if metalLayer.superlayer == nil, let containerView { - containerView.layer.addSublayer(metalLayer) - } + mpv_set_property_string(mpv, "vid", "no") - print("[MpvPlayerCore] Switched back to gpu-next VO successfully") - return true + var pointer = Int64(Int(bitPattern: layerPtr)) + mpv_set_property(mpv, "wid", MPV_FORMAT_INT64, &pointer) + + mpv_set_property_string(mpv, "vo", "pip") + mpv_set_property_string(mpv, "vid", "auto") + + print("[MpvPlayerCore] Switched to pip VO successfully") + return true + } + + func switchToGpuNextVO() -> Bool { + guard let mpv, let metalLayer else { return false } + + print("[MpvPlayerCore] Switching back to gpu-next VO") + + mpv_set_property_string(mpv, "vid", "no") + + var layer = metalLayer + mpv_set_property(mpv, "wid", MPV_FORMAT_INT64, &layer) + + applyGpuNextOptions() + mpv_set_property_string(mpv, "vid", "auto") + + if metalLayer.superlayer == nil, let containerView { + containerView.layer.addSublayer(metalLayer) } - func setVisible(_ visible: Bool) { - guard let containerView else { return } + print("[MpvPlayerCore] Switched back to gpu-next VO successfully") + return true + } - if visible { - containerView.removeFromSuperview() - window?.insertSubview(containerView, at: 0) - } + func setVisible(_ visible: Bool) { + guard let containerView else { return } - containerView.isHidden = !visible + if visible { + containerView.removeFromSuperview() + window?.insertSubview(containerView, at: 0) } - func updateFrame(_ frame: CGRect? = nil) { - guard let metalLayer, let containerView else { return } + containerView.isHidden = !visible + } - if let frame { - containerView.frame = frame - metalLayer.frame = containerView.bounds - } else if let window { - containerView.frame = window.bounds - metalLayer.frame = containerView.bounds - } + func updateFrame(_ frame: CGRect? = nil) { + guard let metalLayer, let containerView else { return } - let scale = UIScreen.main.nativeScale - metalLayer.drawableSize = CGSize( - width: metalLayer.frame.width * scale, - height: metalLayer.frame.height * scale - ) + if let frame { + containerView.frame = frame + metalLayer.frame = containerView.bounds + } else if let window { + containerView.frame = window.bounds + metalLayer.frame = containerView.bounds } - /// Nudge mpv to present the current paused frame after switching back from PiP. - func forceDraw() { - command(["seek", "0", "relative+exact"]) + let scale = UIScreen.main.nativeScale + metalLayer.drawableSize = CGSize( + width: metalLayer.frame.width * scale, + height: metalLayer.frame.height * scale + ) + } + + /// Nudge mpv to present the current paused frame after switching back from PiP. + func forceDraw() { + command(["seek", "0", "relative+exact"]) + } + + override func updateEDRMode(sigPeak: Double) { + guard let metalLayer else { return } + + var edrHeadroom: CGFloat = 1.0 + #if os(iOS) + if #available(iOS 16.0, *) { + edrHeadroom = containerView?.window?.screen.potentialEDRHeadroom ?? 1.0 + metalLayer.wantsExtendedDynamicRangeContent = + hdrEnabled && sigPeak > 1.0 && edrHeadroom > 1.0 + } + #endif + + let shouldEnableEDR = hdrEnabled && sigPeak > 1.0 && edrHeadroom > 1.0 + print( + "[MpvPlayerCore] EDR mode: \(shouldEnableEDR) (hdrEnabled: \(hdrEnabled), sigPeak: \(sigPeak), headroom: \(edrHeadroom))" + ) + } + + func dispose() { + NotificationCenter.default.removeObserver(self) + disposeSharedState(destroySynchronously: false) + + metalLayer?.removeFromSuperlayer() + metalLayer = nil + containerView?.removeFromSuperview() + containerView = nil + isInitialized = false + print("[MpvPlayerCore] Disposed") + } + + deinit { + dispose() + } + + private func setupNotifications() { + NotificationCenter.default.addObserver( + self, + selector: #selector(enterBackground), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(enterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + } + + @objc private func enterBackground() { + if isPipActive || isPipStarting { + print("[MpvPlayerCore] Entering background - PiP active/starting, keeping video") + return } - override func updateEDRMode(sigPeak: Double) { - guard let metalLayer else { return } + print("[MpvPlayerCore] Entering background - disabling video") + if mpv != nil { + mpv_set_option_string(mpv, "vid", "no") + } + } - var edrHeadroom: CGFloat = 1.0 - #if os(iOS) - if #available(iOS 16.0, *) { - edrHeadroom = containerView?.window?.screen.potentialEDRHeadroom ?? 1.0 - metalLayer.wantsExtendedDynamicRangeContent = - hdrEnabled && sigPeak > 1.0 && edrHeadroom > 1.0 - } - #endif - - let shouldEnableEDR = hdrEnabled && sigPeak > 1.0 && edrHeadroom > 1.0 - print( - "[MpvPlayerCore] EDR mode: \(shouldEnableEDR) (hdrEnabled: \(hdrEnabled), sigPeak: \(sigPeak), headroom: \(edrHeadroom))" - ) + @objc private func enterForeground() { + if isPipActive { + print("[MpvPlayerCore] Entering foreground - PiP active, skipping vid restore") + return } - func dispose() { - NotificationCenter.default.removeObserver(self) - disposeSharedState(destroySynchronously: false) - - metalLayer?.removeFromSuperlayer() - metalLayer = nil - containerView?.removeFromSuperview() - containerView = nil - isInitialized = false - print("[MpvPlayerCore] Disposed") - } - - deinit { - dispose() - } - - private func setupNotifications() { - NotificationCenter.default.addObserver( - self, - selector: #selector(enterBackground), - name: UIApplication.didEnterBackgroundNotification, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(enterForeground), - name: UIApplication.willEnterForegroundNotification, - object: nil - ) - } - - @objc private func enterBackground() { - if isPipActive || isPipStarting { - print("[MpvPlayerCore] Entering background - PiP active/starting, keeping video") - return - } - - print("[MpvPlayerCore] Entering background - disabling video") - if mpv != nil { - mpv_set_option_string(mpv, "vid", "no") - } - } - - @objc private func enterForeground() { - if isPipActive { - print("[MpvPlayerCore] Entering foreground - PiP active, skipping vid restore") - return - } - - print("[MpvPlayerCore] Entering foreground - enabling video") - if mpv != nil { - mpv_set_option_string(mpv, "vid", "auto") - } + print("[MpvPlayerCore] Entering foreground - enabling video") + if mpv != nil { + mpv_set_option_string(mpv, "vid", "auto") } + } } diff --git a/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift b/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift index dbedc5bd..62eb0d60 100644 --- a/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift +++ b/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift @@ -5,392 +5,416 @@ import AVKit /// Flutter plugin that bridges MPV player to Dart via method and event channels class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginShared { - // MARK: - Properties + // MARK: - Properties - private var playerCore: MpvPlayerCore? - var eventSink: FlutterEventSink? - private weak var registrar: FlutterPluginRegistrar? - var nameToId: [String: Int] = [:] + private var playerCore: MpvPlayerCore? + var eventSink: FlutterEventSink? + private weak var registrar: FlutterPluginRegistrar? + var nameToId: [String: Int] = [:] - // MpvPluginShared conformance - var coreBase: MpvPlayerCoreBase? { playerCore } - func setPlayerVisible(_ visible: Bool) { playerCore?.setVisible(visible) } - func updatePlayerFrame() { playerCore?.updateFrame() } + // MpvPluginShared conformance + var coreBase: MpvPlayerCoreBase? { playerCore } + func setPlayerVisible(_ visible: Bool) { playerCore?.setVisible(visible) } + func updatePlayerFrame() { playerCore?.updateFrame() } - // PiP - private var pipController: MpvPipController? - private var pipChannel: FlutterMethodChannel? - private var autoPipEnabled = false - private var isManualPipRequest = false - private var pipTimebaseSyncTimer: Timer? - private var pendingInlineRestoreAfterPip = false - private var sceneActivationObserverRegistered = false + // PiP + private var pipController: MpvPipController? + private var pipChannel: FlutterMethodChannel? + private var autoPipEnabled = false + private var isManualPipRequest = false + private var pipTimebaseSyncTimer: Timer? + private var pendingInlineRestoreAfterPip = false + private var sceneActivationObserverRegistered = false - // MARK: - FlutterPlugin Registration + // MARK: - FlutterPlugin Registration - static func register(with registrar: FlutterPluginRegistrar) { - let methodChannel = FlutterMethodChannel( - name: "com.plezy/mpv_player", - binaryMessenger: registrar.messenger() - ) - let eventChannel = FlutterEventChannel( - name: "com.plezy/mpv_player/events", - binaryMessenger: registrar.messenger() - ) - let pipChannel = FlutterMethodChannel( - name: "com.plezy/pip", - binaryMessenger: registrar.messenger() - ) + static func register(with registrar: FlutterPluginRegistrar) { + let methodChannel = FlutterMethodChannel( + name: "com.plezy/mpv_player", + binaryMessenger: registrar.messenger() + ) + let eventChannel = FlutterEventChannel( + name: "com.plezy/mpv_player/events", + binaryMessenger: registrar.messenger() + ) + let pipChannel = FlutterMethodChannel( + name: "com.plezy/pip", + binaryMessenger: registrar.messenger() + ) - let instance = MpvPlayerPlugin() - instance.registrar = registrar - instance.pipChannel = pipChannel + let instance = MpvPlayerPlugin() + instance.registrar = registrar + instance.pipChannel = pipChannel - registrar.addMethodCallDelegate(instance, channel: methodChannel) - eventChannel.setStreamHandler(instance) - pipChannel.setMethodCallHandler(instance.handlePipCall) + registrar.addMethodCallDelegate(instance, channel: methodChannel) + eventChannel.setStreamHandler(instance) + pipChannel.setMethodCallHandler(instance.handlePipCall) + } + + // MARK: - FlutterStreamHandler + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) + -> FlutterError? + { + self.eventSink = events + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + self.eventSink = nil + return nil + } + + // MARK: - FlutterPlugin Method Handler + + func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "initialize": + handleInitialize(result: result) + case "dispose": + handleDispose(result: result) + case "setProperty": + handleSetProperty(call: call, result: result) + case "getProperty": + handleGetProperty(call: call, result: result) + case "observeProperty": + handleObserveProperty(call: call, result: result) + case "command": + handleCommand(call: call, result: result) + case "setVisible": + handleSetVisible(call: call, result: result) + case "isInitialized": + result(playerCore?.isInitialized ?? false) + case "updateFrame": + handleUpdateFrame(result: result) + case "setLogLevel": + handleSetLogLevel(call: call, result: result) + default: + result(FlutterMethodNotImplemented) } + } - // MARK: - FlutterStreamHandler + // MARK: - PiP - func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { - self.eventSink = events - return nil + private func ensurePipController() -> MpvPipController { + if let existing = pipController { return existing } + let controller = MpvPipController() + controller.delegate = self + pipController = controller + return controller + } + + private func registerSceneActivationObserver() { + guard !sceneActivationObserverRegistered else { return } + NotificationCenter.default.addObserver( + self, + selector: #selector(sceneDidActivate), + name: UIScene.didActivateNotification, + object: nil + ) + sceneActivationObserverRegistered = true + } + + private func unregisterSceneActivationObserver() { + guard sceneActivationObserverRegistered else { return } + NotificationCenter.default.removeObserver( + self, name: UIScene.didActivateNotification, object: nil) + sceneActivationObserverRegistered = false + } + + private var isSceneActive: Bool { + UIApplication.shared.connectedScenes.contains { $0.activationState == .foregroundActive } + } + + private func restoreInlinePlayerAfterPip() { + guard pendingInlineRestoreAfterPip, + let playerCore = playerCore, + !playerCore.isPipActive, + !playerCore.isPipStarting + else { return } + + print("[MpvPlayerPlugin] Restoring inline player after PiP") + playerCore.setVisible(true) + playerCore.updateFrame() + if playerCore.isPaused { + playerCore.forceDraw() } + pendingInlineRestoreAfterPip = false + } - func onCancel(withArguments arguments: Any?) -> FlutterError? { - self.eventSink = nil - return nil - } - - // MARK: - FlutterPlugin Method Handler - - func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - switch call.method { - case "initialize": - handleInitialize(result: result) - case "dispose": - handleDispose(result: result) - case "setProperty": - handleSetProperty(call: call, result: result) - case "getProperty": - handleGetProperty(call: call, result: result) - case "observeProperty": - handleObserveProperty(call: call, result: result) - case "command": - handleCommand(call: call, result: result) - case "setVisible": - handleSetVisible(call: call, result: result) - case "isInitialized": - result(playerCore?.isInitialized ?? false) - case "updateFrame": - handleUpdateFrame(result: result) - case "setLogLevel": - handleSetLogLevel(call: call, result: result) - default: - result(FlutterMethodNotImplemented) - } - } - - // MARK: - PiP - - private func ensurePipController() -> MpvPipController { - if let existing = pipController { return existing } - let controller = MpvPipController() - controller.delegate = self - pipController = controller - return controller - } - - private func registerSceneActivationObserver() { - guard !sceneActivationObserverRegistered else { return } - NotificationCenter.default.addObserver( - self, - selector: #selector(sceneDidActivate), - name: UIScene.didActivateNotification, - object: nil - ) - sceneActivationObserverRegistered = true - } - - private func unregisterSceneActivationObserver() { - guard sceneActivationObserverRegistered else { return } - NotificationCenter.default.removeObserver(self, name: UIScene.didActivateNotification, object: nil) - sceneActivationObserverRegistered = false - } - - private var isSceneActive: Bool { - UIApplication.shared.connectedScenes.contains { $0.activationState == .foregroundActive } - } - - private func restoreInlinePlayerAfterPip() { - guard pendingInlineRestoreAfterPip, - let playerCore = playerCore, - !playerCore.isPipActive, - !playerCore.isPipStarting else { return } - - print("[MpvPlayerPlugin] Restoring inline player after PiP") - playerCore.setVisible(true) - playerCore.updateFrame() - if playerCore.isPaused { - playerCore.forceDraw() - } - pendingInlineRestoreAfterPip = false - } - - private func handlePipCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - DispatchQueue.main.async { [weak self] in - guard let self = self else { result(nil); return } - switch call.method { - case "isSupported": - result(MpvPipController.isSupported) - case "enter": - self.enterPip(manual: true, result: result) - case "setAutoPipReady": - if let args = call.arguments as? [String: Any], let ready = args["ready"] as? Bool { - self.autoPipEnabled = ready - if ready { - let pip = self.ensurePipController() - pip.setAutoStart(true) - // Warm the layer so the system considers PiP possible - if let pc = self.playerCore { - pip.warmLayer(currentTime: pc.timePos, isPlaying: !pc.isPaused) - } - } else { - self.pipController?.setAutoStart(false) - } - } - result(nil) - default: - result(FlutterMethodNotImplemented) + private func handlePipCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { result(nil); return } + switch call.method { + case "isSupported": + result(MpvPipController.isSupported) + case "enter": + self.enterPip(manual: true, result: result) + case "setAutoPipReady": + if let args = call.arguments as? [String: Any], let ready = args["ready"] as? Bool { + self.autoPipEnabled = ready + if ready { + let pip = self.ensurePipController() + pip.setAutoStart(true) + // Warm the layer so the system considers PiP possible + if let pc = self.playerCore { + pip.warmLayer(currentTime: pc.timePos, isPlaying: !pc.isPaused) } + } else { + self.pipController?.setAutoStart(false) + } } - } - - /// Switch to PiP VO and prepare the sample buffer layer for PiP display. - /// Returns the MpvPipController on success, nil on failure. - @discardableResult - private func switchToPipAndPrepare() -> MpvPipController? { - guard let playerCore = playerCore else { return nil } - let pip = ensurePipController() - guard playerCore.switchToPipVO(layerPtr: pip.layerPointer) else { return nil } - pendingInlineRestoreAfterPip = false - playerCore.isPipStarting = true - pip.pushBlankFrame() - pip.syncTimebase(currentTime: playerCore.timePos, isPlaying: !playerCore.isPaused) - pip.invalidatePlaybackState() - return pip - } - - /// Manual PiP entry (button press). Auto-PiP is handled by the system via - /// canStartPictureInPictureAutomaticallyFromInline + pipWillStart delegate. - private func enterPip(manual: Bool, result: FlutterResult? = nil) { - guard MpvPipController.isSupported else { - result?(["success": false, "errorCode": "ios_version", "errorMessage": "Requires iOS 15.0+"]) - return - } - guard playerCore != nil else { - result?(["success": false, "errorCode": "failed", "errorMessage": "Player not initialized"]) - return - } - guard let pip = switchToPipAndPrepare() else { - result?(["success": false, "errorCode": "vo_switch_failed", "errorMessage": "Failed to switch VO"]) - return - } - - isManualPipRequest = manual - pip.startPip(waitForFrame: manual) { [weak self] started in - if started { - result?(["success": true]) - } else { - self?.cleanupPip(notify: false) - result?(["success": false, "errorCode": "failed", "errorMessage": "PiP failed to start"]) - } - } - } - - /// Unified cleanup for all PiP exit paths - private func cleanupPip(notify: Bool, pause: Bool = false) { - playerCore?.isPipStarting = false - playerCore?.isPipActive = false - isManualPipRequest = false - stopPipTimebaseSync() - pipController?.flushLayer() - let restoredInlineVO = playerCore?.switchToGpuNextVO() ?? false - if pause { playerCore?.setProperty("pause", value: "yes") } - pendingInlineRestoreAfterPip = restoredInlineVO - if pendingInlineRestoreAfterPip { - if isSceneActive { - restoreInlinePlayerAfterPip() - } else { - print("[MpvPlayerPlugin] Deferring inline restore until scene activation") - } - } - if notify { pipChannel?.invokeMethod("onPipChanged", arguments: false) } - } - - /// Scene became active — restore inline playback if needed and re-warm the - /// sample-buffer layer so future auto-PiP remains possible. - @objc private func sceneDidActivate() { - restoreInlinePlayerAfterPip() - if autoPipEnabled, let pip = pipController, let pc = playerCore, !pc.isPipActive { - pip.warmLayer(currentTime: pc.timePos, isPlaying: !pc.isPaused) - } - } - - // MARK: - Timebase Sync - - private func syncPipTimebase() { - guard let playerCore = playerCore, let pipController = pipController else { return } - pipController.syncTimebase( - currentTime: playerCore.timePos, - isPlaying: !playerCore.isPaused - ) - } - - private func startPipTimebaseSync() { - stopPipTimebaseSync() - pipTimebaseSyncTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in - self?.syncPipTimebase() - } - } - - private func stopPipTimebaseSync() { - pipTimebaseSyncTimer?.invalidate() - pipTimebaseSyncTimer = nil - } - - // MARK: - Platform-Specific Method Handlers - - private func handleInitialize(result: @escaping FlutterResult) { - DispatchQueue.main.async { [weak self] in - guard let self = self else { - result(FlutterError(code: "ERROR", message: "Plugin deallocated", details: nil)) - return - } - - if self.playerCore?.isInitialized == true { - self.registerSceneActivationObserver() - result(true) - return - } - - guard let window = self.findKeyWindow() else { - result(FlutterError(code: "NO_WINDOW", message: "Could not find key window", details: nil)) - return - } - - let core = MpvPlayerCore() - core.delegate = self - - guard core.initialize(in: window) else { - result(FlutterError(code: "MPV_INIT_FAILED", message: "Failed to initialize MPV", details: nil)) - return - } - - self.playerCore = core - self.registerSceneActivationObserver() - core.setVisible(false) - result(true) - } - } - - private func handleDispose(result: @escaping FlutterResult) { - DispatchQueue.main.async { [weak self] in - guard let self = self else { result(nil); return } - self.pipController?.teardown() - self.pipController = nil - self.autoPipEnabled = false - self.pendingInlineRestoreAfterPip = false - self.unregisterSceneActivationObserver() - self.stopPipTimebaseSync() - self.playerCore?.dispose() - self.playerCore = nil - result(nil) - } - } - - private func handleSetProperty(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let args = call.arguments as? [String: Any], - let name = args["name"] as? String, - let value = args["value"] as? String else { - result(FlutterError(code: "INVALID_ARGS", message: "Missing 'name' or 'value' argument", details: nil)) - return - } - - playerCore?.setProperty(name, value: value) - - if name == "pause" { - pipController?.invalidatePlaybackState() - if playerCore?.isPipActive == true { syncPipTimebase() } - } - result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + } + + /// Switch to PiP VO and prepare the sample buffer layer for PiP display. + /// Returns the MpvPipController on success, nil on failure. + @discardableResult + private func switchToPipAndPrepare() -> MpvPipController? { + guard let playerCore = playerCore else { return nil } + let pip = ensurePipController() + guard playerCore.switchToPipVO(layerPtr: pip.layerPointer) else { return nil } + pendingInlineRestoreAfterPip = false + playerCore.isPipStarting = true + pip.pushBlankFrame() + pip.syncTimebase(currentTime: playerCore.timePos, isPlaying: !playerCore.isPaused) + pip.invalidatePlaybackState() + return pip + } + + /// Manual PiP entry (button press). Auto-PiP is handled by the system via + /// canStartPictureInPictureAutomaticallyFromInline + pipWillStart delegate. + private func enterPip(manual: Bool, result: FlutterResult? = nil) { + guard MpvPipController.isSupported else { + result?([ + "success": false, "errorCode": "ios_version", "errorMessage": "Requires iOS 15.0+", + ]) + return + } + guard playerCore != nil else { + result?([ + "success": false, "errorCode": "failed", "errorMessage": "Player not initialized", + ]) + return + } + guard let pip = switchToPipAndPrepare() else { + result?([ + "success": false, "errorCode": "vo_switch_failed", + "errorMessage": "Failed to switch VO", + ]) + return } - // MARK: - Helpers - - private func findKeyWindow() -> UIWindow? { - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first(where: { $0.isKeyWindow }) else { - return nil - } - return window + isManualPipRequest = manual + pip.startPip(waitForFrame: manual) { [weak self] started in + if started { + result?(["success": true]) + } else { + self?.cleanupPip(notify: false) + result?([ + "success": false, "errorCode": "failed", "errorMessage": "PiP failed to start", + ]) + } } + } + + /// Unified cleanup for all PiP exit paths + private func cleanupPip(notify: Bool, pause: Bool = false) { + playerCore?.isPipStarting = false + playerCore?.isPipActive = false + isManualPipRequest = false + stopPipTimebaseSync() + pipController?.flushLayer() + let restoredInlineVO = playerCore?.switchToGpuNextVO() ?? false + if pause { playerCore?.setProperty("pause", value: "yes") } + pendingInlineRestoreAfterPip = restoredInlineVO + if pendingInlineRestoreAfterPip { + if isSceneActive { + restoreInlinePlayerAfterPip() + } else { + print("[MpvPlayerPlugin] Deferring inline restore until scene activation") + } + } + if notify { pipChannel?.invokeMethod("onPipChanged", arguments: false) } + } + + /// Scene became active — restore inline playback if needed and re-warm the + /// sample-buffer layer so future auto-PiP remains possible. + @objc private func sceneDidActivate() { + restoreInlinePlayerAfterPip() + if autoPipEnabled, let pip = pipController, let pc = playerCore, !pc.isPipActive { + pip.warmLayer(currentTime: pc.timePos, isPlaying: !pc.isPaused) + } + } + + // MARK: - Timebase Sync + + private func syncPipTimebase() { + guard let playerCore = playerCore, let pipController = pipController else { return } + pipController.syncTimebase( + currentTime: playerCore.timePos, + isPlaying: !playerCore.isPaused + ) + } + + private func startPipTimebaseSync() { + stopPipTimebaseSync() + pipTimebaseSyncTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { + [weak self] _ in + self?.syncPipTimebase() + } + } + + private func stopPipTimebaseSync() { + pipTimebaseSyncTimer?.invalidate() + pipTimebaseSyncTimer = nil + } + + // MARK: - Platform-Specific Method Handlers + + private func handleInitialize(result: @escaping FlutterResult) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { + result(FlutterError(code: "ERROR", message: "Plugin deallocated", details: nil)) + return + } + + if self.playerCore?.isInitialized == true { + self.registerSceneActivationObserver() + result(true) + return + } + + guard let window = self.findKeyWindow() else { + result( + FlutterError( + code: "NO_WINDOW", message: "Could not find key window", details: nil)) + return + } + + let core = MpvPlayerCore() + core.delegate = self + + guard core.initialize(in: window) else { + result( + FlutterError( + code: "MPV_INIT_FAILED", message: "Failed to initialize MPV", details: nil)) + return + } + + self.playerCore = core + self.registerSceneActivationObserver() + core.setVisible(false) + result(true) + } + } + + private func handleDispose(result: @escaping FlutterResult) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { result(nil); return } + self.pipController?.teardown() + self.pipController = nil + self.autoPipEnabled = false + self.pendingInlineRestoreAfterPip = false + self.unregisterSceneActivationObserver() + self.stopPipTimebaseSync() + self.playerCore?.dispose() + self.playerCore = nil + result(nil) + } + } + + private func handleSetProperty(call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let args = call.arguments as? [String: Any], + let name = args["name"] as? String, + let value = args["value"] as? String + else { + result( + FlutterError( + code: "INVALID_ARGS", message: "Missing 'name' or 'value' argument", + details: nil)) + return + } + + playerCore?.setProperty(name, value: value) + + if name == "pause" { + pipController?.invalidatePlaybackState() + if playerCore?.isPipActive == true { syncPipTimebase() } + } + + result(nil) + } + + // MARK: - Helpers + + private func findKeyWindow() -> UIWindow? { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first(where: { $0.isKeyWindow }) + else { + return nil + } + return window + } } // MARK: - MpvPipDelegate extension MpvPlayerPlugin: MpvPipDelegate { - func pipWillStart() { - // If PiP was system-initiated (not via our enterPip), switch VO now - guard let playerCore = playerCore, !playerCore.isPipStarting else { return } - print("[MpvPlayerPlugin] System-initiated PiP detected, switching VO") - if switchToPipAndPrepare() == nil { - print("[MpvPlayerPlugin] VO switch failed for system-initiated PiP") - pipController?.stopPip() - } + func pipWillStart() { + // If PiP was system-initiated (not via our enterPip), switch VO now + guard let playerCore = playerCore, !playerCore.isPipStarting else { return } + print("[MpvPlayerPlugin] System-initiated PiP detected, switching VO") + if switchToPipAndPrepare() == nil { + print("[MpvPlayerPlugin] VO switch failed for system-initiated PiP") + pipController?.stopPip() } + } - func pipDidStart() { - playerCore?.isPipStarting = false - playerCore?.isPipActive = true - pendingInlineRestoreAfterPip = false - pipChannel?.invokeMethod("onPipChanged", arguments: true) - syncPipTimebase() - startPipTimebaseSync() + func pipDidStart() { + playerCore?.isPipStarting = false + playerCore?.isPipActive = true + pendingInlineRestoreAfterPip = false + pipChannel?.invokeMethod("onPipChanged", arguments: true) + syncPipTimebase() + startPipTimebaseSync() - if isManualPipRequest { - isManualPipRequest = false - UIControl().sendAction(#selector(URLSessionTask.suspend), to: UIApplication.shared, for: nil) - } + if isManualPipRequest { + isManualPipRequest = false + UIControl().sendAction( + #selector(URLSessionTask.suspend), to: UIApplication.shared, for: nil) } + } - func pipDidStop(restored: Bool) { - cleanupPip(notify: true, pause: !restored) + func pipDidStop(restored: Bool) { + cleanupPip(notify: true, pause: !restored) + } + + func pipDidFailToStart(error: Error?) { + cleanupPip(notify: true) + } + + func pipSetPlaying(_ playing: Bool) { + playerCore?.setProperty("pause", value: playing ? "no" : "yes") + pipController?.invalidatePlaybackState() + syncPipTimebase() + } + + func pipSkip(byInterval seconds: Double) { + guard let playerCore = playerCore else { return } + let newTime = max(0, playerCore.timePos + seconds) + playerCore.command(["seek", String(newTime), "absolute"]) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.syncPipTimebase() + self?.pipController?.invalidatePlaybackState() } + } - func pipDidFailToStart(error: Error?) { - cleanupPip(notify: true) - } - - func pipSetPlaying(_ playing: Bool) { - playerCore?.setProperty("pause", value: playing ? "no" : "yes") - pipController?.invalidatePlaybackState() - syncPipTimebase() - } - - func pipSkip(byInterval seconds: Double) { - guard let playerCore = playerCore else { return } - let newTime = max(0, playerCore.timePos + seconds) - playerCore.command(["seek", String(newTime), "absolute"]) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - self?.syncPipTimebase() - self?.pipController?.invalidatePlaybackState() - } - } - - var isPipPlaying: Bool { !(playerCore?.isPaused ?? true) } - var pipDuration: Double { playerCore?.duration ?? 0 } + var isPipPlaying: Bool { !(playerCore?.isPaused ?? true) } + var pipDuration: Double { playerCore?.duration ?? 0 } } diff --git a/linux/runner/mpv/mpv_player.cc b/linux/runner/mpv/mpv_player.cc index 210503cc..b51a0001 100644 --- a/linux/runner/mpv/mpv_player.cc +++ b/linux/runner/mpv/mpv_player.cc @@ -1,8 +1,8 @@ #include "mpv_player.h" -#include -#include #include +#include +#include #include #ifdef GDK_WINDOWING_X11 #include @@ -24,9 +24,7 @@ namespace mpv { MpvPlayer::MpvPlayer() {} -MpvPlayer::~MpvPlayer() { - Dispose(); -} +MpvPlayer::~MpvPlayer() { Dispose(); } bool MpvPlayer::Initialize() { if (mpv_) { @@ -111,7 +109,7 @@ bool MpvPlayer::InitRenderContext() { } EGLint num_configs = 0; - EGLint config_attribs[] = { EGL_CONFIG_ID, config_id, EGL_NONE }; + EGLint config_attribs[] = {EGL_CONFIG_ID, config_id, EGL_NONE}; if (!eglChooseConfig(egl_display_, config_attribs, &config, 1, &num_configs) || num_configs == 0) { g_warning("MPV: Failed to get Flutter's EGL config"); return false; @@ -121,7 +119,8 @@ bool MpvPlayer::InitRenderContext() { // GL state pollution eglBindAPI(EGL_OPENGL_ES_API); EGLint context_attribs[] = { - EGL_CONTEXT_CLIENT_VERSION, 2, + EGL_CONTEXT_CLIENT_VERSION, + 2, EGL_NONE, }; egl_context_ = eglCreateContext(egl_display_, config, EGL_NO_CONTEXT, context_attribs); @@ -142,8 +141,7 @@ bool MpvPlayer::InitRenderContext() { }; mpv_render_param params[] = { - {MPV_RENDER_PARAM_API_TYPE, - const_cast(MPV_RENDER_API_TYPE_OPENGL)}, + {MPV_RENDER_PARAM_API_TYPE, const_cast(MPV_RENDER_API_TYPE_OPENGL)}, {MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &gl_init_params}, {MPV_RENDER_PARAM_INVALID, nullptr}, // slot for X11/Wayland display {MPV_RENDER_PARAM_INVALID, nullptr}, @@ -170,8 +168,7 @@ bool MpvPlayer::InitRenderContext() { eglMakeCurrent(egl_display_, flutter_draw, flutter_read, flutter_context); if (err < 0) { - g_warning("MPV: mpv_render_context_create() failed: %s", - mpv_error_string(err)); + g_warning("MPV: mpv_render_context_create() failed: %s", mpv_error_string(err)); eglDestroyContext(egl_display_, egl_context_); egl_context_ = EGL_NO_CONTEXT; return false; @@ -287,8 +284,7 @@ void MpvPlayer::Command(const std::vector& args) { mpv_command(mpv_, c_args.data()); } -void MpvPlayer::CommandAsync(const std::vector& args, - CommandCallback callback) { +void MpvPlayer::CommandAsync(const std::vector& args, CommandCallback callback) { if (disposed_ || !mpv_) { if (callback) callback(0); return; @@ -336,9 +332,7 @@ std::string MpvPlayer::GetProperty(const std::string& name) { return result; } -void MpvPlayer::ObserveProperty(const std::string& name, - const std::string& format, - int id) { +void MpvPlayer::ObserveProperty(const std::string& name, const std::string& format, int id) { if (disposed_ || !mpv_) return; if (observed_properties_.find(name) != observed_properties_.end()) { @@ -403,8 +397,7 @@ void MpvPlayer::OnMpvWakeup(void* ctx) { } return G_SOURCE_REMOVE; }, - player, - nullptr); + player, nullptr); } void MpvPlayer::OnMpvRenderUpdate(void* ctx) { @@ -482,12 +475,9 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) { g_message("MPV [%s] %s: %s", msg->level, msg->prefix, msg->text); FlValue* data = fl_value_new_map(); - fl_value_set_string_take(data, "prefix", - fl_value_new_string(SanitizeUtf8(msg->prefix).c_str())); - fl_value_set_string_take(data, "level", - fl_value_new_string(SanitizeUtf8(msg->level).c_str())); - fl_value_set_string_take(data, "text", - fl_value_new_string(SanitizeUtf8(msg->text).c_str())); + fl_value_set_string_take(data, "prefix", fl_value_new_string(SanitizeUtf8(msg->prefix).c_str())); + fl_value_set_string_take(data, "level", fl_value_new_string(SanitizeUtf8(msg->level).c_str())); + fl_value_set_string_take(data, "text", fl_value_new_string(SanitizeUtf8(msg->text).c_str())); SendEvent("log-message", data); fl_value_unref(data); break; @@ -499,8 +489,7 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) { switch (prop->format) { case MPV_FORMAT_STRING: - node.u.string = - prop->data ? *static_cast(prop->data) : nullptr; + node.u.string = prop->data ? *static_cast(prop->data) : nullptr; break; case MPV_FORMAT_FLAG: node.u.flag = prop->data ? *static_cast(prop->data) : 0; @@ -527,13 +516,11 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) { case MPV_EVENT_END_FILE: { auto* end = static_cast(event->data); FlValue* data = fl_value_new_map(); - fl_value_set_string_take(data, "reason", - fl_value_new_int(static_cast(end->reason))); + fl_value_set_string_take(data, "reason", fl_value_new_int(static_cast(end->reason))); if (end->reason == MPV_END_FILE_REASON_ERROR) { - fl_value_set_string_take(data, "error", - fl_value_new_int(static_cast(end->error))); - fl_value_set_string_take(data, "message", - fl_value_new_string(SanitizeUtf8(mpv_error_string(end->error)).c_str())); + fl_value_set_string_take(data, "error", fl_value_new_int(static_cast(end->error))); + fl_value_set_string_take( + data, "message", fl_value_new_string(SanitizeUtf8(mpv_error_string(end->error)).c_str())); } SendEvent("end-file", data); fl_value_unref(data); @@ -578,9 +565,7 @@ FlValue* MpvPlayer::NodeToFlValue(mpv_node* node) { case MPV_FORMAT_NODE_MAP: { FlValue* map = fl_value_new_map(); for (int i = 0; i < node->u.list->num; i++) { - fl_value_set_string_take( - map, node->u.list->keys[i], - NodeToFlValue(&node->u.list->values[i])); + fl_value_set_string_take(map, node->u.list->keys[i], NodeToFlValue(&node->u.list->values[i])); } return map; } diff --git a/linux/runner/mpv/mpv_player.h b/linux/runner/mpv/mpv_player.h index d63747d1..efd5e9e3 100644 --- a/linux/runner/mpv/mpv_player.h +++ b/linux/runner/mpv/mpv_player.h @@ -1,12 +1,12 @@ #ifndef MPV_PLAYER_H_ #define MPV_PLAYER_H_ +#include +#include +#include #include #include #include -#include -#include -#include #include #include @@ -84,8 +84,7 @@ class MpvPlayer { std::string GetProperty(const std::string& name); /// Observes an mpv property for changes. - void ObserveProperty(const std::string& name, const std::string& format, - int id); + void ObserveProperty(const std::string& name, const std::string& format, int id); /// Renders a frame to the specified FBO. void Render(int width, int height, int fbo = 0); diff --git a/linux/runner/mpv/mpv_plugin.cc b/linux/runner/mpv/mpv_plugin.cc index c2028f6d..02f319ba 100644 --- a/linux/runner/mpv/mpv_plugin.cc +++ b/linux/runner/mpv/mpv_plugin.cc @@ -1,8 +1,9 @@ #include "mpv_plugin.h" -#include "mpv_texture.h" #include +#include "mpv_texture.h" + struct _MpvPlugin { GObject parent_instance; @@ -12,7 +13,7 @@ struct _MpvPlugin { FlTextureRegistrar* texture_registrar; std::unique_ptr player; - MpvTexture* texture; // owned via GObject ref + MpvTexture* texture; // owned via GObject ref gboolean visible; gboolean initialized; }; @@ -20,9 +21,7 @@ struct _MpvPlugin { G_DEFINE_TYPE(MpvPlugin, mpv_plugin, G_TYPE_OBJECT) // Forward declarations -static void mpv_plugin_handle_method_call(FlMethodChannel* channel, - FlMethodCall* method_call, - gpointer user_data); +static void mpv_plugin_handle_method_call(FlMethodChannel* channel, FlMethodCall* method_call, gpointer user_data); static void send_event(MpvPlugin* self, FlValue* event) { if (self->event_channel) { @@ -43,8 +42,7 @@ static void mpv_plugin_dispose(GObject* object) { if (self->texture) { mpv_texture_dispose(self->texture); if (self->texture_registrar) { - fl_texture_registrar_unregister_texture(self->texture_registrar, - FL_TEXTURE(self->texture)); + fl_texture_registrar_unregister_texture(self->texture_registrar, FL_TEXTURE(self->texture)); } g_object_unref(self->texture); self->texture = nullptr; @@ -62,9 +60,7 @@ static void mpv_plugin_dispose(GObject* object) { G_OBJECT_CLASS(mpv_plugin_parent_class)->dispose(object); } -static void mpv_plugin_class_init(MpvPluginClass* klass) { - G_OBJECT_CLASS(klass)->dispose = mpv_plugin_dispose; -} +static void mpv_plugin_class_init(MpvPluginClass* klass) { G_OBJECT_CLASS(klass)->dispose = mpv_plugin_dispose; } static void mpv_plugin_init(MpvPlugin* self) { self->visible = FALSE; @@ -77,26 +73,17 @@ MpvPlugin* mpv_plugin_new(FlPluginRegistrar* registrar) { MpvPlugin* self = MPV_PLUGIN(g_object_new(MPV_PLUGIN_TYPE, nullptr)); self->registrar = FL_PLUGIN_REGISTRAR(g_object_ref(registrar)); - self->texture_registrar = - fl_plugin_registrar_get_texture_registrar(registrar); + self->texture_registrar = fl_plugin_registrar_get_texture_registrar(registrar); self->player = std::make_unique(); g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); self->method_channel = fl_method_channel_new( - fl_plugin_registrar_get_messenger(registrar), - "com.plezy/mpv_player", - FL_METHOD_CODEC(codec)); + fl_plugin_registrar_get_messenger(registrar), "com.plezy/mpv_player", FL_METHOD_CODEC(codec)); - fl_method_channel_set_method_call_handler( - self->method_channel, - mpv_plugin_handle_method_call, - self, - nullptr); + fl_method_channel_set_method_call_handler(self->method_channel, mpv_plugin_handle_method_call, self, nullptr); self->event_channel = fl_event_channel_new( - fl_plugin_registrar_get_messenger(registrar), - "com.plezy/mpv_player/events", - FL_METHOD_CODEC(codec)); + fl_plugin_registrar_get_messenger(registrar), "com.plezy/mpv_player/events", FL_METHOD_CODEC(codec)); return self; } @@ -104,14 +91,10 @@ MpvPlugin* mpv_plugin_new(FlPluginRegistrar* registrar) { // Static reference to keep the plugin alive. static MpvPlugin* g_mpv_plugin = nullptr; -void mpv_plugin_register_with_registrar(FlPluginRegistrar* registrar) { - g_mpv_plugin = mpv_plugin_new(registrar); -} +void mpv_plugin_register_with_registrar(FlPluginRegistrar* registrar) { g_mpv_plugin = mpv_plugin_new(registrar); } /// Method call handler. -static void mpv_plugin_handle_method_call(FlMethodChannel* channel, - FlMethodCall* method_call, - gpointer user_data) { +static void mpv_plugin_handle_method_call(FlMethodChannel* channel, FlMethodCall* method_call, gpointer user_data) { (void)channel; MpvPlugin* self = MPV_PLUGIN(user_data); const gchar* method = fl_method_call_get_name(method_call); @@ -122,8 +105,8 @@ static void mpv_plugin_handle_method_call(FlMethodChannel* channel, if (strcmp(method, "initialize") == 0) { if (self->initialized && self->texture) { // Already initialized — return existing texture ID - response = FL_METHOD_RESPONSE(fl_method_success_response_new( - fl_value_new_int(mpv_texture_get_id(self->texture)))); + response = + FL_METHOD_RESPONSE(fl_method_success_response_new(fl_value_new_int(mpv_texture_get_id(self->texture)))); } else { // Create player if it was disposed or doesn't exist if (!self->player || self->player->IsDisposed()) { @@ -133,11 +116,9 @@ static void mpv_plugin_handle_method_call(FlMethodChannel* channel, if (self->player->Initialize()) { // Create the FlTextureGL and register it FlView* view = fl_plugin_registrar_get_view(self->registrar); - self->texture = mpv_texture_new( - self->player.get(), self->texture_registrar, view); + self->texture = mpv_texture_new(self->player.get(), self->texture_registrar, view); - fl_texture_registrar_register_texture( - self->texture_registrar, FL_TEXTURE(self->texture)); + fl_texture_registrar_register_texture(self->texture_registrar, FL_TEXTURE(self->texture)); // Create the render context eagerly — mpv needs it BEFORE any // file is loaded, otherwise VO init fails with "No render context @@ -146,23 +127,19 @@ static void mpv_plugin_handle_method_call(FlMethodChannel* channel, // Set redraw callback: when mpv has a frame, mark texture available MpvTexture* tex = self->texture; - self->player->SetRedrawCallback([tex]() { - mpv_texture_mark_frame_available(tex); - }); + self->player->SetRedrawCallback([tex]() { mpv_texture_mark_frame_available(tex); }); self->initialized = TRUE; // Set up event callback - self->player->SetEventCallback([self](FlValue* event) { - send_event(self, event); - }); + self->player->SetEventCallback([self](FlValue* event) { send_event(self, event); }); // Return the texture ID for the Dart Texture widget - response = FL_METHOD_RESPONSE(fl_method_success_response_new( - fl_value_new_int(mpv_texture_get_id(self->texture)))); + response = + FL_METHOD_RESPONSE(fl_method_success_response_new(fl_value_new_int(mpv_texture_get_id(self->texture)))); } else { - response = FL_METHOD_RESPONSE(fl_method_error_response_new( - "INIT_FAILED", "Failed to initialize MPV player", nullptr)); + response = + FL_METHOD_RESPONSE(fl_method_error_response_new("INIT_FAILED", "Failed to initialize MPV player", nullptr)); } } } else if (strcmp(method, "dispose") == 0) { @@ -171,8 +148,7 @@ static void mpv_plugin_handle_method_call(FlMethodChannel* channel, // during player disposal. if (self->texture) { mpv_texture_dispose(self->texture); - fl_texture_registrar_unregister_texture(self->texture_registrar, - FL_TEXTURE(self->texture)); + fl_texture_registrar_unregister_texture(self->texture_registrar, FL_TEXTURE(self->texture)); g_object_unref(self->texture); self->texture = nullptr; } @@ -186,14 +162,11 @@ static void mpv_plugin_handle_method_call(FlMethodChannel* channel, response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); } else if (strcmp(method, "command") == 0) { if (!self->player || !self->initialized) { - response = FL_METHOD_RESPONSE(fl_method_error_response_new( - "NOT_INITIALIZED", "Player not initialized", nullptr)); + response = FL_METHOD_RESPONSE(fl_method_error_response_new("NOT_INITIALIZED", "Player not initialized", nullptr)); } else { FlValue* args_value = fl_value_lookup_string(args, "args"); - if (args_value == nullptr || - fl_value_get_type(args_value) != FL_VALUE_TYPE_LIST) { - response = FL_METHOD_RESPONSE(fl_method_error_response_new( - "INVALID_ARGS", "Missing 'args' list", nullptr)); + if (args_value == nullptr || fl_value_get_type(args_value) != FL_VALUE_TYPE_LIST) { + response = FL_METHOD_RESPONSE(fl_method_error_response_new("INVALID_ARGS", "Missing 'args' list", nullptr)); } else { std::vector command_args; size_t len = fl_value_get_length(args_value); @@ -207,8 +180,8 @@ static void mpv_plugin_handle_method_call(FlMethodChannel* channel, self->player->CommandAsync(command_args, [method_call](int error) { g_autoptr(FlMethodResponse) async_response = nullptr; if (error < 0) { - async_response = FL_METHOD_RESPONSE(fl_method_error_response_new( - "COMMAND_FAILED", "MPV command failed", nullptr)); + async_response = + FL_METHOD_RESPONSE(fl_method_error_response_new("COMMAND_FAILED", "MPV command failed", nullptr)); } else { async_response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); } @@ -220,37 +193,28 @@ static void mpv_plugin_handle_method_call(FlMethodChannel* channel, } } else if (strcmp(method, "setProperty") == 0) { if (!self->player || !self->initialized) { - response = FL_METHOD_RESPONSE(fl_method_error_response_new( - "NOT_INITIALIZED", "Player not initialized", nullptr)); + response = FL_METHOD_RESPONSE(fl_method_error_response_new("NOT_INITIALIZED", "Player not initialized", nullptr)); } else { FlValue* name_value = fl_value_lookup_string(args, "name"); FlValue* value_value = fl_value_lookup_string(args, "value"); - if (name_value == nullptr || - fl_value_get_type(name_value) != FL_VALUE_TYPE_STRING) { - response = FL_METHOD_RESPONSE(fl_method_error_response_new( - "INVALID_ARGS", "Missing 'name'", nullptr)); - } else if (value_value == nullptr || - fl_value_get_type(value_value) != FL_VALUE_TYPE_STRING) { - response = FL_METHOD_RESPONSE(fl_method_error_response_new( - "INVALID_ARGS", "Missing 'value'", nullptr)); + if (name_value == nullptr || fl_value_get_type(name_value) != FL_VALUE_TYPE_STRING) { + response = FL_METHOD_RESPONSE(fl_method_error_response_new("INVALID_ARGS", "Missing 'name'", nullptr)); + } else if (value_value == nullptr || fl_value_get_type(value_value) != FL_VALUE_TYPE_STRING) { + response = FL_METHOD_RESPONSE(fl_method_error_response_new("INVALID_ARGS", "Missing 'value'", nullptr)); } else { - self->player->SetProperty(fl_value_get_string(name_value), - fl_value_get_string(value_value)); + self->player->SetProperty(fl_value_get_string(name_value), fl_value_get_string(value_value)); response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); } } } else if (strcmp(method, "setLogLevel") == 0) { if (!self->player || !self->initialized) { - response = FL_METHOD_RESPONSE(fl_method_error_response_new( - "NOT_INITIALIZED", "Player not initialized", nullptr)); + response = FL_METHOD_RESPONSE(fl_method_error_response_new("NOT_INITIALIZED", "Player not initialized", nullptr)); } else { FlValue* level_value = fl_value_lookup_string(args, "level"); - if (level_value == nullptr || - fl_value_get_type(level_value) != FL_VALUE_TYPE_STRING) { - response = FL_METHOD_RESPONSE(fl_method_error_response_new( - "INVALID_ARGS", "Missing 'level'", nullptr)); + if (level_value == nullptr || fl_value_get_type(level_value) != FL_VALUE_TYPE_STRING) { + response = FL_METHOD_RESPONSE(fl_method_error_response_new("INVALID_ARGS", "Missing 'level'", nullptr)); } else { self->player->SetLogLevel(fl_value_get_string(level_value)); response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); @@ -258,62 +222,47 @@ static void mpv_plugin_handle_method_call(FlMethodChannel* channel, } } else if (strcmp(method, "getProperty") == 0) { if (!self->player || !self->initialized) { - response = FL_METHOD_RESPONSE(fl_method_error_response_new( - "NOT_INITIALIZED", "Player not initialized", nullptr)); + response = FL_METHOD_RESPONSE(fl_method_error_response_new("NOT_INITIALIZED", "Player not initialized", nullptr)); } else { FlValue* name_value = fl_value_lookup_string(args, "name"); - if (name_value == nullptr || - fl_value_get_type(name_value) != FL_VALUE_TYPE_STRING) { - response = FL_METHOD_RESPONSE(fl_method_error_response_new( - "INVALID_ARGS", "Missing 'name'", nullptr)); + if (name_value == nullptr || fl_value_get_type(name_value) != FL_VALUE_TYPE_STRING) { + response = FL_METHOD_RESPONSE(fl_method_error_response_new("INVALID_ARGS", "Missing 'name'", nullptr)); } else { - std::string value = - self->player->GetProperty(fl_value_get_string(name_value)); + std::string value = self->player->GetProperty(fl_value_get_string(name_value)); if (value.empty()) { - response = - FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); + response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); } else { - response = FL_METHOD_RESPONSE(fl_method_success_response_new( - fl_value_new_string(value.c_str()))); + response = FL_METHOD_RESPONSE(fl_method_success_response_new(fl_value_new_string(value.c_str()))); } } } } else if (strcmp(method, "observeProperty") == 0) { if (!self->player || !self->initialized) { - response = FL_METHOD_RESPONSE(fl_method_error_response_new( - "NOT_INITIALIZED", "Player not initialized", nullptr)); + response = FL_METHOD_RESPONSE(fl_method_error_response_new("NOT_INITIALIZED", "Player not initialized", nullptr)); } else { FlValue* name_value = fl_value_lookup_string(args, "name"); FlValue* format_value = fl_value_lookup_string(args, "format"); FlValue* id_value = fl_value_lookup_string(args, "id"); - if (name_value == nullptr || - fl_value_get_type(name_value) != FL_VALUE_TYPE_STRING) { - response = FL_METHOD_RESPONSE(fl_method_error_response_new( - "INVALID_ARGS", "Missing 'name'", nullptr)); - } else if (format_value == nullptr || - fl_value_get_type(format_value) != FL_VALUE_TYPE_STRING) { - response = FL_METHOD_RESPONSE(fl_method_error_response_new( - "INVALID_ARGS", "Missing 'format'", nullptr)); - } else if (id_value == nullptr || - fl_value_get_type(id_value) != FL_VALUE_TYPE_INT) { - response = FL_METHOD_RESPONSE(fl_method_error_response_new( - "INVALID_ARGS", "Missing 'id'", nullptr)); + if (name_value == nullptr || fl_value_get_type(name_value) != FL_VALUE_TYPE_STRING) { + response = FL_METHOD_RESPONSE(fl_method_error_response_new("INVALID_ARGS", "Missing 'name'", nullptr)); + } else if (format_value == nullptr || fl_value_get_type(format_value) != FL_VALUE_TYPE_STRING) { + response = FL_METHOD_RESPONSE(fl_method_error_response_new("INVALID_ARGS", "Missing 'format'", nullptr)); + } else if (id_value == nullptr || fl_value_get_type(id_value) != FL_VALUE_TYPE_INT) { + response = FL_METHOD_RESPONSE(fl_method_error_response_new("INVALID_ARGS", "Missing 'id'", nullptr)); } else { - self->player->ObserveProperty(fl_value_get_string(name_value), - fl_value_get_string(format_value), - static_cast(fl_value_get_int(id_value))); + self->player->ObserveProperty( + fl_value_get_string(name_value), fl_value_get_string(format_value), + static_cast(fl_value_get_int(id_value))); response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); } } } else if (strcmp(method, "setVisible") == 0) { FlValue* visible_value = fl_value_lookup_string(args, "visible"); - if (visible_value == nullptr || - fl_value_get_type(visible_value) != FL_VALUE_TYPE_BOOL) { - response = FL_METHOD_RESPONSE(fl_method_error_response_new( - "INVALID_ARGS", "Missing 'visible'", nullptr)); + if (visible_value == nullptr || fl_value_get_type(visible_value) != FL_VALUE_TYPE_BOOL) { + response = FL_METHOD_RESPONSE(fl_method_error_response_new("INVALID_ARGS", "Missing 'visible'", nullptr)); } else { self->visible = fl_value_get_bool(visible_value); @@ -331,8 +280,7 @@ static void mpv_plugin_handle_method_call(FlMethodChannel* channel, response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); } else if (strcmp(method, "isInitialized") == 0) { gboolean initialized = self->player && self->initialized; - response = FL_METHOD_RESPONSE( - fl_method_success_response_new(fl_value_new_bool(initialized))); + response = FL_METHOD_RESPONSE(fl_method_success_response_new(fl_value_new_bool(initialized))); } else { response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new()); } diff --git a/linux/runner/mpv/mpv_texture.cc b/linux/runner/mpv/mpv_texture.cc index 35fead56..e60c200b 100644 --- a/linux/runner/mpv/mpv_texture.cc +++ b/linux/runner/mpv/mpv_texture.cc @@ -1,7 +1,7 @@ #include "mpv_texture.h" -#include #include +#include // EGLImage extension function pointers typedef EGLImageKHR (*PFNEGLCREATEIMAGEKHRPROC)(EGLDisplay, EGLContext, EGLenum, EGLClientBuffer, const EGLint*); @@ -17,7 +17,8 @@ static void init_egl_image_extensions() { if (!initialized) { _eglCreateImageKHR = (PFNEGLCREATEIMAGEKHRPROC)eglGetProcAddress("eglCreateImageKHR"); _eglDestroyImageKHR = (PFNEGLDESTROYIMAGEKHRPROC)eglGetProcAddress("eglDestroyImageKHR"); - _glEGLImageTargetTexture2DOES = (PFNGLEGLIMAGETARGETTEXTURE2DOESPROC)eglGetProcAddress("glEGLImageTargetTexture2DOES"); + _glEGLImageTargetTexture2DOES = + (PFNGLEGLIMAGETARGETTEXTURE2DOESPROC)eglGetProcAddress("glEGLImageTargetTexture2DOES"); initialized = true; } } @@ -25,9 +26,9 @@ static void init_egl_image_extensions() { struct _MpvTexture { FlTextureGL parent_instance; - mpv::MpvPlayer* player; // not owned - FlTextureRegistrar* registrar; // not owned - FlView* view; // not owned, for querying allocation size + mpv::MpvPlayer* player; // not owned + FlTextureRegistrar* registrar; // not owned + FlView* view; // not owned, for querying allocation size // mpv's FBO and texture (owned by mpv's isolated EGL context) GLuint mpv_fbo; @@ -84,19 +85,16 @@ static void ensure_textures(MpvTexture* self, int32_t w, int32_t h) { glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, w, h, 0, GL_RGBA, - GL_UNSIGNED_BYTE, nullptr); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); glGenFramebuffers(1, &self->mpv_fbo); glBindFramebuffer(GL_FRAMEBUFFER, self->mpv_fbo); - glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, - GL_TEXTURE_2D, self->mpv_texture, 0); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, self->mpv_texture, 0); // Create EGLImage from mpv's texture for cross-context sharing - EGLint image_attribs[] = { EGL_NONE }; + EGLint image_attribs[] = {EGL_NONE}; self->egl_image = _eglCreateImageKHR( - egl_display, egl_context, EGL_GL_TEXTURE_2D_KHR, - (EGLClientBuffer)(uintptr_t)self->mpv_texture, image_attribs); + egl_display, egl_context, EGL_GL_TEXTURE_2D_KHR, (EGLClientBuffer)(uintptr_t)self->mpv_texture, image_attribs); glBindFramebuffer(GL_FRAMEBUFFER, 0); glBindTexture(GL_TEXTURE_2D, 0); @@ -121,12 +119,8 @@ static void ensure_textures(MpvTexture* self, int32_t w, int32_t h) { glBindTexture(GL_TEXTURE_2D, 0); } -static gboolean mpv_texture_populate(FlTextureGL* gl_texture, - uint32_t* target, - uint32_t* name, - uint32_t* width, - uint32_t* height, - GError** error) { +static gboolean mpv_texture_populate( + FlTextureGL* gl_texture, uint32_t* target, uint32_t* name, uint32_t* width, uint32_t* height, GError** error) { MpvTexture* self = MPV_TEXTURE(gl_texture); if (!self->player) { @@ -137,8 +131,7 @@ static gboolean mpv_texture_populate(FlTextureGL* gl_texture, // since Flutter's GL context is current here. if (!self->player->HasRenderContext()) { if (!self->player->InitRenderContext()) { - g_set_error(error, g_quark_from_static_string("mpv"), 0, - "Failed to create mpv render context"); + g_set_error(error, g_quark_from_static_string("mpv"), 0, "Failed to create mpv render context"); return FALSE; } } @@ -201,9 +194,7 @@ static void mpv_texture_init(MpvTexture* self) { self->height = 0; } -MpvTexture* mpv_texture_new(mpv::MpvPlayer* player, - FlTextureRegistrar* registrar, - FlView* view) { +MpvTexture* mpv_texture_new(mpv::MpvPlayer* player, FlTextureRegistrar* registrar, FlView* view) { init_egl_image_extensions(); MpvTexture* self = MPV_TEXTURE(g_object_new(MPV_TEXTURE_TYPE, nullptr)); self->player = player; @@ -214,8 +205,7 @@ MpvTexture* mpv_texture_new(mpv::MpvPlayer* player, void mpv_texture_mark_frame_available(MpvTexture* self) { if (self && self->registrar) { - fl_texture_registrar_mark_texture_frame_available( - self->registrar, FL_TEXTURE(self)); + fl_texture_registrar_mark_texture_frame_available(self->registrar, FL_TEXTURE(self)); } } @@ -268,6 +258,4 @@ void mpv_texture_dispose(MpvTexture* self) { self->view = nullptr; } -int64_t mpv_texture_get_id(MpvTexture* self) { - return fl_texture_get_id(FL_TEXTURE(self)); -} +int64_t mpv_texture_get_id(MpvTexture* self) { return fl_texture_get_id(FL_TEXTURE(self)); } diff --git a/linux/runner/mpv/mpv_texture.h b/linux/runner/mpv/mpv_texture.h index 3992b219..c06e9a5b 100644 --- a/linux/runner/mpv/mpv_texture.h +++ b/linux/runner/mpv/mpv_texture.h @@ -12,9 +12,7 @@ G_BEGIN_DECLS G_DECLARE_FINAL_TYPE(MpvTexture, mpv_texture, MPV, TEXTURE, FlTextureGL) /// Creates a new MpvTexture that renders mpv video to an offscreen FBO. -MpvTexture* mpv_texture_new(mpv::MpvPlayer* player, - FlTextureRegistrar* registrar, - FlView* view); +MpvTexture* mpv_texture_new(mpv::MpvPlayer* player, FlTextureRegistrar* registrar, FlView* view); /// Notifies Flutter that a new frame is available. void mpv_texture_mark_frame_available(MpvTexture* self); diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 1c575a42..3aea47e3 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -1,6 +1,7 @@ #include "my_application.h" #include + #include "flutter/generated_plugin_registrant.h" #include "mpv/mpv_plugin.h" @@ -15,8 +16,7 @@ G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) // Implements GApplication::activate. static void my_application_activate(GApplication* application) { MyApplication* self = MY_APPLICATION(application); - GtkWindow* window = - GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + GtkWindow* window = GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); // Default to traditional titlebar. Set GTK_CSD=1 to use a header bar. gboolean use_header_bar = FALSE; @@ -38,8 +38,7 @@ static void my_application_activate(GApplication* application) { // Create the Flutter view (opaque — no overlay needed). g_autoptr(FlDartProject) project = fl_dart_project_new(); - fl_dart_project_set_dart_entrypoint_arguments(project, - self->dart_entrypoint_arguments); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); self->flutter_view = fl_view_new(project); gtk_widget_show(GTK_WIDGET(self->flutter_view)); @@ -50,8 +49,7 @@ static void my_application_activate(GApplication* application) { // Register the MPV plugin (uses FlTextureGL — no overlay/GtkGLArea needed). FlPluginRegistrar* registrar = - fl_plugin_registry_get_registrar_for_plugin(FL_PLUGIN_REGISTRY(self->flutter_view), - "MpvPlugin"); + fl_plugin_registry_get_registrar_for_plugin(FL_PLUGIN_REGISTRY(self->flutter_view), "MpvPlugin"); mpv_plugin_register_with_registrar(registrar); gtk_widget_show(GTK_WIDGET(window)); @@ -59,9 +57,7 @@ static void my_application_activate(GApplication* application) { } // Implements GApplication::local_command_line. -static gboolean my_application_local_command_line(GApplication* application, - gchar*** arguments, - int* exit_status) { +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { MyApplication* self = MY_APPLICATION(application); // Strip out the first argument as it is the binary name. self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); @@ -98,22 +94,17 @@ static void my_application_dispose(GObject* object) { static void my_application_class_init(MyApplicationClass* klass) { G_APPLICATION_CLASS(klass)->activate = my_application_activate; - G_APPLICATION_CLASS(klass)->local_command_line = - my_application_local_command_line; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; G_APPLICATION_CLASS(klass)->startup = my_application_startup; G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; G_OBJECT_CLASS(klass)->dispose = my_application_dispose; } -static void my_application_init(MyApplication* self) { - self->flutter_view = nullptr; -} +static void my_application_init(MyApplication* self) { self->flutter_view = nullptr; } MyApplication* my_application_new() { g_set_prgname(APPLICATION_ID); - return MY_APPLICATION(g_object_new(my_application_get_type(), - "application-id", APPLICATION_ID, - "flags", G_APPLICATION_NON_UNIQUE, - nullptr)); + return MY_APPLICATION(g_object_new( + my_application_get_type(), "application-id", APPLICATION_ID, "flags", G_APPLICATION_NON_UNIQUE, nullptr)); } diff --git a/linux/runner/my_application.h b/linux/runner/my_application.h index 72271d5e..aaf3b05a 100644 --- a/linux/runner/my_application.h +++ b/linux/runner/my_application.h @@ -3,8 +3,7 @@ #include -G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, - GtkApplication) +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, GtkApplication) /** * my_application_new: diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index 44ac0791..ff3a4c0b 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -24,10 +24,12 @@ class MainFlutterWindow: NSWindow { self.toolbar = toolbar // Register MPV player plugin for video playback - MpvPlayerPlugin.register(with: flutterViewController.registrar(forPlugin: "MpvPlayerPlugin")) + MpvPlayerPlugin.register( + with: flutterViewController.registrar(forPlugin: "MpvPlayerPlugin")) // Register window utils plugin for dynamic titlebar/fullscreen control from Dart - WindowUtilsPlugin.register(with: flutterViewController.registrar(forPlugin: "WindowUtilsPlugin")) + WindowUtilsPlugin.register( + with: flutterViewController.registrar(forPlugin: "WindowUtilsPlugin")) WindowUtilsPlugin.setWindow(self) // Set custom traffic light positions using centralized values from plugin diff --git a/macos/Runner/MpvPlayer/MpvPipController.swift b/macos/Runner/MpvPlayer/MpvPipController.swift index d5378c51..d6be9386 100644 --- a/macos/Runner/MpvPlayer/MpvPipController.swift +++ b/macos/Runner/MpvPlayer/MpvPipController.swift @@ -2,14 +2,14 @@ import Cocoa /// Delegate to notify the plugin of PiP lifecycle events protocol MpvPipDelegate: AnyObject { - func pipWillStart() - func pipDidStart() - /// Called when PiP stops. `restored` is true if the user pressed the close button (restore UI). - func pipDidStop(restored: Bool) - /// Forward play/pause commands from PiP overlay to mpv - func pipSetPlaying(_ playing: Bool) - /// Query whether mpv is currently playing - var isPipPlaying: Bool { get } + func pipWillStart() + func pipDidStart() + /// Called when PiP stops. `restored` is true if the user pressed the close button (restore UI). + func pipDidStop(restored: Bool) + /// Forward play/pause commands from PiP overlay to mpv + func pipSetPlaying(_ playing: Bool) + /// Query whether mpv is currently playing + var isPipPlaying: Bool { get } } /// Encapsulates macOS Picture-in-Picture using the private PIP.framework (PIPViewController). @@ -17,134 +17,134 @@ protocol MpvPipDelegate: AnyObject { /// mpv continues rendering to its CAMetalLayer throughout PiP. class MpvPipController: NSObject, PIPViewControllerDelegate { - // MARK: - Properties + // MARK: - Properties - private lazy var pip: PIPViewController = { - let vc = PIPViewController() - vc.delegate = self - return vc - }() + private lazy var pip: PIPViewController = { + let vc = PIPViewController() + vc.delegate = self + return vc + }() - private var pipVideoVC: NSViewController? - private var pipVideoView: NSView? + private var pipVideoVC: NSViewController? + private var pipVideoView: NSView? - weak var delegate: MpvPipDelegate? - private(set) var isActive = false - var autoPipEnabled = false + weak var delegate: MpvPipDelegate? + private(set) var isActive = false + var autoPipEnabled = false - // Keep reference to the window for restore animation - private weak var sourceWindow: NSWindow? + // Keep reference to the window for restore animation + private weak var sourceWindow: NSWindow? - // MARK: - Public API + // MARK: - Public API - static var isSupported: Bool { true } + static var isSupported: Bool { true } - /// Enter PiP by wrapping the given Metal layer in a view and presenting it. - /// The layer continues receiving mpv frames — no VO switch needed. - func startPip(metalLayer: CAMetalLayer, window: NSWindow, aspectRatio: NSSize) { - guard !isActive else { return } + /// Enter PiP by wrapping the given Metal layer in a view and presenting it. + /// The layer continues receiving mpv frames — no VO switch needed. + func startPip(metalLayer: CAMetalLayer, window: NSWindow, aspectRatio: NSSize) { + guard !isActive else { return } - sourceWindow = window + sourceWindow = window - // Create a layer-hosting wrapper view for the Metal layer. - // PIPViewController resizes the view (and its root layer) as the PiP window resizes. - let videoView = NSView(frame: NSRect(origin: .zero, size: aspectRatio)) - videoView.wantsLayer = true - videoView.layer = metalLayer + // Create a layer-hosting wrapper view for the Metal layer. + // PIPViewController resizes the view (and its root layer) as the PiP window resizes. + let videoView = NSView(frame: NSRect(origin: .zero, size: aspectRatio)) + videoView.wantsLayer = true + videoView.layer = metalLayer - // Reset drawableSize to zero so it auto-derives from the layer's bounds. - // Without this, the explicit drawableSize set by updateFrame() (main window size) - // persists and causes mpv/MoltenVK to render at the wrong resolution in PiP. - metalLayer.drawableSize = .zero + // Reset drawableSize to zero so it auto-derives from the layer's bounds. + // Without this, the explicit drawableSize set by updateFrame() (main window size) + // persists and causes mpv/MoltenVK to render at the wrong resolution in PiP. + metalLayer.drawableSize = .zero - // Create a view controller for PIPViewController - let vc = NSViewController() - vc.view = videoView + // Create a view controller for PIPViewController + let vc = NSViewController() + vc.view = videoView - pipVideoVC = vc - pipVideoView = videoView + pipVideoVC = vc + pipVideoView = videoView - // Configure PiP - pip.playing = delegate?.isPipPlaying ?? false - pip.aspectRatio = aspectRatio - pip.replacementWindow = window - pip.replacementRect = window.contentView?.frame ?? .zero + // Configure PiP + pip.playing = delegate?.isPipPlaying ?? false + pip.aspectRatio = aspectRatio + pip.replacementWindow = window + pip.replacementRect = window.contentView?.frame ?? .zero - delegate?.pipWillStart() + delegate?.pipWillStart() - // Present PiP - pip.presentAsPicture(inPicture: vc) - isActive = true - delegate?.pipDidStart() - } + // Present PiP + pip.presentAsPicture(inPicture: vc) + isActive = true + delegate?.pipDidStart() + } - func stopPip() { - guard isActive else { return } - pip.dismiss(pipVideoVC!) - } + func stopPip() { + guard isActive else { return } + pip.dismiss(pipVideoVC!) + } - /// Update the play/pause button state in the PiP overlay - func setPlaying(_ playing: Bool) { - pip.playing = playing - } + /// Update the play/pause button state in the PiP overlay + func setPlaying(_ playing: Bool) { + pip.playing = playing + } - /// Update the aspect ratio (e.g., when video track changes) - func setAspectRatio(_ size: NSSize) { - pip.aspectRatio = size - } + /// Update the aspect ratio (e.g., when video track changes) + func setAspectRatio(_ size: NSSize) { + pip.aspectRatio = size + } - func setAutoStart(_ enabled: Bool) { - autoPipEnabled = enabled - } + func setAutoStart(_ enabled: Bool) { + autoPipEnabled = enabled + } - /// Clean up after PiP closes — detaches the Metal layer from the wrapper view - /// so MpvPlayerCore can re-add it to the main window. - /// Returns the Metal layer that was hosted in PiP. - @discardableResult - func detachLayer() -> CAMetalLayer? { - let metalLayer = pipVideoView?.layer as? CAMetalLayer - pipVideoView?.layer = CALayer() // detach before removing - pipVideoView = nil - pipVideoVC = nil - return metalLayer - } + /// Clean up after PiP closes — detaches the Metal layer from the wrapper view + /// so MpvPlayerCore can re-add it to the main window. + /// Returns the Metal layer that was hosted in PiP. + @discardableResult + func detachLayer() -> CAMetalLayer? { + let metalLayer = pipVideoView?.layer as? CAMetalLayer + pipVideoView?.layer = CALayer() // detach before removing + pipVideoView = nil + pipVideoVC = nil + return metalLayer + } - // MARK: - PIPViewControllerDelegate + // MARK: - PIPViewControllerDelegate - func pipShouldClose(_ pip: PIPViewController) -> Bool { - prepareForClose() - return true - } + func pipShouldClose(_ pip: PIPViewController) -> Bool { + prepareForClose() + return true + } - func pipWillClose(_ pip: PIPViewController) { - prepareForClose() - } + func pipWillClose(_ pip: PIPViewController) { + prepareForClose() + } - func pipDidClose(_ pip: PIPViewController) { - isActive = false - delegate?.pipDidStop(restored: true) - } + func pipDidClose(_ pip: PIPViewController) { + isActive = false + delegate?.pipDidStop(restored: true) + } - func pipActionPlay(_ pip: PIPViewController) { - delegate?.pipSetPlaying(true) - } + func pipActionPlay(_ pip: PIPViewController) { + delegate?.pipSetPlaying(true) + } - func pipActionPause(_ pip: PIPViewController) { - delegate?.pipSetPlaying(false) - } + func pipActionPause(_ pip: PIPViewController) { + delegate?.pipSetPlaying(false) + } - func pipActionStop(_ pip: PIPViewController) { - delegate?.pipSetPlaying(false) - } + func pipActionStop(_ pip: PIPViewController) { + delegate?.pipSetPlaying(false) + } - // MARK: - Private + // MARK: - Private - private func prepareForClose() { - guard let window = sourceWindow else { return } - pip.replacementWindow = window - pip.replacementRect = window.contentView?.frame ?? .zero - // Bring the main window forward for the restore animation - NSApp.activate(ignoringOtherApps: true) - window.deminiaturize(nil) - } + private func prepareForClose() { + guard let window = sourceWindow else { return } + pip.replacementWindow = window + pip.replacementRect = window.contentView?.frame ?? .zero + // Bring the main window forward for the restore animation + NSApp.activate(ignoringOtherApps: true) + window.deminiaturize(nil) + } } diff --git a/macos/Runner/MpvPlayer/MpvPlayerCore.swift b/macos/Runner/MpvPlayer/MpvPlayerCore.swift index 5cb06d68..c99d7f2e 100644 --- a/macos/Runner/MpvPlayer/MpvPlayerCore.swift +++ b/macos/Runner/MpvPlayer/MpvPlayerCore.swift @@ -4,256 +4,256 @@ import Libmpv /// Core MPV player using Metal rendering. class MpvPlayerCore: MpvPlayerCoreBase { - private weak var window: NSWindow? - private var playbackActivity: NSObjectProtocol? - private var layerHiddenForOcclusion = false + private weak var window: NSWindow? + private var playbackActivity: NSObjectProtocol? + private var layerHiddenForOcclusion = false - func initialize(in window: NSWindow) -> Bool { - guard !isInitialized else { - print("[MpvPlayerCore] Already initialized") - return true - } + func initialize(in window: NSWindow) -> Bool { + guard !isInitialized else { + print("[MpvPlayerCore] Already initialized") + return true + } - guard let contentView = window.contentView else { - print("[MpvPlayerCore] No content view") - return false - } + guard let contentView = window.contentView else { + print("[MpvPlayerCore] No content view") + return false + } - self.window = window + self.window = window - let layer = MpvMetalLayer() - layer.frame = contentView.bounds - if let screen = window.screen ?? NSScreen.main { - layer.contentsScale = screen.backingScaleFactor - } - layer.framebufferOnly = true - layer.isOpaque = true - layer.backgroundColor = NSColor.black.cgColor - layer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable] + let layer = MpvMetalLayer() + layer.frame = contentView.bounds + if let screen = window.screen ?? NSScreen.main { + layer.contentsScale = screen.backingScaleFactor + } + layer.framebufferOnly = true + layer.isOpaque = true + layer.backgroundColor = NSColor.black.cgColor + layer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable] - metalLayer = layer + metalLayer = layer - contentView.wantsLayer = true - contentView.layer?.addSublayer(layer) + contentView.wantsLayer = true + contentView.layer?.addSublayer(layer) - print("[MpvPlayerCore] Metal layer added, frame: \(layer.frame)") + print("[MpvPlayerCore] Metal layer added, frame: \(layer.frame)") - guard setupMpv() else { - print("[MpvPlayerCore] Failed to setup MPV") - layer.removeFromSuperlayer() - metalLayer = nil - return false - } + guard setupMpv() else { + print("[MpvPlayerCore] Failed to setup MPV") + layer.removeFromSuperlayer() + metalLayer = nil + return false + } - let center = NotificationCenter.default - center.addObserver( - self, - selector: #selector(windowWillEnterFullScreen), - name: NSWindow.willEnterFullScreenNotification, - object: window + let center = NotificationCenter.default + center.addObserver( + self, + selector: #selector(windowWillEnterFullScreen), + name: NSWindow.willEnterFullScreenNotification, + object: window + ) + center.addObserver( + self, + selector: #selector(windowDidEnterFullScreen), + name: NSWindow.didEnterFullScreenNotification, + object: window + ) + center.addObserver( + self, + selector: #selector(windowWillExitFullScreen), + name: NSWindow.willExitFullScreenNotification, + object: window + ) + center.addObserver( + self, + selector: #selector(windowDidExitFullScreen), + name: NSWindow.didExitFullScreenNotification, + object: window + ) + center.addObserver( + self, + selector: #selector(windowOcclusionDidChange), + name: NSWindow.didChangeOcclusionStateNotification, + object: window + ) + + isInitialized = true + print("[MpvPlayerCore] Initialized successfully with MPV") + return true + } + + override func configurePlatformMpvOptions() { + guard let mpv else { return } + checkError(mpv_set_option_string(mpv, "ao", "avfoundation,coreaudio")) + // Default fifo (vsync) mode — mailbox was causing continuous GPU rendering even when paused + } + + var videoLayer: CAMetalLayer? { metalLayer } + + func reattachMetalLayer() { + guard let metalLayer, let contentView = window?.contentView else { return } + + if metalLayer.superlayer == nil { + contentView.wantsLayer = true + contentView.layer?.insertSublayer(metalLayer, at: 0) + metalLayer.frame = contentView.bounds + if let screen = window?.screen ?? NSScreen.main { + metalLayer.contentsScale = screen.backingScaleFactor + metalLayer.drawableSize = CGSize( + width: contentView.bounds.width * screen.backingScaleFactor, + height: contentView.bounds.height * screen.backingScaleFactor ) - center.addObserver( - self, - selector: #selector(windowDidEnterFullScreen), - name: NSWindow.didEnterFullScreenNotification, - object: window - ) - center.addObserver( - self, - selector: #selector(windowWillExitFullScreen), - name: NSWindow.willExitFullScreenNotification, - object: window - ) - center.addObserver( - self, - selector: #selector(windowDidExitFullScreen), - name: NSWindow.didExitFullScreenNotification, - object: window - ) - center.addObserver( - self, - selector: #selector(windowOcclusionDidChange), - name: NSWindow.didChangeOcclusionStateNotification, - object: window - ) - - isInitialized = true - print("[MpvPlayerCore] Initialized successfully with MPV") - return true + } } - override func configurePlatformMpvOptions() { - guard let mpv else { return } - checkError(mpv_set_option_string(mpv, "ao", "avfoundation,coreaudio")) - // Default fifo (vsync) mode — mailbox was causing continuous GPU rendering even when paused + print("[MpvPlayerCore] Metal layer reattached to window") + } + + func forceDraw() { + command(["seek", "0", "relative+exact"]) + } + + private var isVisible = false + private var pausedState = true + + func setVisible(_ visible: Bool) { + guard let metalLayer, !isPipActive else { return } + + isVisible = visible + isBackgrounded = !visible + + if visible { + metalLayer.removeFromSuperlayer() + if let superlayer = window?.contentView?.layer { + superlayer.insertSublayer(metalLayer, at: 0) + } + beginPlaybackActivity() + } else { + endPlaybackActivity() } - var videoLayer: CAMetalLayer? { metalLayer } + metalLayer.isHidden = !visible + print("[MpvPlayerCore] setVisible(\(visible))") + } - func reattachMetalLayer() { - guard let metalLayer, let contentView = window?.contentView else { return } + func setPaused(_ paused: Bool) { + pausedState = paused + if paused { + endPlaybackActivity() + } else if isVisible { + beginPlaybackActivity() + } + } - if metalLayer.superlayer == nil { - contentView.wantsLayer = true - contentView.layer?.insertSublayer(metalLayer, at: 0) - metalLayer.frame = contentView.bounds - if let screen = window?.screen ?? NSScreen.main { - metalLayer.contentsScale = screen.backingScaleFactor - metalLayer.drawableSize = CGSize( - width: contentView.bounds.width * screen.backingScaleFactor, - height: contentView.bounds.height * screen.backingScaleFactor - ) - } - } + func updateFrame(_ frame: CGRect? = nil) { + guard let metalLayer, !isPipActive else { return } - print("[MpvPlayerCore] Metal layer reattached to window") + if let frame { + metalLayer.frame = frame + } else if let contentView = window?.contentView { + metalLayer.frame = contentView.bounds } - func forceDraw() { - command(["seek", "0", "relative+exact"]) + if let screen = window?.screen ?? NSScreen.main { + let scale = screen.backingScaleFactor + metalLayer.drawableSize = CGSize( + width: metalLayer.frame.width * scale, + height: metalLayer.frame.height * scale + ) } - private var isVisible = false - private var pausedState = true + print("[MpvPlayerCore] updateFrame: \(metalLayer.frame)") + } - func setVisible(_ visible: Bool) { - guard let metalLayer, !isPipActive else { return } + override func updateEDRMode(sigPeak: Double) { + guard let metalLayer else { return } - isVisible = visible - isBackgrounded = !visible - - if visible { - metalLayer.removeFromSuperlayer() - if let superlayer = window?.contentView?.layer { - superlayer.insertSublayer(metalLayer, at: 0) - } - beginPlaybackActivity() - } else { - endPlaybackActivity() - } - - metalLayer.isHidden = !visible - print("[MpvPlayerCore] setVisible(\(visible))") + var edrHeadroom: CGFloat = 1.0 + if let screen = window?.screen ?? NSScreen.main { + edrHeadroom = screen.maximumExtendedDynamicRangeColorComponentValue } - func setPaused(_ paused: Bool) { - pausedState = paused - if paused { - endPlaybackActivity() - } else if isVisible { - beginPlaybackActivity() - } + let shouldEnableEDR = hdrEnabled && sigPeak > 1.0 && edrHeadroom > 1.0 + metalLayer.wantsExtendedDynamicRangeContent = shouldEnableEDR + + print( + "[MpvPlayerCore] EDR mode: \(shouldEnableEDR) (hdrEnabled: \(hdrEnabled), sigPeak: \(sigPeak), headroom: \(edrHeadroom))" + ) + } + + func dispose() { + endPlaybackActivity() + NotificationCenter.default.removeObserver(self) + disposeSharedState(destroySynchronously: false) + + metalLayer?.removeFromSuperlayer() + metalLayer = nil + isInitialized = false + print("[MpvPlayerCore] Disposed") + } + + deinit { + dispose() + } + + @objc private func windowWillEnterFullScreen(_ notification: Notification) { + guard mpv != nil, !isPipActive else { return } + print("[MpvPlayerCore] willEnterFullScreen - disabling video output") + mpv_set_property_string(mpv, "vid", "no") + } + + @objc private func windowDidEnterFullScreen(_ notification: Notification) { + guard mpv != nil, !isPipActive else { return } + print("[MpvPlayerCore] didEnterFullScreen - re-enabling video output") + mpv_set_property_string(mpv, "vid", "auto") + } + + @objc private func windowWillExitFullScreen(_ notification: Notification) { + guard mpv != nil, !isPipActive else { return } + print("[MpvPlayerCore] willExitFullScreen - disabling video output") + mpv_set_property_string(mpv, "vid", "no") + } + + @objc private func windowDidExitFullScreen(_ notification: Notification) { + guard mpv != nil, !isPipActive else { return } + print("[MpvPlayerCore] didExitFullScreen - re-enabling video output") + mpv_set_property_string(mpv, "vid", "auto") + } + + @objc private func windowOcclusionDidChange(_ notification: Notification) { + guard let metalLayer, mpv != nil, !isPipActive else { return } + + let windowVisible = window?.occlusionState.contains(.visible) ?? true + if !windowVisible && !layerHiddenForOcclusion { + print("[MpvPlayerCore] Window occluded - hiding Metal layer") + metalLayer.isHidden = true + layerHiddenForOcclusion = true + isBackgrounded = true + endPlaybackActivity() + } else if windowVisible && layerHiddenForOcclusion { + print("[MpvPlayerCore] Window visible - showing Metal layer") + layerHiddenForOcclusion = false + metalLayer.isHidden = false + isBackgrounded = false + if !pausedState { + beginPlaybackActivity() + } } + } - func updateFrame(_ frame: CGRect? = nil) { - guard let metalLayer, !isPipActive else { return } + private func beginPlaybackActivity() { + guard playbackActivity == nil else { return } + playbackActivity = ProcessInfo.processInfo.beginActivity( + options: [.userInitiated, .latencyCritical], + reason: "Video playback" + ) + print("[MpvPlayerCore] Began playback activity assertion") + } - if let frame { - metalLayer.frame = frame - } else if let contentView = window?.contentView { - metalLayer.frame = contentView.bounds - } - - if let screen = window?.screen ?? NSScreen.main { - let scale = screen.backingScaleFactor - metalLayer.drawableSize = CGSize( - width: metalLayer.frame.width * scale, - height: metalLayer.frame.height * scale - ) - } - - print("[MpvPlayerCore] updateFrame: \(metalLayer.frame)") - } - - override func updateEDRMode(sigPeak: Double) { - guard let metalLayer else { return } - - var edrHeadroom: CGFloat = 1.0 - if let screen = window?.screen ?? NSScreen.main { - edrHeadroom = screen.maximumExtendedDynamicRangeColorComponentValue - } - - let shouldEnableEDR = hdrEnabled && sigPeak > 1.0 && edrHeadroom > 1.0 - metalLayer.wantsExtendedDynamicRangeContent = shouldEnableEDR - - print( - "[MpvPlayerCore] EDR mode: \(shouldEnableEDR) (hdrEnabled: \(hdrEnabled), sigPeak: \(sigPeak), headroom: \(edrHeadroom))" - ) - } - - func dispose() { - endPlaybackActivity() - NotificationCenter.default.removeObserver(self) - disposeSharedState(destroySynchronously: false) - - metalLayer?.removeFromSuperlayer() - metalLayer = nil - isInitialized = false - print("[MpvPlayerCore] Disposed") - } - - deinit { - dispose() - } - - @objc private func windowWillEnterFullScreen(_ notification: Notification) { - guard mpv != nil, !isPipActive else { return } - print("[MpvPlayerCore] willEnterFullScreen - disabling video output") - mpv_set_property_string(mpv, "vid", "no") - } - - @objc private func windowDidEnterFullScreen(_ notification: Notification) { - guard mpv != nil, !isPipActive else { return } - print("[MpvPlayerCore] didEnterFullScreen - re-enabling video output") - mpv_set_property_string(mpv, "vid", "auto") - } - - @objc private func windowWillExitFullScreen(_ notification: Notification) { - guard mpv != nil, !isPipActive else { return } - print("[MpvPlayerCore] willExitFullScreen - disabling video output") - mpv_set_property_string(mpv, "vid", "no") - } - - @objc private func windowDidExitFullScreen(_ notification: Notification) { - guard mpv != nil, !isPipActive else { return } - print("[MpvPlayerCore] didExitFullScreen - re-enabling video output") - mpv_set_property_string(mpv, "vid", "auto") - } - - @objc private func windowOcclusionDidChange(_ notification: Notification) { - guard let metalLayer, mpv != nil, !isPipActive else { return } - - let windowVisible = window?.occlusionState.contains(.visible) ?? true - if !windowVisible && !layerHiddenForOcclusion { - print("[MpvPlayerCore] Window occluded - hiding Metal layer") - metalLayer.isHidden = true - layerHiddenForOcclusion = true - isBackgrounded = true - endPlaybackActivity() - } else if windowVisible && layerHiddenForOcclusion { - print("[MpvPlayerCore] Window visible - showing Metal layer") - layerHiddenForOcclusion = false - metalLayer.isHidden = false - isBackgrounded = false - if !pausedState { - beginPlaybackActivity() - } - } - } - - private func beginPlaybackActivity() { - guard playbackActivity == nil else { return } - playbackActivity = ProcessInfo.processInfo.beginActivity( - options: [.userInitiated, .latencyCritical], - reason: "Video playback" - ) - print("[MpvPlayerCore] Began playback activity assertion") - } - - private func endPlaybackActivity() { - guard let playbackActivity else { return } - ProcessInfo.processInfo.endActivity(playbackActivity) - self.playbackActivity = nil - print("[MpvPlayerCore] Ended playback activity assertion") - } + private func endPlaybackActivity() { + guard let playbackActivity else { return } + ProcessInfo.processInfo.endActivity(playbackActivity) + self.playbackActivity = nil + print("[MpvPlayerCore] Ended playback activity assertion") + } } diff --git a/macos/Runner/MpvPlayer/MpvPlayerPlugin.swift b/macos/Runner/MpvPlayer/MpvPlayerPlugin.swift index 8606274c..a3abbd2e 100644 --- a/macos/Runner/MpvPlayer/MpvPlayerPlugin.swift +++ b/macos/Runner/MpvPlayer/MpvPlayerPlugin.swift @@ -4,345 +4,371 @@ import FlutterMacOS /// Flutter plugin that bridges MPV player to Dart via method and event channels class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginShared { - // MARK: - Properties + // MARK: - Properties - private var playerCore: MpvPlayerCore? - var eventSink: FlutterEventSink? - private weak var registrar: FlutterPluginRegistrar? - var nameToId: [String: Int] = [:] + private var playerCore: MpvPlayerCore? + var eventSink: FlutterEventSink? + private weak var registrar: FlutterPluginRegistrar? + var nameToId: [String: Int] = [:] - // MpvPluginShared conformance - var coreBase: MpvPlayerCoreBase? { playerCore } - func setPlayerVisible(_ visible: Bool) { playerCore?.setVisible(visible) } - func updatePlayerFrame() { playerCore?.updateFrame() } + // MpvPluginShared conformance + var coreBase: MpvPlayerCoreBase? { playerCore } + func setPlayerVisible(_ visible: Bool) { playerCore?.setVisible(visible) } + func updatePlayerFrame() { playerCore?.updateFrame() } - // PiP - private var pipController: MpvPipController? - private var pipChannel: FlutterMethodChannel? - private var autoPipEnabled = false - private var enteredPipViaAuto = false + // PiP + private var pipController: MpvPipController? + private var pipChannel: FlutterMethodChannel? + private var autoPipEnabled = false + private var enteredPipViaAuto = false - // MARK: - FlutterPlugin Registration + // MARK: - FlutterPlugin Registration - static func register(with registrar: FlutterPluginRegistrar) { - // Method channel for commands - let methodChannel = FlutterMethodChannel( - name: "com.plezy/mpv_player", - binaryMessenger: registrar.messenger - ) + static func register(with registrar: FlutterPluginRegistrar) { + // Method channel for commands + let methodChannel = FlutterMethodChannel( + name: "com.plezy/mpv_player", + binaryMessenger: registrar.messenger + ) - // Event channel for state updates - let eventChannel = FlutterEventChannel( - name: "com.plezy/mpv_player/events", - binaryMessenger: registrar.messenger - ) + // Event channel for state updates + let eventChannel = FlutterEventChannel( + name: "com.plezy/mpv_player/events", + binaryMessenger: registrar.messenger + ) - let pipChannel = FlutterMethodChannel( - name: "com.plezy/pip", - binaryMessenger: registrar.messenger - ) + let pipChannel = FlutterMethodChannel( + name: "com.plezy/pip", + binaryMessenger: registrar.messenger + ) - let instance = MpvPlayerPlugin() - instance.registrar = registrar - instance.pipChannel = pipChannel + let instance = MpvPlayerPlugin() + instance.registrar = registrar + instance.pipChannel = pipChannel - registrar.addMethodCallDelegate(instance, channel: methodChannel) - eventChannel.setStreamHandler(instance) - pipChannel.setMethodCallHandler(instance.handlePipCall) + registrar.addMethodCallDelegate(instance, channel: methodChannel) + eventChannel.setStreamHandler(instance) + pipChannel.setMethodCallHandler(instance.handlePipCall) - print("[MpvPlayerPlugin] Registered with Flutter") + print("[MpvPlayerPlugin] Registered with Flutter") + } + + // MARK: - FlutterStreamHandler + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) + -> FlutterError? + { + self.eventSink = events + print("[MpvPlayerPlugin] Event stream connected") + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + self.eventSink = nil + print("[MpvPlayerPlugin] Event stream disconnected") + return nil + } + + // MARK: - FlutterPlugin Method Handler + + func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "initialize": + handleInitialize(result: result) + + case "dispose": + handleDispose(result: result) + + case "setProperty": + handleSetProperty(call: call, result: result) + + case "getProperty": + handleGetProperty(call: call, result: result) + + case "observeProperty": + handleObserveProperty(call: call, result: result) + + case "command": + handleCommand(call: call, result: result) + + case "setVisible": + handleSetVisible(call: call, result: result) + + case "isInitialized": + result(playerCore?.isInitialized ?? false) + + case "updateFrame": + handleUpdateFrame(result: result) + + case "setLogLevel": + handleSetLogLevel(call: call, result: result) + + default: + result(FlutterMethodNotImplemented) } + } - // MARK: - FlutterStreamHandler + // MARK: - PiP - func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { - self.eventSink = events - print("[MpvPlayerPlugin] Event stream connected") - return nil - } - - func onCancel(withArguments arguments: Any?) -> FlutterError? { - self.eventSink = nil - print("[MpvPlayerPlugin] Event stream disconnected") - return nil - } - - // MARK: - FlutterPlugin Method Handler - - func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - switch call.method { - case "initialize": - handleInitialize(result: result) - - case "dispose": - handleDispose(result: result) - - case "setProperty": - handleSetProperty(call: call, result: result) - - case "getProperty": - handleGetProperty(call: call, result: result) - - case "observeProperty": - handleObserveProperty(call: call, result: result) - - case "command": - handleCommand(call: call, result: result) - - case "setVisible": - handleSetVisible(call: call, result: result) - - case "isInitialized": - result(playerCore?.isInitialized ?? false) - - case "updateFrame": - handleUpdateFrame(result: result) - - case "setLogLevel": - handleSetLogLevel(call: call, result: result) - - default: - result(FlutterMethodNotImplemented) - } - } - - // MARK: - PiP - - private func ensurePipController() -> MpvPipController { - if let existing = pipController { return existing } - let controller = MpvPipController() - controller.delegate = self - pipController = controller - return controller - } - - private func handlePipCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - switch call.method { - case "isSupported": - result(MpvPipController.isSupported) - case "enter": - enterPip(manual: true, result: result) - case "exit": - pipController?.stopPip() - result(nil) - case "setAutoPipReady": - if let args = call.arguments as? [String: Any], let ready = args["ready"] as? Bool { - autoPipEnabled = ready - let pip = ensurePipController() - pip.setAutoStart(ready) - if ready { - // Observe app resigning active to auto-enter PiP - NotificationCenter.default.removeObserver(self, name: NSApplication.didResignActiveNotification, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(appDidResignActive), name: NSApplication.didResignActiveNotification, object: nil) - // Observe app becoming active to auto-exit PiP - NotificationCenter.default.removeObserver(self, name: NSApplication.didBecomeActiveNotification, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(appDidBecomeActive), name: NSApplication.didBecomeActiveNotification, object: nil) - } else { - NotificationCenter.default.removeObserver(self, name: NSApplication.didResignActiveNotification, object: nil) - NotificationCenter.default.removeObserver(self, name: NSApplication.didBecomeActiveNotification, object: nil) - } - } - result(nil) - default: - result(FlutterMethodNotImplemented) - } - } - - /// Enter PiP by moving the Metal rendering layer to a PiP window. - /// No VO switching — mpv keeps rendering to the same Metal layer. - private func enterPip(manual: Bool, result: FlutterResult? = nil) { - guard let playerCore = playerCore else { - result?(["success": false, "errorCode": "failed", "errorMessage": "Player not initialized"]) - return - } - guard let metalLayer = playerCore.videoLayer else { - result?(["success": false, "errorCode": "failed", "errorMessage": "No video layer"]) - return - } - guard let window = findFlutterWindow()?.0 else { - result?(["success": false, "errorCode": "failed", "errorMessage": "No window"]) - return - } + private func ensurePipController() -> MpvPipController { + if let existing = pipController { return existing } + let controller = MpvPipController() + controller.delegate = self + pipController = controller + return controller + } + private func handlePipCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "isSupported": + result(MpvPipController.isSupported) + case "enter": + enterPip(manual: true, result: result) + case "exit": + pipController?.stopPip() + result(nil) + case "setAutoPipReady": + if let args = call.arguments as? [String: Any], let ready = args["ready"] as? Bool { + autoPipEnabled = ready let pip = ensurePipController() - guard !pip.isActive else { - result?(["success": false, "errorCode": "failed", "errorMessage": "PiP already active"]) - return + pip.setAutoStart(ready) + if ready { + // Observe app resigning active to auto-enter PiP + NotificationCenter.default.removeObserver( + self, name: NSApplication.didResignActiveNotification, object: nil) + NotificationCenter.default.addObserver( + self, selector: #selector(appDidResignActive), + name: NSApplication.didResignActiveNotification, object: nil) + // Observe app becoming active to auto-exit PiP + NotificationCenter.default.removeObserver( + self, name: NSApplication.didBecomeActiveNotification, object: nil) + NotificationCenter.default.addObserver( + self, selector: #selector(appDidBecomeActive), + name: NSApplication.didBecomeActiveNotification, object: nil) + } else { + NotificationCenter.default.removeObserver( + self, name: NSApplication.didResignActiveNotification, object: nil) + NotificationCenter.default.removeObserver( + self, name: NSApplication.didBecomeActiveNotification, object: nil) } + } + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } - // Get video dimensions for aspect ratio - var aspectRatio = NSSize(width: 16, height: 9) // default - if let w = playerCore.getProperty("width"), let h = playerCore.getProperty("height"), - let width = Double(w), let height = Double(h), width > 0 && height > 0 { - aspectRatio = NSSize(width: width, height: height) - } - - enteredPipViaAuto = !manual - playerCore.isPipActive = true - - pip.startPip(metalLayer: metalLayer, window: window, aspectRatio: aspectRatio) - pipChannel?.invokeMethod("onPipChanged", arguments: true) - result?(["success": true]) + /// Enter PiP by moving the Metal rendering layer to a PiP window. + /// No VO switching — mpv keeps rendering to the same Metal layer. + private func enterPip(manual: Bool, result: FlutterResult? = nil) { + guard let playerCore = playerCore else { + result?([ + "success": false, "errorCode": "failed", "errorMessage": "Player not initialized", + ]) + return + } + guard let metalLayer = playerCore.videoLayer else { + result?(["success": false, "errorCode": "failed", "errorMessage": "No video layer"]) + return + } + guard let window = findFlutterWindow()?.0 else { + result?(["success": false, "errorCode": "failed", "errorMessage": "No window"]) + return } - /// App resigned active — auto-enter PiP if enabled and playing - @objc private func appDidResignActive() { - guard autoPipEnabled, - let pc = playerCore, - !pc.isPipActive, - !pc.isPaused, - pipController?.autoPipEnabled == true else { return } - print("[MpvPlayerPlugin] Auto-PiP: app resigned active, entering PiP") - enterPip(manual: false) + let pip = ensurePipController() + guard !pip.isActive else { + result?(["success": false, "errorCode": "failed", "errorMessage": "PiP already active"]) + return } - /// App became active — auto-exit PiP if it was entered automatically - @objc private func appDidBecomeActive() { - guard enteredPipViaAuto, let pip = pipController, pip.isActive else { return } - print("[MpvPlayerPlugin] Auto-PiP: app became active, exiting PiP") + // Get video dimensions for aspect ratio + var aspectRatio = NSSize(width: 16, height: 9) // default + if let w = playerCore.getProperty("width"), let h = playerCore.getProperty("height"), + let width = Double(w), let height = Double(h), width > 0 && height > 0 + { + aspectRatio = NSSize(width: width, height: height) + } + + enteredPipViaAuto = !manual + playerCore.isPipActive = true + + pip.startPip(metalLayer: metalLayer, window: window, aspectRatio: aspectRatio) + pipChannel?.invokeMethod("onPipChanged", arguments: true) + result?(["success": true]) + } + + /// App resigned active — auto-enter PiP if enabled and playing + @objc private func appDidResignActive() { + guard autoPipEnabled, + let pc = playerCore, + !pc.isPipActive, + !pc.isPaused, + pipController?.autoPipEnabled == true + else { return } + print("[MpvPlayerPlugin] Auto-PiP: app resigned active, entering PiP") + enterPip(manual: false) + } + + /// App became active — auto-exit PiP if it was entered automatically + @objc private func appDidBecomeActive() { + guard enteredPipViaAuto, let pip = pipController, pip.isActive else { return } + print("[MpvPlayerPlugin] Auto-PiP: app became active, exiting PiP") + pip.stopPip() + } + + // MARK: - Platform-Specific Method Handlers + + private func handleInitialize(result: @escaping FlutterResult) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { + result(FlutterError(code: "ERROR", message: "Plugin deallocated", details: nil)) + return + } + + // Check if already initialized + if self.playerCore?.isInitialized == true { + print("[MpvPlayerPlugin] Already initialized") + result(true) + return + } + + // Find the Flutter window + guard let (window, _, _) = self.findFlutterWindow() else { + print("[MpvPlayerPlugin] Failed to find Flutter window") + result( + FlutterError( + code: "NO_WINDOW", message: "Could not find Flutter window", details: nil)) + return + } + + // Create and initialize player core + let core = MpvPlayerCore() + core.delegate = self + + guard core.initialize(in: window) else { + print("[MpvPlayerPlugin] Failed to initialize MPV") + result( + FlutterError( + code: "MPV_INIT_FAILED", message: "Failed to initialize MPV", details: nil)) + return + } + + self.playerCore = core + + // Start hidden + core.setVisible(false) + + print("[MpvPlayerPlugin] Initialized successfully") + result(true) + } + } + + private func handleDispose(result: @escaping FlutterResult) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { result(nil); return } + if let pip = self.pipController, pip.isActive { pip.stopPip() + pip.detachLayer() + } + self.pipController = nil + self.autoPipEnabled = false + NotificationCenter.default.removeObserver( + self, name: NSApplication.didResignActiveNotification, object: nil) + NotificationCenter.default.removeObserver( + self, name: NSApplication.didBecomeActiveNotification, object: nil) + self.playerCore?.dispose() + self.playerCore = nil + print("[MpvPlayerPlugin] Disposed") + result(nil) + } + } + + private func handleSetProperty(call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let args = call.arguments as? [String: Any], + let name = args["name"] as? String, + let value = args["value"] as? String + else { + result( + FlutterError( + code: "INVALID_ARGS", message: "Missing 'name' or 'value' argument", + details: nil)) + return } - // MARK: - Platform-Specific Method Handlers + playerCore?.setProperty(name, value: value) - private func handleInitialize(result: @escaping FlutterResult) { - DispatchQueue.main.async { [weak self] in - guard let self = self else { - result(FlutterError(code: "ERROR", message: "Plugin deallocated", details: nil)) - return - } - - // Check if already initialized - if self.playerCore?.isInitialized == true { - print("[MpvPlayerPlugin] Already initialized") - result(true) - return - } - - // Find the Flutter window - guard let (window, _, _) = self.findFlutterWindow() else { - print("[MpvPlayerPlugin] Failed to find Flutter window") - result(FlutterError(code: "NO_WINDOW", message: "Could not find Flutter window", details: nil)) - return - } - - // Create and initialize player core - let core = MpvPlayerCore() - core.delegate = self - - guard core.initialize(in: window) else { - print("[MpvPlayerPlugin] Failed to initialize MPV") - result(FlutterError(code: "MPV_INIT_FAILED", message: "Failed to initialize MPV", details: nil)) - return - } - - self.playerCore = core - - // Start hidden - core.setVisible(false) - - print("[MpvPlayerPlugin] Initialized successfully") - result(true) - } + if name == "pause" { + let isPlaying = value == "no" + pipController?.setPlaying(isPlaying) + playerCore?.setPaused(!isPlaying) } - private func handleDispose(result: @escaping FlutterResult) { - DispatchQueue.main.async { [weak self] in - guard let self = self else { result(nil); return } - if let pip = self.pipController, pip.isActive { - pip.stopPip() - pip.detachLayer() - } - self.pipController = nil - self.autoPipEnabled = false - NotificationCenter.default.removeObserver(self, name: NSApplication.didResignActiveNotification, object: nil) - NotificationCenter.default.removeObserver(self, name: NSApplication.didBecomeActiveNotification, object: nil) - self.playerCore?.dispose() - self.playerCore = nil - print("[MpvPlayerPlugin] Disposed") - result(nil) - } + result(nil) + } + + // MARK: - Helpers + + private func findFlutterWindow() -> (NSWindow, NSView, NSView)? { + for window in NSApplication.shared.windows { + if window is MainFlutterWindow, + let contentView = window.contentView, + let contentVC = window.contentViewController + { + let flutterView = contentVC.view + return (window, contentView, flutterView) + } } - private func handleSetProperty(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let args = call.arguments as? [String: Any], - let name = args["name"] as? String, - let value = args["value"] as? String else { - result(FlutterError(code: "INVALID_ARGS", message: "Missing 'name' or 'value' argument", details: nil)) - return - } - - playerCore?.setProperty(name, value: value) - - if name == "pause" { - let isPlaying = value == "no" - pipController?.setPlaying(isPlaying) - playerCore?.setPaused(!isPlaying) - } - - result(nil) + // Fallback + for window in NSApplication.shared.windows { + if let contentView = window.contentView, + let contentVC = window.contentViewController + { + let flutterView = contentVC.view + return (window, contentView, flutterView) + } } - // MARK: - Helpers - - private func findFlutterWindow() -> (NSWindow, NSView, NSView)? { - for window in NSApplication.shared.windows { - if window is MainFlutterWindow, - let contentView = window.contentView, - let contentVC = window.contentViewController { - let flutterView = contentVC.view - return (window, contentView, flutterView) - } - } - - // Fallback - for window in NSApplication.shared.windows { - if let contentView = window.contentView, - let contentVC = window.contentViewController { - let flutterView = contentVC.view - return (window, contentView, flutterView) - } - } - - return nil - } + return nil + } } // MARK: - MpvPipDelegate extension MpvPlayerPlugin: MpvPipDelegate { - func pipWillStart() { - print("[MpvPlayerPlugin] PiP will start") + func pipWillStart() { + print("[MpvPlayerPlugin] PiP will start") + } + + func pipDidStart() { + print("[MpvPlayerPlugin] PiP did start") + } + + func pipDidStop(restored: Bool) { + print("[MpvPlayerPlugin] PiP did stop (restored: \(restored))") + playerCore?.isPipActive = false + enteredPipViaAuto = false + + // Detach the Metal layer from the PiP wrapper view + pipController?.detachLayer() + + // Re-attach the Metal layer to the main window + playerCore?.reattachMetalLayer() + + // Force a redraw if paused (prevents black frame after PiP exit) + if playerCore?.isPaused == true { + playerCore?.forceDraw() } - func pipDidStart() { - print("[MpvPlayerPlugin] PiP did start") - } + pipChannel?.invokeMethod("onPipChanged", arguments: false) + } - func pipDidStop(restored: Bool) { - print("[MpvPlayerPlugin] PiP did stop (restored: \(restored))") - playerCore?.isPipActive = false - enteredPipViaAuto = false + func pipSetPlaying(_ playing: Bool) { + playerCore?.setProperty("pause", value: playing ? "no" : "yes") + pipController?.setPlaying(playing) + } - // Detach the Metal layer from the PiP wrapper view - pipController?.detachLayer() - - // Re-attach the Metal layer to the main window - playerCore?.reattachMetalLayer() - - // Force a redraw if paused (prevents black frame after PiP exit) - if playerCore?.isPaused == true { - playerCore?.forceDraw() - } - - pipChannel?.invokeMethod("onPipChanged", arguments: false) - } - - func pipSetPlaying(_ playing: Bool) { - playerCore?.setProperty("pause", value: playing ? "no" : "yes") - pipController?.setPlaying(playing) - } - - var isPipPlaying: Bool { !(playerCore?.isPaused ?? true) } + var isPipPlaying: Bool { !(playerCore?.isPaused ?? true) } } diff --git a/macos/Runner/Runner-Bridging-Header.h b/macos/Runner/Runner-Bridging-Header.h index e260a0fa..46a4e120 100644 --- a/macos/Runner/Runner-Bridging-Header.h +++ b/macos/Runner/Runner-Bridging-Header.h @@ -6,14 +6,14 @@ @interface PIPViewController : NSViewController -@property (nonatomic, copy, nullable) NSString *name; -@property (nonatomic, weak, nullable) id delegate; -@property (nonatomic, weak, nullable) NSWindow *replacementWindow; -@property (nonatomic) NSRect replacementRect; -@property (nonatomic) bool playing; -@property (nonatomic) NSSize aspectRatio; +@property(nonatomic, copy, nullable) NSString* name; +@property(nonatomic, weak, nullable) id delegate; +@property(nonatomic, weak, nullable) NSWindow* replacementWindow; +@property(nonatomic) NSRect replacementRect; +@property(nonatomic) bool playing; +@property(nonatomic) NSSize aspectRatio; -- (void)presentViewControllerAsPictureInPicture:(NSViewController *)viewController; +- (void)presentViewControllerAsPictureInPicture:(NSViewController*)viewController; @end @@ -21,12 +21,12 @@ @optional // macOS 10.12-10.14 -- (BOOL)pipShouldClose:(PIPViewController *)pip; +- (BOOL)pipShouldClose:(PIPViewController*)pip; // macOS 10.15+ -- (void)pipWillClose:(PIPViewController *)pip; -- (void)pipDidClose:(PIPViewController *)pip; -- (void)pipActionPlay:(PIPViewController *)pip; -- (void)pipActionPause:(PIPViewController *)pip; -- (void)pipActionStop:(PIPViewController *)pip; +- (void)pipWillClose:(PIPViewController*)pip; +- (void)pipDidClose:(PIPViewController*)pip; +- (void)pipActionPlay:(PIPViewController*)pip; +- (void)pipActionPause:(PIPViewController*)pip; +- (void)pipActionStop:(PIPViewController*)pip; @end diff --git a/macos/Runner/WindowDelegate.swift b/macos/Runner/WindowDelegate.swift index 394a1d49..d07ca0fb 100644 --- a/macos/Runner/WindowDelegate.swift +++ b/macos/Runner/WindowDelegate.swift @@ -2,64 +2,67 @@ import Cocoa import FlutterMacOS class WindowDelegate: NSObject, NSWindowDelegate { - weak var channel: FlutterMethodChannel? - weak var window: NSWindow? + weak var channel: FlutterMethodChannel? + weak var window: NSWindow? - // Hardcoded presentation options for fullscreen mode - // Auto-hide toolbar, menu bar, and dock when in fullscreen - private let fullScreenPresentationOptions: NSApplication.PresentationOptions = [ - .fullScreen, - .autoHideToolbar, - .autoHideMenuBar, - .autoHideDock - ] + // Hardcoded presentation options for fullscreen mode + // Auto-hide toolbar, menu bar, and dock when in fullscreen + private let fullScreenPresentationOptions: NSApplication.PresentationOptions = [ + .fullScreen, + .autoHideToolbar, + .autoHideMenuBar, + .autoHideDock, + ] - // MARK: - Private Helpers + // MARK: - Private Helpers - private func emit(_ method: String) { - channel?.invokeMethod(method, arguments: nil) - } + private func emit(_ method: String) { + channel?.invokeMethod(method, arguments: nil) + } - // MARK: - NSWindowDelegate + // MARK: - NSWindowDelegate - func window(_ window: NSWindow, willUseFullScreenPresentationOptions proposedOptions: NSApplication.PresentationOptions) -> NSApplication.PresentationOptions { - return fullScreenPresentationOptions - } + func window( + _ window: NSWindow, + willUseFullScreenPresentationOptions proposedOptions: NSApplication.PresentationOptions + ) -> NSApplication.PresentationOptions { + return fullScreenPresentationOptions + } - func windowWillEnterFullScreen(_ notification: Notification) { - guard let window = window else { return } - // Remove toolbar before entering fullscreen - window.toolbar = nil - // Show title and make titlebar opaque for native fullscreen look - window.titleVisibility = .visible - window.titlebarAppearsTransparent = false - // Reset traffic light positions to default - WindowUtilsPlugin.setTrafficLightPositions(custom: false, window: window) - // Notify Dart for state management only - emit("windowWillEnterFullScreen") - } + func windowWillEnterFullScreen(_ notification: Notification) { + guard let window = window else { return } + // Remove toolbar before entering fullscreen + window.toolbar = nil + // Show title and make titlebar opaque for native fullscreen look + window.titleVisibility = .visible + window.titlebarAppearsTransparent = false + // Reset traffic light positions to default + WindowUtilsPlugin.setTrafficLightPositions(custom: false, window: window) + // Notify Dart for state management only + emit("windowWillEnterFullScreen") + } - func windowDidEnterFullScreen(_ notification: Notification) { - emit("windowDidEnterFullScreen") - } + func windowDidEnterFullScreen(_ notification: Notification) { + emit("windowDidEnterFullScreen") + } - func windowWillExitFullScreen(_ notification: Notification) { - guard let window = window else { return } - // Hide title and make titlebar transparent BEFORE exiting - window.titleVisibility = .hidden - window.titlebarAppearsTransparent = true - emit("windowWillExitFullScreen") - } + func windowWillExitFullScreen(_ notification: Notification) { + guard let window = window else { return } + // Hide title and make titlebar transparent BEFORE exiting + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + emit("windowWillExitFullScreen") + } - func windowDidExitFullScreen(_ notification: Notification) { - guard let window = window else { return } - // Restore toolbar - if let flutterVC = window.contentViewController { - let toolbar = ForwardingToolbar(flutterViewController: flutterVC) - window.toolbar = toolbar - } - // Restore custom traffic light positions - WindowUtilsPlugin.setTrafficLightPositions(custom: true, window: window) - emit("windowDidExitFullScreen") + func windowDidExitFullScreen(_ notification: Notification) { + guard let window = window else { return } + // Restore toolbar + if let flutterVC = window.contentViewController { + let toolbar = ForwardingToolbar(flutterViewController: flutterVC) + window.toolbar = toolbar } + // Restore custom traffic light positions + WindowUtilsPlugin.setTrafficLightPositions(custom: true, window: window) + emit("windowDidExitFullScreen") + } } diff --git a/macos/Runner/WindowUtilsPlugin.swift b/macos/Runner/WindowUtilsPlugin.swift index b3e84962..2c2ac970 100644 --- a/macos/Runner/WindowUtilsPlugin.swift +++ b/macos/Runner/WindowUtilsPlugin.swift @@ -4,221 +4,231 @@ import FlutterMacOS // MARK: - ForwardingView // A view that forwards mouse events to the Flutter view controller class ForwardingView: NSView { - weak var flutterViewController: NSViewController? + weak var flutterViewController: NSViewController? - override func mouseDown(with event: NSEvent) { - flutterViewController?.mouseDown(with: event) - } + override func mouseDown(with event: NSEvent) { + flutterViewController?.mouseDown(with: event) + } - override func mouseUp(with event: NSEvent) { - flutterViewController?.mouseUp(with: event) - } + override func mouseUp(with event: NSEvent) { + flutterViewController?.mouseUp(with: event) + } } // MARK: - ForwardingToolbar // A custom toolbar that forwards mouse events from the toolbar area to Flutter class ForwardingToolbar: NSToolbar, NSToolbarDelegate { - let flutterViewController: NSViewController + let flutterViewController: NSViewController - init(flutterViewController: NSViewController) { - self.flutterViewController = flutterViewController - super.init(identifier: "ForwardingToolbar") - self.delegate = self - self.showsBaselineSeparator = false + init(flutterViewController: NSViewController) { + self.flutterViewController = flutterViewController + super.init(identifier: "ForwardingToolbar") + self.delegate = self + self.showsBaselineSeparator = false - // Prevent toolbar customization UI (the "rounded box") - self.allowsUserCustomization = false - self.allowsExtensionItems = false - if #available(macOS 15.0, *) { - self.allowsDisplayModeCustomization = false - } + // Prevent toolbar customization UI (the "rounded box") + self.allowsUserCustomization = false + self.allowsExtensionItems = false + if #available(macOS 15.0, *) { + self.allowsDisplayModeCustomization = false } + } - func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - [.flexibleSpace, NSToolbarItem.Identifier("ForwardingItem")] - } + func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + [.flexibleSpace, NSToolbarItem.Identifier("ForwardingItem")] + } - func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - toolbarDefaultItemIdentifiers(toolbar) - } + func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + toolbarDefaultItemIdentifiers(toolbar) + } - func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { - if itemIdentifier == NSToolbarItem.Identifier("ForwardingItem") { - let item = NSToolbarItem(itemIdentifier: itemIdentifier) - item.isBordered = false // Remove the rounded box appearance - let view = ForwardingView() - view.flutterViewController = flutterViewController - view.widthAnchor.constraint(lessThanOrEqualToConstant: 100000).isActive = true - view.widthAnchor.constraint(greaterThanOrEqualToConstant: 1).isActive = true - item.view = view - return item - } - return nil + func toolbar( + _ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, + willBeInsertedIntoToolbar flag: Bool + ) -> NSToolbarItem? { + if itemIdentifier == NSToolbarItem.Identifier("ForwardingItem") { + let item = NSToolbarItem(itemIdentifier: itemIdentifier) + item.isBordered = false // Remove the rounded box appearance + let view = ForwardingView() + view.flutterViewController = flutterViewController + view.widthAnchor.constraint(lessThanOrEqualToConstant: 100000).isActive = true + view.widthAnchor.constraint(greaterThanOrEqualToConstant: 1).isActive = true + item.view = view + return item } + return nil + } } // MARK: - WindowUtilsPlugin class WindowUtilsPlugin: NSObject, FlutterPlugin { - private static var instance: WindowUtilsPlugin? - private var channel: FlutterMethodChannel? - private weak var window: NSWindow? - private var windowDelegate: WindowDelegate? - private var originalButtonConstraints: [NSWindow.ButtonType: [NSLayoutConstraint]] = [:] + private static var instance: WindowUtilsPlugin? + private var channel: FlutterMethodChannel? + private weak var window: NSWindow? + private var windowDelegate: WindowDelegate? + private var originalButtonConstraints: [NSWindow.ButtonType: [NSLayoutConstraint]] = [:] - // Centralized traffic light positions - the single source of truth - private static let customButtonPositions: [(NSWindow.ButtonType, CGPoint)] = [ - (.closeButton, CGPoint(x: 20, y: 21)), - (.miniaturizeButton, CGPoint(x: 40, y: 21)), - (.zoomButton, CGPoint(x: 60, y: 21)) - ] + // Centralized traffic light positions - the single source of truth + private static let customButtonPositions: [(NSWindow.ButtonType, CGPoint)] = [ + (.closeButton, CGPoint(x: 20, y: 21)), + (.miniaturizeButton, CGPoint(x: 40, y: 21)), + (.zoomButton, CGPoint(x: 60, y: 21)), + ] - static func register(with registrar: FlutterPluginRegistrar) { - let channel = FlutterMethodChannel( - name: "com.plezy/window_utils", - binaryMessenger: registrar.messenger - ) - let instance = WindowUtilsPlugin() - instance.channel = channel - registrar.addMethodCallDelegate(instance, channel: channel) - self.instance = instance + static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: "com.plezy/window_utils", + binaryMessenger: registrar.messenger + ) + let instance = WindowUtilsPlugin() + instance.channel = channel + registrar.addMethodCallDelegate(instance, channel: channel) + self.instance = instance + } + + static func setWindow(_ window: NSWindow) { + instance?.window = window + } + + /// Apply custom traffic light positions. Called by MainFlutterWindow on startup. + static func setInitialTrafficLightPositions() { + guard let instance = instance, let window = instance.window else { return } + instance.applyTrafficLightPositions(custom: true, window: window) + } + + /// Apply traffic light positions. Called by WindowDelegate during fullscreen transitions. + static func setTrafficLightPositions(custom: Bool, window: NSWindow) { + guard let instance = instance else { return } + instance.applyTrafficLightPositions(custom: custom, window: window) + } + + private func applyTrafficLightPositions(custom: Bool, window: NSWindow) { + if custom { + for (buttonType, offset) in WindowUtilsPlugin.customButtonPositions { + overrideButtonPosition(window: window, buttonType: buttonType, offset: offset) + } + } else { + for (buttonType, _) in WindowUtilsPlugin.customButtonPositions { + resetButtonPosition(window: window, buttonType: buttonType) + } + } + } + + func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let window = window else { + result(FlutterError(code: "NO_WINDOW", message: "Window not available", details: nil)) + return } - static func setWindow(_ window: NSWindow) { - instance?.window = window - } + switch call.method { + case "initialize": + let args = call.arguments as? [String: Any] + let enableWindowDelegate = args?["enableWindowDelegate"] as? Bool ?? false + initialize(window: window, enableWindowDelegate: enableWindowDelegate) + result(nil) - /// Apply custom traffic light positions. Called by MainFlutterWindow on startup. - static func setInitialTrafficLightPositions() { - guard let instance = instance, let window = instance.window else { return } - instance.applyTrafficLightPositions(custom: true, window: window) - } + case "setTrafficLightsVisible": + let args = call.arguments as? [String: Any] + let visible = args?["visible"] as? Bool ?? true + for buttonType in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] { + window.standardWindowButton(buttonType)?.isHidden = !visible + } + result(nil) - /// Apply traffic light positions. Called by WindowDelegate during fullscreen transitions. - static func setTrafficLightPositions(custom: Bool, window: NSWindow) { - guard let instance = instance else { return } - instance.applyTrafficLightPositions(custom: custom, window: window) - } + case "enterFullscreen": + if !window.styleMask.contains(.fullScreen) { + window.toggleFullScreen(nil) + } + result(nil) - private func applyTrafficLightPositions(custom: Bool, window: NSWindow) { - if custom { - for (buttonType, offset) in WindowUtilsPlugin.customButtonPositions { - overrideButtonPosition(window: window, buttonType: buttonType, offset: offset) - } - } else { - for (buttonType, _) in WindowUtilsPlugin.customButtonPositions { - resetButtonPosition(window: window, buttonType: buttonType) - } + case "exitFullscreen": + if window.styleMask.contains(.fullScreen) { + window.toggleFullScreen(nil) + } + result(nil) + + case "isFullscreen": + result(window.styleMask.contains(.fullScreen)) + + default: + result(FlutterMethodNotImplemented) + } + } + + private func initialize(window: NSWindow, enableWindowDelegate: Bool) { + self.window = window + + if enableWindowDelegate { + let delegate = WindowDelegate() + delegate.channel = channel + delegate.window = window + windowDelegate = delegate + window.delegate = delegate + } + } + + private func withButton( + _ buttonType: NSWindow.ButtonType, + in window: NSWindow, + action: (NSButton, NSView) -> Void + ) { + guard let button = window.standardWindowButton(buttonType), + let superview = button.superview + else { return } + action(button, superview) + } + + private func positionConstraints(for button: NSButton, in superview: NSView) + -> [NSLayoutConstraint] + { + superview.constraints.filter { constraint in + ((constraint.firstItem as? NSButton) == button + || (constraint.secondItem as? NSButton) == button) + && (constraint.firstAttribute == .left || constraint.firstAttribute == .leading + || constraint.firstAttribute == .top || constraint.firstAttribute == .centerY) + } + } + + private func overrideButtonPosition( + window: NSWindow, buttonType: NSWindow.ButtonType, offset: CGPoint + ) { + withButton(buttonType, in: window) { button, superview in + // Store original constraints if not already stored + if originalButtonConstraints[buttonType] == nil { + let constraints = superview.constraints.filter { constraint in + (constraint.firstItem as? NSButton) == button + || (constraint.secondItem as? NSButton) == button } + originalButtonConstraints[buttonType] = constraints + } + + // Remove existing position constraints + superview.removeConstraints(positionConstraints(for: button, in: superview)) + + button.translatesAutoresizingMaskIntoConstraints = false + + // Add new positioning constraints + superview.addConstraints([ + button.leftAnchor.constraint(equalTo: superview.leftAnchor, constant: offset.x), + button.topAnchor.constraint(equalTo: superview.topAnchor, constant: offset.y), + ]) + superview.layoutSubtreeIfNeeded() } + } - func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let window = window else { - result(FlutterError(code: "NO_WINDOW", message: "Window not available", details: nil)) - return - } + private func resetButtonPosition(window: NSWindow, buttonType: NSWindow.ButtonType) { + withButton(buttonType, in: window) { button, superview in + // Remove custom constraints + superview.removeConstraints(positionConstraints(for: button, in: superview)) - switch call.method { - case "initialize": - let args = call.arguments as? [String: Any] - let enableWindowDelegate = args?["enableWindowDelegate"] as? Bool ?? false - initialize(window: window, enableWindowDelegate: enableWindowDelegate) - result(nil) + // Restore original constraints if we have them + if let originalConstraints = originalButtonConstraints[buttonType] { + superview.addConstraints(originalConstraints) + originalButtonConstraints.removeValue(forKey: buttonType) + } - case "setTrafficLightsVisible": - let args = call.arguments as? [String: Any] - let visible = args?["visible"] as? Bool ?? true - for buttonType in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] { - window.standardWindowButton(buttonType)?.isHidden = !visible - } - result(nil) - - case "enterFullscreen": - if !window.styleMask.contains(.fullScreen) { - window.toggleFullScreen(nil) - } - result(nil) - - case "exitFullscreen": - if window.styleMask.contains(.fullScreen) { - window.toggleFullScreen(nil) - } - result(nil) - - case "isFullscreen": - result(window.styleMask.contains(.fullScreen)) - - default: - result(FlutterMethodNotImplemented) - } - } - - private func initialize(window: NSWindow, enableWindowDelegate: Bool) { - self.window = window - - if enableWindowDelegate { - let delegate = WindowDelegate() - delegate.channel = channel - delegate.window = window - windowDelegate = delegate - window.delegate = delegate - } - } - - private func withButton( - _ buttonType: NSWindow.ButtonType, - in window: NSWindow, - action: (NSButton, NSView) -> Void - ) { - guard let button = window.standardWindowButton(buttonType), - let superview = button.superview else { return } - action(button, superview) - } - - private func positionConstraints(for button: NSButton, in superview: NSView) -> [NSLayoutConstraint] { - superview.constraints.filter { constraint in - ((constraint.firstItem as? NSButton) == button || (constraint.secondItem as? NSButton) == button) && - (constraint.firstAttribute == .left || constraint.firstAttribute == .leading || - constraint.firstAttribute == .top || constraint.firstAttribute == .centerY) - } - } - - private func overrideButtonPosition(window: NSWindow, buttonType: NSWindow.ButtonType, offset: CGPoint) { - withButton(buttonType, in: window) { button, superview in - // Store original constraints if not already stored - if originalButtonConstraints[buttonType] == nil { - let constraints = superview.constraints.filter { constraint in - (constraint.firstItem as? NSButton) == button || (constraint.secondItem as? NSButton) == button - } - originalButtonConstraints[buttonType] = constraints - } - - // Remove existing position constraints - superview.removeConstraints(positionConstraints(for: button, in: superview)) - - button.translatesAutoresizingMaskIntoConstraints = false - - // Add new positioning constraints - superview.addConstraints([ - button.leftAnchor.constraint(equalTo: superview.leftAnchor, constant: offset.x), - button.topAnchor.constraint(equalTo: superview.topAnchor, constant: offset.y) - ]) - superview.layoutSubtreeIfNeeded() - } - } - - private func resetButtonPosition(window: NSWindow, buttonType: NSWindow.ButtonType) { - withButton(buttonType, in: window) { button, superview in - // Remove custom constraints - superview.removeConstraints(positionConstraints(for: button, in: superview)) - - // Restore original constraints if we have them - if let originalConstraints = originalButtonConstraints[buttonType] { - superview.addConstraints(originalConstraints) - originalButtonConstraints.removeValue(forKey: buttonType) - } - - button.translatesAutoresizingMaskIntoConstraints = true - superview.layoutSubtreeIfNeeded() - } + button.translatesAutoresizingMaskIntoConstraints = true + superview.layoutSubtreeIfNeeded() } + } } diff --git a/scripts/ci_checks.sh b/scripts/ci_checks.sh index ae35047e..14695f45 100755 --- a/scripts/ci_checks.sh +++ b/scripts/ci_checks.sh @@ -59,7 +59,19 @@ else rm -f "$out" fi -# 2. flutter analyze (mirrors ci.yml "Analyze code") +# 2. Native formatting +section "native format" +out="$(mktemp)" +if scripts/format_native.sh --check >"$out" 2>&1; then + ok "native files correctly formatted" +else + fail "native formatting issues" + sed 's/^/ /' "$out" + FAILED=1 +fi +rm -f "$out" + +# 3. flutter analyze (mirrors ci.yml "Analyze code") section "flutter analyze" out="$(mktemp)" flutter analyze >"$out" 2>&1 || true @@ -76,7 +88,7 @@ else fi rm -f "$out" -# 3. Unused code (mirrors ci.yml "Check for unused code") +# 4. Unused code (mirrors ci.yml "Check for unused code") section "dart_code_linter: unused code" if ! have_dart_code_linter; then skip "dart_code_linter unresolved — run 'flutter pub get'" @@ -93,7 +105,7 @@ else rm -f "$out" fi -# 4. Unused files (mirrors ci.yml "Check for unused files") +# 5. Unused files (mirrors ci.yml "Check for unused files") section "dart_code_linter: unused files" if ! have_dart_code_linter; then skip "dart_code_linter unresolved — run 'flutter pub get'" diff --git a/scripts/format_native.sh b/scripts/format_native.sh new file mode 100755 index 00000000..60a8ced4 --- /dev/null +++ b/scripts/format_native.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$ROOT" + +MODE="check" +case "${1:---check}" in + --check) MODE="check" ;; + --fix|--write) MODE="fix" ;; + -h|--help) + echo "Usage: scripts/format_native.sh [--check|--fix]" + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + echo "Usage: scripts/format_native.sh [--check|--fix]" >&2 + exit 2 + ;; +esac + +KTLINT_VERSION="${KTLINT_VERSION:-1.5.0}" +KTLINT_BIN="$ROOT/.dart_tool/native-format/ktlint-$KTLINT_VERSION" + +has_command() { + command -v "$1" >/dev/null 2>&1 +} + +run_clang_format() { + if has_command xcrun && xcrun --find clang-format >/dev/null 2>&1; then + xcrun clang-format "$@" + elif has_command clang-format; then + clang-format "$@" + else + echo "clang-format not found. Install clang-format or Xcode command line tools." >&2 + return 127 + fi +} + +run_swift_format() { + if has_command xcrun && xcrun --find swift-format >/dev/null 2>&1; then + xcrun swift-format "$@" + elif has_command swift-format; then + swift-format "$@" + elif has_command swift && swift format --help >/dev/null 2>&1; then + swift format "$@" + else + echo "swift-format not found. Install Swift 6+, swift-format, or Xcode 16+." >&2 + return 127 + fi +} + +ensure_ktlint() { + if [ -x "$KTLINT_BIN" ]; then + return 0 + fi + if ! has_command curl; then + echo "curl not found. Install curl to download ktlint." >&2 + return 127 + fi + if ! has_command java; then + echo "java not found. Install JDK 17+ to run ktlint." >&2 + return 127 + fi + + mkdir -p "$(dirname "$KTLINT_BIN")" + curl -fsSL "https://github.com/pinterest/ktlint/releases/download/$KTLINT_VERSION/ktlint" -o "$KTLINT_BIN" + chmod +x "$KTLINT_BIN" +} + +append_native_files() { + while IFS= read -r -d '' file; do + case "$file" in + android/app/src/main/cpp/include/*) continue ;; + android/app/src/main/java/io/flutter/plugins/*) continue ;; + ios/Flutter/*|macos/Flutter/*|tvos/Flutter/*) continue ;; + linux/flutter/*|windows/flutter/*) continue ;; + tvos/Runner/Plugins/*) continue ;; + */GeneratedPluginRegistrant.*|*/generated_plugin_registrant.*) continue ;; + esac + + case "$file" in + *.kt|*.kts) ktlint_files+=("$file") ;; + *.swift) swift_files+=("$file") ;; + *.c|*.cc|*.cpp|*.h|*.hpp|*.m|*.mm) clang_files+=("$file") ;; + esac + done < <(git ls-files -z -- "$@") +} + +ktlint_files=() +swift_files=() +clang_files=() + +append_native_files \ + 'android/**/*.kt' 'android/**/*.kts' \ + 'ios/**/*.swift' 'macos/**/*.swift' 'tvos/**/*.swift' 'shared/**/*.swift' \ + 'android/**/*.[ch]' 'android/**/*.cc' 'android/**/*.cpp' 'android/**/*.hpp' \ + 'ios/**/*.[hm]' 'ios/**/*.mm' \ + 'macos/**/*.[hm]' 'macos/**/*.mm' \ + 'tvos/**/*.[hm]' 'tvos/**/*.mm' \ + 'linux/**/*.[ch]' 'linux/**/*.cc' 'linux/**/*.cpp' 'linux/**/*.hpp' \ + 'windows/**/*.[ch]' 'windows/**/*.cc' 'windows/**/*.cpp' 'windows/**/*.hpp' \ + 'shared/**/*.[ch]' 'shared/**/*.cc' 'shared/**/*.cpp' 'shared/**/*.hpp' + +FAILED=0 + +if [ "${#ktlint_files[@]}" -gt 0 ]; then + ensure_ktlint + if [ "$MODE" = "fix" ]; then + "$KTLINT_BIN" -F "${ktlint_files[@]}" + else + "$KTLINT_BIN" "${ktlint_files[@]}" || FAILED=1 + fi +else + echo "No Kotlin files found." +fi + +if [ "${#swift_files[@]}" -gt 0 ]; then + if [ "$MODE" = "fix" ]; then + run_swift_format format --configuration "$ROOT/.swift-format" --in-place "${swift_files[@]}" + else + swift_failed=0 + for file in "${swift_files[@]}"; do + tmp="$(mktemp)" + run_swift_format format --configuration "$ROOT/.swift-format" "$file" >"$tmp" + if ! cmp -s "$file" "$tmp"; then + if [ "$swift_failed" -eq 0 ]; then + echo "Swift files need formatting:" + fi + echo " $file" + swift_failed=1 + fi + rm -f "$tmp" + done + if [ "$swift_failed" -ne 0 ]; then + FAILED=1 + fi + fi +else + echo "No Swift files found." +fi + +if [ "${#clang_files[@]}" -gt 0 ]; then + if [ "$MODE" = "fix" ]; then + run_clang_format -i "${clang_files[@]}" + else + run_clang_format --dry-run --Werror "${clang_files[@]}" || FAILED=1 + fi +else + echo "No C/C++/Obj-C files found." +fi + +if [ "$FAILED" -ne 0 ]; then + echo "Native formatting issues found. Run: scripts/format_native.sh --fix" >&2 + exit 1 +fi + +if [ "$MODE" = "check" ]; then + echo "Native formatting passed." +else + echo "Native formatting applied." +fi diff --git a/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift b/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift index 302730e3..d3dbe981 100644 --- a/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift +++ b/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift @@ -3,58 +3,58 @@ import Libmpv import QuartzCore #if os(iOS) || os(tvOS) -import UIKit + import UIKit #elseif os(macOS) -import Cocoa + import Cocoa #endif protocol MpvPlayerDelegate: AnyObject { - func onPropertyChange(name: String, value: Any?) - func onEvent(name: String, data: [String: Any]?) + func onPropertyChange(name: String, value: Any?) + func onEvent(name: String, data: [String: Any]?) } // Workaround for MoltenVK problems that cause flicker. // https://github.com/mpv-player/mpv/pull/13651 class MpvMetalLayer: CAMetalLayer { - override var drawableSize: CGSize { - get { super.drawableSize } - set { - if newValue == .zero || (Int(newValue.width) > 1 && Int(newValue.height) > 1) { - super.drawableSize = newValue - } - } + override var drawableSize: CGSize { + get { super.drawableSize } + set { + if newValue == .zero || (Int(newValue.width) > 1 && Int(newValue.height) > 1) { + super.drawableSize = newValue + } } + } - #if os(iOS) + #if os(iOS) // wantsExtendedDynamicRangeContent is unavailable on tvOS as of SDK 26.4, // so this override only applies to iOS / macOS. @available(iOS 16.0, *) override var wantsExtendedDynamicRangeContent: Bool { - get { super.wantsExtendedDynamicRangeContent } - set { - if Thread.isMainThread { - super.wantsExtendedDynamicRangeContent = newValue - } else { - DispatchQueue.main.sync { - super.wantsExtendedDynamicRangeContent = newValue - } - } + get { super.wantsExtendedDynamicRangeContent } + set { + if Thread.isMainThread { + super.wantsExtendedDynamicRangeContent = newValue + } else { + DispatchQueue.main.sync { + super.wantsExtendedDynamicRangeContent = newValue + } } + } } - #elseif os(macOS) + #elseif os(macOS) override var wantsExtendedDynamicRangeContent: Bool { - get { super.wantsExtendedDynamicRangeContent } - set { - if Thread.isMainThread { - super.wantsExtendedDynamicRangeContent = newValue - } else { - DispatchQueue.main.async { - super.wantsExtendedDynamicRangeContent = newValue - } - } + get { super.wantsExtendedDynamicRangeContent } + set { + if Thread.isMainThread { + super.wantsExtendedDynamicRangeContent = newValue + } else { + DispatchQueue.main.async { + super.wantsExtendedDynamicRangeContent = newValue + } } + } } - #endif + #endif } /// Safely convert a C string to Swift String with UTF-8 validation. @@ -62,475 +62,478 @@ class MpvMetalLayer: CAMetalLayer { /// mpv does not guarantee UTF-8 for log messages, error strings, or /// system-encoded paths and Flutter codecs reject invalid UTF-8. func safeString(_ cstr: UnsafePointer) -> String { - if let string = String(validatingUTF8: cstr) { - return string - } + if let string = String(validatingUTF8: cstr) { + return string + } - let length = strlen(cstr) - let buffer = UnsafeBufferPointer( - start: UnsafeRawPointer(cstr).assumingMemoryBound(to: UInt8.self), - count: length - ) - return String(buffer.map { Character(Unicode.Scalar($0)) }) + let length = strlen(cstr) + let buffer = UnsafeBufferPointer( + start: UnsafeRawPointer(cstr).assumingMemoryBound(to: UInt8.self), + count: length + ) + return String(buffer.map { Character(Unicode.Scalar($0)) }) } class MpvPlayerCoreBase: NSObject { - weak var delegate: MpvPlayerDelegate? + weak var delegate: MpvPlayerDelegate? - var metalLayer: MpvMetalLayer? - var mpv: OpaquePointer? - var isInitialized = false - var isDisposing = false - var isPipActive = false - var isBackgrounded = false - var hdrEnabled = true - var lastSigPeak = 0.0 + var metalLayer: MpvMetalLayer? + var mpv: OpaquePointer? + var isInitialized = false + var isDisposing = false + var isPipActive = false + var isBackgrounded = false + var hdrEnabled = true + var lastSigPeak = 0.0 - /// Properties that must still flow to Dart while backgrounded (state-critical). - private static let criticalProperties: Set = ["pause", "eof-reached", "paused-for-cache"] + /// Properties that must still flow to Dart while backgrounded (state-critical). + private static let criticalProperties: Set = [ + "pause", "eof-reached", "paused-for-cache", + ] - let queue = DispatchQueue(label: "mpv", qos: .userInitiated) - private let queueKey = DispatchSpecificKey() + let queue = DispatchQueue(label: "mpv", qos: .userInitiated) + private let queueKey = DispatchSpecificKey() - private var pendingCommands: [UInt64: (Result) -> Void] = [:] - private let pendingCommandsLock = NSLock() - private var nextRequestId: UInt64 = 1 + private var pendingCommands: [UInt64: (Result) -> Void] = [:] + private let pendingCommandsLock = NSLock() + private var nextRequestId: UInt64 = 1 - override init() { - super.init() - queue.setSpecific(key: queueKey, value: ()) + override init() { + super.init() + queue.setSpecific(key: queueKey, value: ()) + } + + func configurePlatformMpvOptions() {} + + func updateEDRMode(sigPeak: Double) {} + + func setupMpv() -> Bool { + guard let metalLayer else { return false } + + mpv = mpv_create() + guard let mpv else { + print("[MpvPlayerCore] Failed to create MPV context") + return false } - func configurePlatformMpvOptions() {} + #if DEBUG + checkError(mpv_request_log_messages(mpv, "info")) + #else + checkError(mpv_request_log_messages(mpv, "warn")) + #endif - func updateEDRMode(sigPeak: Double) {} + var layer = metalLayer + checkError(mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &layer)) + applySharedMpvOptions() + configurePlatformMpvOptions() - func setupMpv() -> Bool { - guard let metalLayer else { return false } - - mpv = mpv_create() - guard let mpv else { - print("[MpvPlayerCore] Failed to create MPV context") - return false - } - - #if DEBUG - checkError(mpv_request_log_messages(mpv, "info")) - #else - checkError(mpv_request_log_messages(mpv, "warn")) - #endif - - var layer = metalLayer - checkError(mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &layer)) - applySharedMpvOptions() - configurePlatformMpvOptions() - - let initResult = mpv_initialize(mpv) - if initResult < 0 { - print("[MpvPlayerCore] mpv_initialize failed: \(safeString(mpv_error_string(initResult)))") - mpv_terminate_destroy(mpv) - self.mpv = nil - return false - } - - mpv_set_wakeup_callback( - mpv, - { context in - guard let context else { return } - let core = Unmanaged.fromOpaque(context).takeUnretainedValue() - core.readEvents() - }, - UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) - ) - - mpv_observe_property(mpv, 0, "video-params/sig-peak", MPV_FORMAT_DOUBLE) - return true + let initResult = mpv_initialize(mpv) + if initResult < 0 { + print("[MpvPlayerCore] mpv_initialize failed: \(safeString(mpv_error_string(initResult)))") + mpv_terminate_destroy(mpv) + self.mpv = nil + return false } - func setLogLevel(_ level: String) { - guard let mpv else { return } - mpv_request_log_messages(mpv, level) + mpv_set_wakeup_callback( + mpv, + { context in + guard let context else { return } + let core = Unmanaged.fromOpaque(context).takeUnretainedValue() + core.readEvents() + }, + UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) + ) + + mpv_observe_property(mpv, 0, "video-params/sig-peak", MPV_FORMAT_DOUBLE) + return true + } + + func setLogLevel(_ level: String) { + guard let mpv else { return } + mpv_request_log_messages(mpv, level) + } + + func setProperty(_ name: String, value: String) { + guard mpv != nil else { return } + + if name == "hdr-enabled" { + let enabled = value == "yes" || value == "true" || value == "1" + setHDREnabled(enabled) + return } - func setProperty(_ name: String, value: String) { - guard mpv != nil else { return } + mpv_set_property_string(mpv, name, value) + } - if name == "hdr-enabled" { - let enabled = value == "yes" || value == "true" || value == "1" - setHDREnabled(enabled) - return - } + func setHDREnabled(_ enabled: Bool) { + hdrEnabled = enabled + print("[MpvPlayerCore] HDR enabled: \(enabled)") - mpv_set_property_string(mpv, name, value) + if mpv != nil { + mpv_set_property_string(mpv, "target-colorspace-hint", enabled ? "yes" : "no") } - func setHDREnabled(_ enabled: Bool) { - hdrEnabled = enabled - print("[MpvPlayerCore] HDR enabled: \(enabled)") + DispatchQueue.main.async { + self.updateEDRMode(sigPeak: self.lastSigPeak) + } + } - if mpv != nil { - mpv_set_property_string(mpv, "target-colorspace-hint", enabled ? "yes" : "no") - } + func getProperty(_ name: String) -> String? { + guard mpv != nil else { return nil } + let cstr = mpv_get_property_string(mpv, name) + defer { mpv_free(cstr) } + return cstr.map { safeString($0) } + } - DispatchQueue.main.async { - self.updateEDRMode(sigPeak: self.lastSigPeak) - } + func observeProperty(_ name: String, format: String) { + guard mpv != nil else { return } + + let mpvFormat: mpv_format + switch format { + case "double": + mpvFormat = MPV_FORMAT_DOUBLE + case "flag": + mpvFormat = MPV_FORMAT_FLAG + case "node": + mpvFormat = MPV_FORMAT_NODE + case "string": + mpvFormat = MPV_FORMAT_STRING + default: + return } - func getProperty(_ name: String) -> String? { - guard mpv != nil else { return nil } - let cstr = mpv_get_property_string(mpv, name) - defer { mpv_free(cstr) } - return cstr.map { safeString($0) } + mpv_observe_property(mpv, 0, name, mpvFormat) + } + + func command(_ args: [String]) { + guard mpv != nil, !args.isEmpty else { return } + command(args[0], args: Array(args.dropFirst())) + } + + func commandAsync(_ args: [String], completion: @escaping (Result) -> Void) { + guard let mpv, !args.isEmpty else { + completion(.success(())) + return } - func observeProperty(_ name: String, format: String) { - guard mpv != nil else { return } + pendingCommandsLock.lock() + let requestId = nextRequestId + nextRequestId += 1 + pendingCommands[requestId] = completion + pendingCommandsLock.unlock() - let mpvFormat: mpv_format - switch format { - case "double": - mpvFormat = MPV_FORMAT_DOUBLE - case "flag": - mpvFormat = MPV_FORMAT_FLAG - case "node": - mpvFormat = MPV_FORMAT_NODE - case "string": - mpvFormat = MPV_FORMAT_STRING - default: - return - } - - mpv_observe_property(mpv, 0, name, mpvFormat) - } - - func command(_ args: [String]) { - guard mpv != nil, !args.isEmpty else { return } - command(args[0], args: Array(args.dropFirst())) - } - - func commandAsync(_ args: [String], completion: @escaping (Result) -> Void) { - guard let mpv, !args.isEmpty else { - completion(.success(())) - return - } + var cargs: [UnsafeMutablePointer?] = args.map { strdup($0) } + cargs.append(nil) + cargs.withUnsafeBufferPointer { buffer in + var constPointers = buffer.map { UnsafePointer($0) } + let result = mpv_command_async(mpv, requestId, &constPointers) + if result < 0 { pendingCommandsLock.lock() - let requestId = nextRequestId - nextRequestId += 1 - pendingCommands[requestId] = completion - pendingCommandsLock.unlock() - - var cargs: [UnsafeMutablePointer?] = args.map { strdup($0) } - cargs.append(nil) - - cargs.withUnsafeBufferPointer { buffer in - var constPointers = buffer.map { UnsafePointer($0) } - let result = mpv_command_async(mpv, requestId, &constPointers) - if result < 0 { - pendingCommandsLock.lock() - let pending = pendingCommands.removeValue(forKey: requestId) - pendingCommandsLock.unlock() - - guard let pending else { return } - let error = NSError( - domain: "mpv", - code: Int(result), - userInfo: [NSLocalizedDescriptionKey: safeString(mpv_error_string(result))] - ) - DispatchQueue.main.async { - pending(.failure(error)) - } - } - } - - for pointer in cargs { - free(pointer) - } - } - - var isPaused: Bool { - guard let mpv else { return true } - var flag: Int32 = 0 - mpv_get_property(mpv, "pause", MPV_FORMAT_FLAG, &flag) - return flag != 0 - } - - var duration: Double { - guard let mpv else { return 0 } - var value: Double = 0 - mpv_get_property(mpv, "duration", MPV_FORMAT_DOUBLE, &value) - return value - } - - var timePos: Double { - guard let mpv else { return 0 } - var value: Double = 0 - mpv_get_property(mpv, "time-pos", MPV_FORMAT_DOUBLE, &value) - return value - } - - func disposeSharedState(destroySynchronously: Bool) { - isDisposing = true - cancelPendingCommands() - - let mpvHandle = mpv - mpv = nil - - let destroy = { - if let mpvHandle { - mpv_set_wakeup_callback(mpvHandle, nil, nil) - mpv_terminate_destroy(mpvHandle) - } - } - - if destroySynchronously { - if DispatchQueue.getSpecific(key: queueKey) != nil { - destroy() - } else { - queue.sync(execute: destroy) - } - } else { - queue.async(execute: destroy) - } - } - - func applyGpuNextOptions() { - guard mpv != nil else { return } - mpv_set_property_string(mpv, "gpu-api", "vulkan") - mpv_set_property_string(mpv, "gpu-context", "moltenvk") - mpv_set_property_string(mpv, "vo", "gpu-next") - } - - private func applySharedMpvOptions() { - guard let mpv else { return } - checkError(mpv_set_option_string(mpv, "vo", "gpu-next")) - checkError(mpv_set_option_string(mpv, "gpu-api", "vulkan")) - checkError(mpv_set_option_string(mpv, "gpu-context", "moltenvk")) - checkError(mpv_set_option_string(mpv, "hwdec", "videotoolbox")) - checkError(mpv_set_option_string(mpv, "target-colorspace-hint", "yes")) - } - - private func cancelPendingCommands() { - pendingCommandsLock.lock() - let pending = pendingCommands - pendingCommands.removeAll() + let pending = pendingCommands.removeValue(forKey: requestId) pendingCommandsLock.unlock() + guard let pending else { return } let error = NSError( - domain: "mpv", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Player disposed"] + domain: "mpv", + code: Int(result), + userInfo: [NSLocalizedDescriptionKey: safeString(mpv_error_string(result))] ) - for (_, completion) in pending { - DispatchQueue.main.async { - completion(.failure(error)) - } + DispatchQueue.main.async { + pending(.failure(error)) } + } } - private func command(_ command: String, args: [String] = []) { - guard mpv != nil else { return } + for pointer in cargs { + free(pointer) + } + } - var cargs: [UnsafeMutablePointer?] = ([command] + args).map { strdup($0) } - cargs.append(nil) - defer { - for pointer in cargs { - free(pointer) - } - } + var isPaused: Bool { + guard let mpv else { return true } + var flag: Int32 = 0 + mpv_get_property(mpv, "pause", MPV_FORMAT_FLAG, &flag) + return flag != 0 + } - cargs.withUnsafeBufferPointer { buffer in - var constPointers = buffer.map { UnsafePointer($0) } - _ = mpv_command(mpv, &constPointers) - } + var duration: Double { + guard let mpv else { return 0 } + var value: Double = 0 + mpv_get_property(mpv, "duration", MPV_FORMAT_DOUBLE, &value) + return value + } + + var timePos: Double { + guard let mpv else { return 0 } + var value: Double = 0 + mpv_get_property(mpv, "time-pos", MPV_FORMAT_DOUBLE, &value) + return value + } + + func disposeSharedState(destroySynchronously: Bool) { + isDisposing = true + cancelPendingCommands() + + let mpvHandle = mpv + mpv = nil + + let destroy = { + if let mpvHandle { + mpv_set_wakeup_callback(mpvHandle, nil, nil) + mpv_terminate_destroy(mpvHandle) + } } - private func readEvents() { - queue.async { [weak self] in - guard let self, !self.isDisposing, let mpv = self.mpv else { return } + if destroySynchronously { + if DispatchQueue.getSpecific(key: queueKey) != nil { + destroy() + } else { + queue.sync(execute: destroy) + } + } else { + queue.async(execute: destroy) + } + } - while true { - let event = mpv_wait_event(mpv, 0) - guard let event else { break } + func applyGpuNextOptions() { + guard mpv != nil else { return } + mpv_set_property_string(mpv, "gpu-api", "vulkan") + mpv_set_property_string(mpv, "gpu-context", "moltenvk") + mpv_set_property_string(mpv, "vo", "gpu-next") + } - if event.pointee.event_id == MPV_EVENT_NONE { - break - } + private func applySharedMpvOptions() { + guard let mpv else { return } + checkError(mpv_set_option_string(mpv, "vo", "gpu-next")) + checkError(mpv_set_option_string(mpv, "gpu-api", "vulkan")) + checkError(mpv_set_option_string(mpv, "gpu-context", "moltenvk")) + checkError(mpv_set_option_string(mpv, "hwdec", "videotoolbox")) + checkError(mpv_set_option_string(mpv, "target-colorspace-hint", "yes")) + } - self.handleEvent(event.pointee) - } - } + private func cancelPendingCommands() { + pendingCommandsLock.lock() + let pending = pendingCommands + pendingCommands.removeAll() + pendingCommandsLock.unlock() + + let error = NSError( + domain: "mpv", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Player disposed"] + ) + for (_, completion) in pending { + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } + + private func command(_ command: String, args: [String] = []) { + guard mpv != nil else { return } + + var cargs: [UnsafeMutablePointer?] = ([command] + args).map { strdup($0) } + cargs.append(nil) + defer { + for pointer in cargs { + free(pointer) + } } - private func handleEvent(_ event: mpv_event) { - switch event.event_id { - case MPV_EVENT_PROPERTY_CHANGE: - guard let data = event.data else { break } - let property = data.assumingMemoryBound(to: mpv_event_property.self).pointee - let name = safeString(property.name) - handlePropertyChange(name: name, property: property) - - case MPV_EVENT_COMMAND_REPLY: - let requestId = event.reply_userdata - pendingCommandsLock.lock() - let completion = pendingCommands.removeValue(forKey: requestId) - pendingCommandsLock.unlock() - - guard let completion else { break } - if event.error < 0 { - let error = NSError( - domain: "mpv", - code: Int(event.error), - userInfo: [NSLocalizedDescriptionKey: safeString(mpv_error_string(event.error))] - ) - DispatchQueue.main.async { - completion(.failure(error)) - } - } else { - DispatchQueue.main.async { - completion(.success(())) - } - } - - case MPV_EVENT_FILE_LOADED: - DispatchQueue.main.async { - self.delegate?.onEvent(name: "file-loaded", data: nil) - } - - case MPV_EVENT_END_FILE: - if let endFilePtr = event.data?.assumingMemoryBound(to: mpv_event_end_file.self) { - let endFile = endFilePtr.pointee - var data: [String: Any] = ["reason": Int(endFile.reason.rawValue)] - if endFile.reason == MPV_END_FILE_REASON_ERROR { - data["error"] = Int(endFile.error) - data["message"] = safeString(mpv_error_string(endFile.error)) - } - DispatchQueue.main.async { - self.delegate?.onEvent(name: "end-file", data: data) - } - } else { - DispatchQueue.main.async { - self.delegate?.onEvent(name: "end-file", data: nil) - } - } - - case MPV_EVENT_SHUTDOWN: - print("[MpvPlayerCore] MPV shutdown event") - - case MPV_EVENT_PLAYBACK_RESTART: - DispatchQueue.main.async { - self.delegate?.onEvent(name: "playback-restart", data: nil) - } - - case MPV_EVENT_LOG_MESSAGE: - if isBackgrounded { break } - if let messagePointer = event.data?.assumingMemoryBound(to: mpv_event_log_message.self) { - let message = messagePointer.pointee - let prefix = message.prefix.map { safeString($0) } ?? "" - let level = message.level.map { safeString($0) } ?? "" - let text = message.text.map { safeString($0) } ?? "" - - DispatchQueue.main.async { - self.delegate?.onEvent( - name: "log-message", - data: ["prefix": prefix, "level": level, "text": text] - ) - } - } - - default: - break - } + cargs.withUnsafeBufferPointer { buffer in + var constPointers = buffer.map { UnsafePointer($0) } + _ = mpv_command(mpv, &constPointers) } + } - private func handlePropertyChange(name: String, property: mpv_event_property) { - if isBackgrounded && !Self.criticalProperties.contains(name) { return } + private func readEvents() { + queue.async { [weak self] in + guard let self, !self.isDisposing, let mpv = self.mpv else { return } - var value: Any? + while true { + let event = mpv_wait_event(mpv, 0) + guard let event else { break } - switch property.format { - case MPV_FORMAT_DOUBLE: - if let data = property.data { - value = data.assumingMemoryBound(to: Double.self).pointee - } - - case MPV_FORMAT_FLAG: - if let data = property.data { - value = data.assumingMemoryBound(to: Int32.self).pointee != 0 - } - - case MPV_FORMAT_NODE: - if let data = property.data { - let node = data.assumingMemoryBound(to: mpv_node.self).pointee - value = convertNode(node) - } - - case MPV_FORMAT_STRING: - if let data = property.data { - let cstring = data.assumingMemoryBound(to: UnsafePointer?.self).pointee - value = cstring.map { safeString($0) } - } - - default: - break + if event.pointee.event_id == MPV_EVENT_NONE { + break } - if name == "video-params/sig-peak", let sigPeak = value as? Double { - lastSigPeak = sigPeak - DispatchQueue.main.async { - self.updateEDRMode(sigPeak: sigPeak) - } + self.handleEvent(event.pointee) + } + } + } + + private func handleEvent(_ event: mpv_event) { + switch event.event_id { + case MPV_EVENT_PROPERTY_CHANGE: + guard let data = event.data else { break } + let property = data.assumingMemoryBound(to: mpv_event_property.self).pointee + let name = safeString(property.name) + handlePropertyChange(name: name, property: property) + + case MPV_EVENT_COMMAND_REPLY: + let requestId = event.reply_userdata + pendingCommandsLock.lock() + let completion = pendingCommands.removeValue(forKey: requestId) + pendingCommandsLock.unlock() + + guard let completion else { break } + if event.error < 0 { + let error = NSError( + domain: "mpv", + code: Int(event.error), + userInfo: [NSLocalizedDescriptionKey: safeString(mpv_error_string(event.error))] + ) + DispatchQueue.main.async { + completion(.failure(error)) } + } else { + DispatchQueue.main.async { + completion(.success(())) + } + } + + case MPV_EVENT_FILE_LOADED: + DispatchQueue.main.async { + self.delegate?.onEvent(name: "file-loaded", data: nil) + } + + case MPV_EVENT_END_FILE: + if let endFilePtr = event.data?.assumingMemoryBound(to: mpv_event_end_file.self) { + let endFile = endFilePtr.pointee + var data: [String: Any] = ["reason": Int(endFile.reason.rawValue)] + if endFile.reason == MPV_END_FILE_REASON_ERROR { + data["error"] = Int(endFile.error) + data["message"] = safeString(mpv_error_string(endFile.error)) + } + DispatchQueue.main.async { + self.delegate?.onEvent(name: "end-file", data: data) + } + } else { + DispatchQueue.main.async { + self.delegate?.onEvent(name: "end-file", data: nil) + } + } + + case MPV_EVENT_SHUTDOWN: + print("[MpvPlayerCore] MPV shutdown event") + + case MPV_EVENT_PLAYBACK_RESTART: + DispatchQueue.main.async { + self.delegate?.onEvent(name: "playback-restart", data: nil) + } + + case MPV_EVENT_LOG_MESSAGE: + if isBackgrounded { break } + if let messagePointer = event.data?.assumingMemoryBound(to: mpv_event_log_message.self) { + let message = messagePointer.pointee + let prefix = message.prefix.map { safeString($0) } ?? "" + let level = message.level.map { safeString($0) } ?? "" + let text = message.text.map { safeString($0) } ?? "" DispatchQueue.main.async { - self.delegate?.onPropertyChange(name: name, value: value) + self.delegate?.onEvent( + name: "log-message", + data: ["prefix": prefix, "level": level, "text": text] + ) } + } + + default: + break + } + } + + private func handlePropertyChange(name: String, property: mpv_event_property) { + if isBackgrounded && !Self.criticalProperties.contains(name) { return } + + var value: Any? + + switch property.format { + case MPV_FORMAT_DOUBLE: + if let data = property.data { + value = data.assumingMemoryBound(to: Double.self).pointee + } + + case MPV_FORMAT_FLAG: + if let data = property.data { + value = data.assumingMemoryBound(to: Int32.self).pointee != 0 + } + + case MPV_FORMAT_NODE: + if let data = property.data { + let node = data.assumingMemoryBound(to: mpv_node.self).pointee + value = convertNode(node) + } + + case MPV_FORMAT_STRING: + if let data = property.data { + let cstring = data.assumingMemoryBound(to: UnsafePointer?.self).pointee + value = cstring.map { safeString($0) } + } + + default: + break } - private func convertNode(_ node: mpv_node) -> Any? { - switch node.format { - case MPV_FORMAT_STRING: - return node.u.string.map { safeString($0) } - - case MPV_FORMAT_FLAG: - return node.u.flag != 0 - - case MPV_FORMAT_INT64: - return node.u.int64 - - case MPV_FORMAT_DOUBLE: - return node.u.double_ - - case MPV_FORMAT_NODE_ARRAY: - guard let list = node.u.list?.pointee else { return nil } - var array = [Any]() - for index in 0.. Any? { + switch node.format { + case MPV_FORMAT_STRING: + return node.u.string.map { safeString($0) } + + case MPV_FORMAT_FLAG: + return node.u.flag != 0 + + case MPV_FORMAT_INT64: + return node.u.int64 + + case MPV_FORMAT_DOUBLE: + return node.u.double_ + + case MPV_FORMAT_NODE_ARRAY: + guard let list = node.u.list?.pointee else { return nil } + var array = [Any]() + for index in 0.. + #include #include -#include // Sanitize a C string that may contain invalid UTF-8 sequences. // Uses simdutf for SIMD-accelerated validation (fast path for valid strings), diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp index de092184..e8d0dbfe 100644 --- a/windows/runner/flutter_window.cpp +++ b/windows/runner/flutter_window.cpp @@ -30,11 +30,10 @@ static void CALLBACK SaveTimerProc(HWND, UINT, UINT_PTR, DWORD) { // Write a WINDOWPLACEMENT struct directly to the registry. static void WriteWindowPlacement(const WINDOWPLACEMENT& wp) { HKEY hKey; - if (RegCreateKeyExW(HKEY_CURRENT_USER, kWindowPlacementKey, 0, nullptr, - REG_OPTION_NON_VOLATILE, KEY_WRITE, nullptr, &hKey, - nullptr) == ERROR_SUCCESS) { - RegSetValueExW(hKey, kWindowPlacementValue, 0, REG_BINARY, - reinterpret_cast(&wp), sizeof(wp)); + if (RegCreateKeyExW( + HKEY_CURRENT_USER, kWindowPlacementKey, 0, nullptr, REG_OPTION_NON_VOLATILE, KEY_WRITE, nullptr, &hKey, + nullptr) == ERROR_SUCCESS) { + RegSetValueExW(hKey, kWindowPlacementValue, 0, REG_BINARY, reinterpret_cast(&wp), sizeof(wp)); RegCloseKey(hKey); } } @@ -51,17 +50,15 @@ static void SaveWindowPlacement(HWND hwnd) { // Returns whether the window should be maximized static bool LoadWindowPlacement(HWND hwnd) { HKEY hKey; - if (RegOpenKeyExW(HKEY_CURRENT_USER, kWindowPlacementKey, 0, KEY_READ, - &hKey) != ERROR_SUCCESS) - return false; + if (RegOpenKeyExW(HKEY_CURRENT_USER, kWindowPlacementKey, 0, KEY_READ, &hKey) != ERROR_SUCCESS) return false; WINDOWPLACEMENT wp{}; wp.length = sizeof(wp); DWORD size = sizeof(wp); bool wasMaximized = false; - if (RegQueryValueExW(hKey, kWindowPlacementValue, nullptr, nullptr, - reinterpret_cast(&wp), &size) == ERROR_SUCCESS && + if (RegQueryValueExW(hKey, kWindowPlacementValue, nullptr, nullptr, reinterpret_cast(&wp), &size) == + ERROR_SUCCESS && size == sizeof(wp)) { // Prevent restoring as minimized if (wp.showCmd == SW_SHOWMINIMIZED) wp.showCmd = SW_SHOWNORMAL; @@ -80,8 +77,7 @@ static void DebounceSaveWindowPlacement(HWND hwnd) { g_saveTimerId = SetTimer(nullptr, 0, 500, SaveTimerProc); // 500ms debounce } -FlutterWindow::FlutterWindow(const flutter::DartProject& project) - : project_(project) {} +FlutterWindow::FlutterWindow(const flutter::DartProject& project) : project_(project) {} FlutterWindow::~FlutterWindow() {} @@ -94,8 +90,8 @@ bool FlutterWindow::OnCreate() { // The size here must match the window dimensions to avoid unnecessary surface // creation / destruction in the startup path. - flutter_controller_ = std::make_unique( - frame.right - frame.left, frame.bottom - frame.top, project_); + flutter_controller_ = + std::make_unique(frame.right - frame.left, frame.bottom - frame.top, project_); // Ensure that basic setup of the controller was successful. if (!flutter_controller_->engine() || !flutter_controller_->view()) { return false; @@ -104,8 +100,7 @@ bool FlutterWindow::OnCreate() { // Register mpv player plugin. OutputDebugStringA("FlutterWindow: About to register MpvPlayerPlugin\n"); - MpvPlayerPluginRegisterWithRegistrar( - flutter_controller_->engine()->GetRegistrarForPlugin("MpvPlayerPlugin")); + MpvPlayerPluginRegisterWithRegistrar(flutter_controller_->engine()->GetRegistrarForPlugin("MpvPlayerPlugin")); OutputDebugStringA("FlutterWindow: MpvPlayerPlugin registered\n"); RegisterWindowChannel(); @@ -116,9 +111,8 @@ bool FlutterWindow::OnCreate() { HWND hwnd = GetHandle(); bool maximized = LoadWindowPlacement(hwnd); - flutter_controller_->engine()->SetNextFrameCallback([this, maximized]() { - ::ShowWindow(this->GetHandle(), maximized ? SW_SHOWMAXIMIZED : SW_SHOWNORMAL); - }); + flutter_controller_->engine()->SetNextFrameCallback( + [this, maximized]() { ::ShowWindow(this->GetHandle(), maximized ? SW_SHOWMAXIMIZED : SW_SHOWNORMAL); }); // Flutter can complete the first frame before the "show window" callback is // registered. The following call ensures a frame is pending to ensure the @@ -152,14 +146,10 @@ void FlutterWindow::OnDestroy() { } LRESULT -FlutterWindow::MessageHandler(HWND hwnd, UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept { // Give Flutter, including plugins, an opportunity to handle window messages. if (flutter_controller_) { - std::optional result = - flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, - lparam); + std::optional result = flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, lparam); if (result) { return *result; } @@ -196,40 +186,34 @@ FlutterWindow::MessageHandler(HWND hwnd, UINT const message, // --------------------------------------------------------------------------- void FlutterWindow::RegisterWindowChannel() { auto messenger = flutter_controller_->engine()->messenger(); - window_channel_ = - std::make_unique>( - messenger, "plezy/window", - &flutter::StandardMethodCodec::GetInstance()); + window_channel_ = std::make_unique>( + messenger, "plezy/window", &flutter::StandardMethodCodec::GetInstance()); - window_channel_->SetMethodCallHandler( - [this](const flutter::MethodCall& call, - std::unique_ptr> - result) { - const std::string& name = call.method_name(); - if (name == "setFullScreen") { - bool value = false; - if (const auto* args = - std::get_if(call.arguments())) { - auto it = args->find(flutter::EncodableValue("isFullScreen")); - if (it != args->end()) { - if (const bool* b = std::get_if(&it->second)) value = *b; - } - } - SetNativeFullScreen(value); - result->Success(); - } else if (name == "isFullScreen") { - result->Success(flutter::EncodableValue(is_fullscreen_)); - } else { - result->NotImplemented(); + window_channel_->SetMethodCallHandler([this]( + const flutter::MethodCall& call, + std::unique_ptr> result) { + const std::string& name = call.method_name(); + if (name == "setFullScreen") { + bool value = false; + if (const auto* args = std::get_if(call.arguments())) { + auto it = args->find(flutter::EncodableValue("isFullScreen")); + if (it != args->end()) { + if (const bool* b = std::get_if(&it->second)) value = *b; } - }); + } + SetNativeFullScreen(value); + result->Success(); + } else if (name == "isFullScreen") { + result->Success(flutter::EncodableValue(is_fullscreen_)); + } else { + result->NotImplemented(); + } + }); } void FlutterWindow::NotifyFullScreenChanged() { if (!window_channel_) return; - window_channel_->InvokeMethod( - "onFullScreenChanged", - std::make_unique(is_fullscreen_)); + window_channel_->InvokeMethod("onFullScreenChanged", std::make_unique(is_fullscreen_)); } void FlutterWindow::SetNativeFullScreen(bool fullscreen) { @@ -255,8 +239,7 @@ void FlutterWindow::SetNativeFullScreen(bool fullscreen) { POINT center{(wr.left + wr.right) / 2, (wr.top + wr.bottom) / 2}; MONITORINFO mi{}; mi.cbSize = sizeof(mi); - if (!::GetMonitorInfoW(::MonitorFromPoint(center, MONITOR_DEFAULTTONEAREST), - &mi)) { + if (!::GetMonitorInfoW(::MonitorFromPoint(center, MONITOR_DEFAULTTONEAREST), &mi)) { g_suppressPlacementSave = false; return; } @@ -271,18 +254,15 @@ void FlutterWindow::SetNativeFullScreen(bool fullscreen) { // Strip frame/caption. Stripping WS_OVERLAPPEDWINDOW alone is enough to // make the following SetWindowPos use the given rect exactly — no need // to ShowWindow(SW_SHOWNORMAL) first (would cause a second relayout). - ::SetWindowLongPtr( - hwnd, GWL_STYLE, style_before_fullscreen_ & ~WS_OVERLAPPEDWINDOW); + ::SetWindowLongPtr(hwnd, GWL_STYLE, style_before_fullscreen_ & ~WS_OVERLAPPEDWINDOW); ::SetWindowLongPtr( hwnd, GWL_EXSTYLE, - ex_style_before_fullscreen_ & - ~(WS_EX_DLGMODALFRAME | WS_EX_WINDOWEDGE | WS_EX_CLIENTEDGE | - WS_EX_STATICEDGE)); + ex_style_before_fullscreen_ & ~(WS_EX_DLGMODALFRAME | WS_EX_WINDOWEDGE | WS_EX_CLIENTEDGE | WS_EX_STATICEDGE)); const RECT& r = mi.rcMonitor; - ::SetWindowPos(hwnd, HWND_TOP, r.left, r.top, r.right - r.left, - r.bottom - r.top, - SWP_FRAMECHANGED | SWP_NOZORDER | SWP_NOACTIVATE); + ::SetWindowPos( + hwnd, HWND_TOP, r.left, r.top, r.right - r.left, r.bottom - r.top, + SWP_FRAMECHANGED | SWP_NOZORDER | SWP_NOACTIVATE); is_fullscreen_ = true; } else { @@ -298,9 +278,8 @@ void FlutterWindow::SetNativeFullScreen(bool fullscreen) { } // Force a frame refresh so restored chrome paints. - ::SetWindowPos(hwnd, nullptr, 0, 0, 0, 0, - SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | - SWP_FRAMECHANGED); + ::SetWindowPos( + hwnd, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED); is_fullscreen_ = false; placement_before_fullscreen_ = {}; diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h index 56aef863..81736240 100644 --- a/windows/runner/flutter_window.h +++ b/windows/runner/flutter_window.h @@ -21,8 +21,7 @@ class FlutterWindow : public Win32Window { // Win32Window: bool OnCreate() override; void OnDestroy() override; - LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, - LPARAM const lparam) noexcept override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept override; private: // The project to run. @@ -32,8 +31,7 @@ class FlutterWindow : public Win32Window { std::unique_ptr flutter_controller_; // Method channel exposing window controls to Dart (plezy/window). - std::unique_ptr> - window_channel_; + std::unique_ptr> window_channel_; // Fullscreen state tracking for monitor-aware native fullscreen. // Maximize state lives inside `placement_before_fullscreen_.showCmd`. diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp index 24f4fb89..176a3213 100644 --- a/windows/runner/main.cpp +++ b/windows/runner/main.cpp @@ -6,8 +6,8 @@ #include "mpv/display_mode_manager.h" #include "utils.h" -int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, - _In_ wchar_t *command_line, _In_ int show_command) { +int APIENTRY +wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, _In_ wchar_t* command_line, _In_ int show_command) { // Single instance enforcement HANDLE mutex = CreateMutex(nullptr, TRUE, L"com.edde746.Plezy.SingleInstance"); if (GetLastError() == ERROR_ALREADY_EXISTS) { @@ -33,8 +33,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, flutter::DartProject project(L"data"); project.set_ui_thread_policy(flutter::UIThreadPolicy::RunOnSeparateThread); - std::vector command_line_arguments = - GetCommandLineArguments(); + std::vector command_line_arguments = GetCommandLineArguments(); project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); @@ -47,8 +46,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, window.SetQuitOnClose(true); // Recover display mode if a prior crash left it changed. - mpv::DisplayModeManager::RecoverIfNeeded( - ::GetAncestor(window.GetHandle(), GA_ROOT)); + mpv::DisplayModeManager::RecoverIfNeeded(::GetAncestor(window.GetHandle(), GA_ROOT)); ::MSG msg; while (::GetMessage(&msg, nullptr, 0, 0)) { diff --git a/windows/runner/mpv/display_mode_manager.cpp b/windows/runner/mpv/display_mode_manager.cpp index 4501f3e8..140656f8 100644 --- a/windows/runner/mpv/display_mode_manager.cpp +++ b/windows/runner/mpv/display_mode_manager.cpp @@ -1,9 +1,9 @@ #include "display_mode_manager.h" -#include "sdk_26100.h" - -#include #include +#include + +#include "sdk_26100.h" namespace mpv { @@ -44,14 +44,12 @@ std::vector DisplayModeManager::GetDisplayConfigPaths() // Retry loop for ERROR_INSUFFICIENT_BUFFER (Kodi pattern). do { - if (GetDisplayConfigBufferSizes(flags, &path_count, &mode_count) != ERROR_SUCCESS) - return {}; + if (GetDisplayConfigBufferSizes(flags, &path_count, &mode_count) != ERROR_SUCCESS) return {}; paths.resize(path_count); modes.resize(mode_count); - result = QueryDisplayConfig(flags, &path_count, paths.data(), - &mode_count, modes.data(), nullptr); + result = QueryDisplayConfig(flags, &path_count, paths.data(), &mode_count, modes.data(), nullptr); } while (result == ERROR_INSUFFICIENT_BUFFER); if (result != ERROR_SUCCESS) return {}; @@ -60,8 +58,7 @@ std::vector DisplayModeManager::GetDisplayConfigPaths() return paths; } -std::optional DisplayModeManager::GetDisplayTargetId( - const std::wstring& gdi_device_name) { +std::optional DisplayModeManager::GetDisplayTargetId(const std::wstring& gdi_device_name) { // Follows Kodi's GetDisplayTargetId: iterate QueryDisplayConfig paths, // match via DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME.viewGdiDeviceName. DISPLAYCONFIG_SOURCE_DEVICE_NAME source = {}; @@ -72,8 +69,7 @@ std::optional DisplayModeManager::GetDisplayTargetId( source.header.adapterId = path.sourceInfo.adapterId; source.header.id = path.sourceInfo.id; - if (DisplayConfigGetDeviceInfo(&source.header) == ERROR_SUCCESS && - gdi_device_name == source.viewGdiDeviceName) { + if (DisplayConfigGetDeviceInfo(&source.header) == ERROR_SUCCESS && gdi_device_name == source.viewGdiDeviceName) { return DisplayConfigId{path.targetInfo.adapterId, path.targetInfo.id}; } } @@ -116,9 +112,13 @@ std::vector DisplayModeManager::EnumerateDisplayModes(HWND window) if (a.height != b.height) return a.height < b.height; return a.refresh_rate < b.refresh_rate; }); - modes.erase(std::unique(modes.begin(), modes.end(), [](const DisplayMode& a, const DisplayMode& b) { - return a.width == b.width && a.height == b.height && a.refresh_rate == b.refresh_rate; - }), modes.end()); + modes.erase( + std::unique( + modes.begin(), modes.end(), + [](const DisplayMode& a, const DisplayMode& b) { + return a.width == b.width && a.height == b.height && a.refresh_rate == b.refresh_rate; + }), + modes.end()); return modes; } @@ -145,12 +145,10 @@ void DisplayModeManager::SaveOriginalMode(HWND window) { original_devmode_ = {}; original_devmode_.dmSize = sizeof(original_devmode_); - EnumDisplaySettingsW(original_device_name_.c_str(), ENUM_CURRENT_SETTINGS, - &original_devmode_); + EnumDisplaySettingsW(original_device_name_.c_str(), ENUM_CURRENT_SETTINGS, &original_devmode_); } -bool DisplayModeManager::SetDisplayMode(HWND window, DWORD width, DWORD height, - DWORD refresh_rate) { +bool DisplayModeManager::SetDisplayMode(HWND window, DWORD width, DWORD height, DWORD refresh_rate) { std::wstring device_name = GetMonitorDeviceName(window); if (device_name.empty()) return false; @@ -175,25 +173,21 @@ bool DisplayModeManager::SetDisplayMode(HWND window, DWORD width, DWORD height, DEVMODEW registry_dm = {}; registry_dm.dmSize = sizeof(registry_dm); if (EnumDisplaySettingsW(device_name.c_str(), ENUM_REGISTRY_SETTINGS, ®istry_dm)) { - LONG rc = ChangeDisplaySettingsExW(device_name.c_str(), &dm, nullptr, - CDS_UPDATEREGISTRY | CDS_NORESET, nullptr); + LONG rc = ChangeDisplaySettingsExW(device_name.c_str(), &dm, nullptr, CDS_UPDATEREGISTRY | CDS_NORESET, nullptr); if (rc == DISP_CHANGE_SUCCESSFUL) { - rc = ChangeDisplaySettingsExW(device_name.c_str(), nullptr, nullptr, - CDS_FULLSCREEN, nullptr); + rc = ChangeDisplaySettingsExW(device_name.c_str(), nullptr, nullptr, CDS_FULLSCREEN, nullptr); if (rc == DISP_CHANGE_SUCCESSFUL) changed = true; // Restore original registry settings. registry_dm.dmFields = DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY | DM_DISPLAYFLAGS; - ChangeDisplaySettingsExW(device_name.c_str(), ®istry_dm, nullptr, - CDS_UPDATEREGISTRY | CDS_NORESET, nullptr); + ChangeDisplaySettingsExW(device_name.c_str(), ®istry_dm, nullptr, CDS_UPDATEREGISTRY | CDS_NORESET, nullptr); } } } // Standard path / fallback. if (!changed) { - LONG rc = ChangeDisplaySettingsExW(device_name.c_str(), &dm, nullptr, - CDS_FULLSCREEN, nullptr); + LONG rc = ChangeDisplaySettingsExW(device_name.c_str(), &dm, nullptr, CDS_FULLSCREEN, nullptr); if (rc == DISP_CHANGE_SUCCESSFUL) changed = true; } @@ -210,9 +204,8 @@ bool DisplayModeManager::RestoreOriginalMode(HWND window) { original_devmode_.dmFields = DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY | DM_DISPLAYFLAGS; - LONG rc = ChangeDisplaySettingsExW(original_device_name_.c_str(), - &original_devmode_, nullptr, - CDS_FULLSCREEN, nullptr); + LONG rc = + ChangeDisplaySettingsExW(original_device_name_.c_str(), &original_devmode_, nullptr, CDS_FULLSCREEN, nullptr); if (rc == DISP_CHANGE_SUCCESSFUL) { mode_changed_ = false; @@ -239,8 +232,7 @@ bool DisplayModeManager::IsHDRSupported(HWND window) { // Follows Kodi's GetDisplayHDRStatus pattern. if (IsWin11_24H2OrNewer()) { DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO_2 info = {}; - info.header.type = static_cast( - DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO_2); + info.header.type = static_cast(DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO_2); info.header.size = sizeof(info); info.header.adapterId = target_id->adapter_id; info.header.id = target_id->id; @@ -275,8 +267,7 @@ bool DisplayModeManager::IsHDREnabled(HWND window) { if (IsWin11_24H2OrNewer()) { DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO_2 info = {}; - info.header.type = static_cast( - DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO_2); + info.header.type = static_cast(DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO_2); info.header.size = sizeof(info); info.header.adapterId = target_id->adapter_id; info.header.id = target_id->id; @@ -331,8 +322,7 @@ bool DisplayModeManager::SetHDREnabled(HWND window, bool enabled) { // Source: Kodi WIN32Util.cpp:1276-1288. if (pre_toggle_dm.dmDisplayFrequency != 0) { pre_toggle_dm.dmFields = DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY | DM_DISPLAYFLAGS; - ChangeDisplaySettingsExW(device_name.c_str(), &pre_toggle_dm, nullptr, - CDS_FULLSCREEN, nullptr); + ChangeDisplaySettingsExW(device_name.c_str(), &pre_toggle_dm, nullptr, CDS_FULLSCREEN, nullptr); } hdr_changed_ = true; @@ -365,8 +355,7 @@ bool DisplayModeManager::RestoreOriginalHDRState(HWND window) { // Restore DEVMODEW after toggle. if (pre_toggle_dm.dmDisplayFrequency != 0) { pre_toggle_dm.dmFields = DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY | DM_DISPLAYFLAGS; - ChangeDisplaySettingsExW(original_hdr_device_name_.c_str(), &pre_toggle_dm, - nullptr, CDS_FULLSCREEN, nullptr); + ChangeDisplaySettingsExW(original_hdr_device_name_.c_str(), &pre_toggle_dm, nullptr, CDS_FULLSCREEN, nullptr); } hdr_changed_ = false; @@ -379,8 +368,7 @@ bool DisplayModeManager::RestoreOriginalHDRState(HWND window) { LONG DisplayModeManager::SetHDRStateForTarget(const DisplayConfigId& target, bool enabled) { if (IsWin11_24H2OrNewer()) { DISPLAYCONFIG_SET_HDR_STATE state = {}; - state.header.type = static_cast( - DISPLAYCONFIG_DEVICE_INFO_SET_HDR_STATE); + state.header.type = static_cast(DISPLAYCONFIG_DEVICE_INFO_SET_HDR_STATE); state.header.size = sizeof(state); state.header.adapterId = target.adapter_id; state.header.id = target.id; @@ -399,44 +387,39 @@ LONG DisplayModeManager::SetHDRStateForTarget(const DisplayConfigId& target, boo bool DisplayModeManager::WriteRegistryDWORD(const wchar_t* value_name, DWORD value) { HKEY key; - if (RegCreateKeyExW(HKEY_CURRENT_USER, kRegistryPath, 0, nullptr, - 0, KEY_WRITE, nullptr, &key, nullptr) != ERROR_SUCCESS) + if (RegCreateKeyExW(HKEY_CURRENT_USER, kRegistryPath, 0, nullptr, 0, KEY_WRITE, nullptr, &key, nullptr) != + ERROR_SUCCESS) return false; - LONG result = RegSetValueExW(key, value_name, 0, REG_DWORD, - reinterpret_cast(&value), sizeof(value)); + LONG result = RegSetValueExW(key, value_name, 0, REG_DWORD, reinterpret_cast(&value), sizeof(value)); RegCloseKey(key); return result == ERROR_SUCCESS; } -bool DisplayModeManager::WriteRegistryString(const wchar_t* value_name, - const std::wstring& value) { +bool DisplayModeManager::WriteRegistryString(const wchar_t* value_name, const std::wstring& value) { HKEY key; - if (RegCreateKeyExW(HKEY_CURRENT_USER, kRegistryPath, 0, nullptr, - 0, KEY_WRITE, nullptr, &key, nullptr) != ERROR_SUCCESS) + if (RegCreateKeyExW(HKEY_CURRENT_USER, kRegistryPath, 0, nullptr, 0, KEY_WRITE, nullptr, &key, nullptr) != + ERROR_SUCCESS) return false; - LONG result = RegSetValueExW(key, value_name, 0, REG_SZ, - reinterpret_cast(value.c_str()), - static_cast((value.size() + 1) * sizeof(wchar_t))); + LONG result = RegSetValueExW( + key, value_name, 0, REG_SZ, reinterpret_cast(value.c_str()), + static_cast((value.size() + 1) * sizeof(wchar_t))); RegCloseKey(key); return result == ERROR_SUCCESS; } bool DisplayModeManager::ReadRegistryDWORD(const wchar_t* value_name, DWORD& value) { HKEY key; - if (RegOpenKeyExW(HKEY_CURRENT_USER, kRegistryPath, 0, KEY_READ, &key) != ERROR_SUCCESS) - return false; + if (RegOpenKeyExW(HKEY_CURRENT_USER, kRegistryPath, 0, KEY_READ, &key) != ERROR_SUCCESS) return false; DWORD size = sizeof(value); DWORD type = 0; - LONG result = RegQueryValueExW(key, value_name, nullptr, &type, - reinterpret_cast(&value), &size); + LONG result = RegQueryValueExW(key, value_name, nullptr, &type, reinterpret_cast(&value), &size); RegCloseKey(key); return result == ERROR_SUCCESS && type == REG_DWORD; } bool DisplayModeManager::ReadRegistryString(const wchar_t* value_name, std::wstring& value) { HKEY key; - if (RegOpenKeyExW(HKEY_CURRENT_USER, kRegistryPath, 0, KEY_READ, &key) != ERROR_SUCCESS) - return false; + if (RegOpenKeyExW(HKEY_CURRENT_USER, kRegistryPath, 0, KEY_READ, &key) != ERROR_SUCCESS) return false; DWORD size = 0; DWORD type = 0; RegQueryValueExW(key, value_name, nullptr, &type, nullptr, &size); @@ -445,8 +428,7 @@ bool DisplayModeManager::ReadRegistryString(const wchar_t* value_name, std::wstr return false; } value.resize(size / sizeof(wchar_t)); - LONG result = RegQueryValueExW(key, value_name, nullptr, nullptr, - reinterpret_cast(&value[0]), &size); + LONG result = RegQueryValueExW(key, value_name, nullptr, nullptr, reinterpret_cast(&value[0]), &size); RegCloseKey(key); if (result != ERROR_SUCCESS) return false; // Remove trailing null. @@ -456,8 +438,7 @@ bool DisplayModeManager::ReadRegistryString(const wchar_t* value_name, std::wstr bool DisplayModeManager::DeleteRegistryValue(const wchar_t* value_name) { HKEY key; - if (RegOpenKeyExW(HKEY_CURRENT_USER, kRegistryPath, 0, KEY_WRITE, &key) != ERROR_SUCCESS) - return false; + if (RegOpenKeyExW(HKEY_CURRENT_USER, kRegistryPath, 0, KEY_WRITE, &key) != ERROR_SUCCESS) return false; RegDeleteValueW(key, value_name); RegCloseKey(key); return true; @@ -517,8 +498,7 @@ bool DisplayModeManager::RecoverIfNeeded(HWND window) { dm.dmDisplayFrequency = refresh; dm.dmFields = DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY; - LONG rc = ChangeDisplaySettingsExW(device_name.c_str(), &dm, nullptr, - CDS_FULLSCREEN, nullptr); + LONG rc = ChangeDisplaySettingsExW(device_name.c_str(), &dm, nullptr, CDS_FULLSCREEN, nullptr); if (rc == DISP_CHANGE_SUCCESSFUL) recovered = true; } } @@ -542,8 +522,7 @@ bool DisplayModeManager::RecoverIfNeeded(HWND window) { // Restore display mode after HDR toggle. if (pre_dm.dmDisplayFrequency != 0) { pre_dm.dmFields = DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY | DM_DISPLAYFLAGS; - ChangeDisplaySettingsExW(device_name.c_str(), &pre_dm, nullptr, - CDS_FULLSCREEN, nullptr); + ChangeDisplaySettingsExW(device_name.c_str(), &pre_dm, nullptr, CDS_FULLSCREEN, nullptr); } } } @@ -556,10 +535,8 @@ bool DisplayModeManager::RecoverIfNeeded(HWND window) { // --- Refresh rate matching --- -DWORD DisplayModeManager::FindBestRefreshRate(double video_fps, - const std::vector& modes, - DWORD current_width, - DWORD current_height) { +DWORD DisplayModeManager::FindBestRefreshRate( + double video_fps, const std::vector& modes, DWORD current_width, DWORD current_height) { if (video_fps <= 0) return 0; // Collect unique refresh rates available at the current resolution. @@ -592,8 +569,7 @@ DWORD DisplayModeManager::FindBestRefreshRate(double video_fps, // Prefer lowest multiplier (exact match > 2x > 3x > ...). // Among equal multipliers, prefer higher rate (shouldn't happen, but safe). - if (best_rate == 0 || multiplier < best_multiplier || - (multiplier == best_multiplier && rate > best_rate)) { + if (best_rate == 0 || multiplier < best_multiplier || (multiplier == best_multiplier && rate > best_rate)) { best_rate = rate; best_multiplier = multiplier; } diff --git a/windows/runner/mpv/display_mode_manager.h b/windows/runner/mpv/display_mode_manager.h index 620fa433..657965fb 100644 --- a/windows/runner/mpv/display_mode_manager.h +++ b/windows/runner/mpv/display_mode_manager.h @@ -26,10 +26,14 @@ struct DisplayConfigId { // Pure Win32 utility — no mpv or Flutter dependency. // // References: -// ChangeDisplaySettingsExW: https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-changedisplaysettingsexw -// EnumDisplaySettingsW: https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-enumdisplaysettingsw -// DisplayConfigGetDeviceInfo: https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-displayconfiggetdeviceinfo -// DisplayConfigSetDeviceInfo: https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-displayconfigsetdeviceinfo +// ChangeDisplaySettingsExW: +// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-changedisplaysettingsexw +// EnumDisplaySettingsW: +// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-enumdisplaysettingsw +// DisplayConfigGetDeviceInfo: +// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-displayconfiggetdeviceinfo +// DisplayConfigSetDeviceInfo: +// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-displayconfigsetdeviceinfo // Kodi impl: xbmc/platform/win32/DisplayUtilsWin32.cpp, xbmc/platform/win32/WIN32Util.cpp class DisplayModeManager { public: @@ -98,9 +102,8 @@ class DisplayModeManager { // Find the best matching refresh rate for a given video fps from available modes. // Returns 0 if no suitable match found. - static DWORD FindBestRefreshRate(double video_fps, - const std::vector& modes, - DWORD current_width, DWORD current_height); + static DWORD FindBestRefreshRate( + double video_fps, const std::vector& modes, DWORD current_width, DWORD current_height); private: // Get the GDI device name for the monitor containing the window. diff --git a/windows/runner/mpv/mpv_container.cpp b/windows/runner/mpv/mpv_container.cpp index 1121de89..cd4c15a1 100644 --- a/windows/runner/mpv/mpv_container.cpp +++ b/windows/runner/mpv/mpv_container.cpp @@ -22,16 +22,13 @@ HWND MpvContainer::Create() { // Use WS_POPUP for a borderless window without title bar. // Use WS_EX_TOOLWINDOW | WS_EX_NOREDIRECTIONBITMAP to prevent shadow and DWM effects. handle_ = ::CreateWindowExW( - WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE | WS_EX_NOREDIRECTIONBITMAP, - kClassName, kWindowName, WS_POPUP, - 0, 0, 100, 100, nullptr, nullptr, - GetModuleHandle(nullptr), nullptr); + WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE | WS_EX_NOREDIRECTIONBITMAP, kClassName, kWindowName, WS_POPUP, 0, 0, 100, + 100, nullptr, nullptr, GetModuleHandle(nullptr), nullptr); // Disable DWM animations on the container. auto disable_window_transitions = TRUE; - DwmSetWindowAttribute(handle_, DWMWA_TRANSITIONS_FORCEDISABLED, - &disable_window_transitions, - sizeof(disable_window_transitions)); + DwmSetWindowAttribute( + handle_, DWMWA_TRANSITIONS_FORCEDISABLED, &disable_window_transitions, sizeof(disable_window_transitions)); return handle_; } @@ -43,11 +40,10 @@ HWND MpvContainer::Get(HWND flutter_window) { RECT window_rect; ::GetWindowRect(flutter_window, &window_rect); - ::SetWindowPos(handle_, flutter_window, window_rect.left, window_rect.top, - window_rect.right - window_rect.left, - window_rect.bottom - window_rect.top, SWP_NOACTIVATE); - ::SetWindowLongPtr(handle_, GWLP_USERDATA, - reinterpret_cast(flutter_window)); + ::SetWindowPos( + handle_, flutter_window, window_rect.left, window_rect.top, window_rect.right - window_rect.left, + window_rect.bottom - window_rect.top, SWP_NOACTIVATE); + ::SetWindowLongPtr(handle_, GWLP_USERDATA, reinterpret_cast(flutter_window)); ::ShowWindow(handle_, SW_SHOWNOACTIVATE); ::SetFocus(flutter_window); @@ -55,10 +51,8 @@ HWND MpvContainer::Get(HWND flutter_window) { return handle_; } -LRESULT CALLBACK MpvContainer::WindowProc(HWND const window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { +LRESULT CALLBACK +MpvContainer::WindowProc(HWND const window, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept { switch (message) { case WM_DESTROY: { ::PostQuitMessage(0); @@ -87,7 +81,6 @@ LRESULT CALLBACK MpvContainer::WindowProc(HWND const window, return ::DefWindowProc(window, message, wparam, lparam); } -std::unique_ptr MpvContainer::instance_ = - std::make_unique(); +std::unique_ptr MpvContainer::instance_ = std::make_unique(); } // namespace mpv diff --git a/windows/runner/mpv/mpv_container.h b/windows/runner/mpv/mpv_container.h index 1fd75534..c7ac77fa 100644 --- a/windows/runner/mpv/mpv_container.h +++ b/windows/runner/mpv/mpv_container.h @@ -26,8 +26,7 @@ class MpvContainer { HWND handle() const { return handle_; } private: - static LRESULT CALLBACK WindowProc(HWND window, UINT message, WPARAM wparam, - LPARAM lparam) noexcept; + static LRESULT CALLBACK WindowProc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) noexcept; HWND handle_ = nullptr; diff --git a/windows/runner/mpv/mpv_core.cpp b/windows/runner/mpv/mpv_core.cpp index 6e75eed6..611866a7 100644 --- a/windows/runner/mpv/mpv_core.cpp +++ b/windows/runner/mpv/mpv_core.cpp @@ -9,12 +9,9 @@ namespace mpv { MpvCore* MpvCore::GetInstance() { return instance_.get(); } -void MpvCore::SetInstance(std::unique_ptr instance) { - instance_ = std::move(instance); -} +void MpvCore::SetInstance(std::unique_ptr instance) { instance_ = std::move(instance); } -MpvCore::MpvCore(HWND flutter_window) - : flutter_window_(flutter_window) {} +MpvCore::MpvCore(HWND flutter_window) : flutter_window_(flutter_window) {} MpvCore::~MpvCore() { // Close all mpv views. @@ -29,8 +26,7 @@ void MpvCore::EnsureInitialized() { container_ = MpvContainer::GetInstance()->Get(flutter_window_); } -void MpvCore::CreateMpvView(HWND mpv_hwnd, RECT rect, - double device_pixel_ratio) { +void MpvCore::CreateMpvView(HWND mpv_hwnd, RECT rect, double device_pixel_ratio) { ::SetParent(mpv_hwnd, container_); ::ShowWindow(mpv_hwnd, SW_SHOW); @@ -43,21 +39,19 @@ void MpvCore::CreateMpvView(HWND mpv_hwnd, RECT rect, mpv_views_[mpv_hwnd] = rect; // Position the mpv view behind the Flutter window. - auto global_rect = - GetGlobalRect(rect.left, rect.top, rect.right, rect.bottom); - ::SetWindowPos(mpv_hwnd, flutter_window_, global_rect.left, global_rect.top, - global_rect.right - global_rect.left, - global_rect.bottom - global_rect.top, SWP_NOACTIVATE); + auto global_rect = GetGlobalRect(rect.left, rect.top, rect.right, rect.bottom); + ::SetWindowPos( + mpv_hwnd, flutter_window_, global_rect.left, global_rect.top, global_rect.right - global_rect.left, + global_rect.bottom - global_rect.top, SWP_NOACTIVATE); } void MpvCore::ResizeMpvView(HWND mpv_hwnd, RECT rect) { mpv_views_[mpv_hwnd] = rect; - auto global_rect = - GetGlobalRect(rect.left, rect.top, rect.right, rect.bottom); + auto global_rect = GetGlobalRect(rect.left, rect.top, rect.right, rect.bottom); // Use MoveWindow to trigger redraw. - ::MoveWindow(mpv_hwnd, global_rect.left, global_rect.top, - global_rect.right - global_rect.left, - global_rect.bottom - global_rect.top, TRUE); + ::MoveWindow( + mpv_hwnd, global_rect.left, global_rect.top, global_rect.right - global_rect.left, + global_rect.bottom - global_rect.top, TRUE); } void MpvCore::DisposeMpvView(HWND mpv_hwnd) { @@ -76,24 +70,22 @@ void MpvCore::SetVisible(bool visible) { } } -std::optional MpvCore::WindowProc(HWND hwnd, UINT message, - WPARAM wparam, LPARAM lparam) { +std::optional MpvCore::WindowProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) { switch (message) { case WM_ACTIVATE: { RECT window_rect; ::GetWindowRect(flutter_window_, &window_rect); // Position container behind Flutter window. - ::SetWindowPos(container_, flutter_window_, window_rect.left, - window_rect.top, window_rect.right - window_rect.left, - window_rect.bottom - window_rect.top, SWP_NOACTIVATE); + ::SetWindowPos( + container_, flutter_window_, window_rect.left, window_rect.top, window_rect.right - window_rect.left, + window_rect.bottom - window_rect.top, SWP_NOACTIVATE); break; } case WM_SIZE: { // Handle Windows's minimize & maximize animations properly. // During these transitions, we hide the container and make Flutter opaque, // then restore after the animation completes using a Windows timer. - if (wparam != SIZE_RESTORED || last_wm_size_wparam_ == SIZE_MINIMIZED || - last_wm_size_wparam_ == SIZE_MAXIMIZED || + if (wparam != SIZE_RESTORED || last_wm_size_wparam_ == SIZE_MINIMIZED || last_wm_size_wparam_ == SIZE_MAXIMIZED || was_window_hidden_due_to_minimize_) { was_window_hidden_due_to_minimize_ = false; DisableComposition(); @@ -112,17 +104,16 @@ std::optional MpvCore::WindowProc(HWND hwnd, UINT message, // Update container position to match current Flutter window bounds RECT window_rect; ::GetWindowRect(flutter_window_, &window_rect); - ::SetWindowPos(container_, flutter_window_, window_rect.left, - window_rect.top, window_rect.right - window_rect.left, - window_rect.bottom - window_rect.top, SWP_NOACTIVATE); + ::SetWindowPos( + container_, flutter_window_, window_rect.left, window_rect.top, window_rect.right - window_rect.left, + window_rect.bottom - window_rect.top, SWP_NOACTIVATE); // Restore transparency if video is visible if (visible_) { EnableComposition(); // Force a redraw to ensure Flutter's render surface is correctly sized - ::RedrawWindow(flutter_window_, nullptr, nullptr, - RDW_INVALIDATE | RDW_UPDATENOW | RDW_ALLCHILDREN); + ::RedrawWindow(flutter_window_, nullptr, nullptr, RDW_INVALIDATE | RDW_UPDATENOW | RDW_ALLCHILDREN); } } break; @@ -130,14 +121,12 @@ std::optional MpvCore::WindowProc(HWND hwnd, UINT message, case WM_WINDOWPOSCHANGED: { RECT window_rect; ::GetWindowRect(flutter_window_, &window_rect); - if (window_rect.right - window_rect.left > 0 && - window_rect.bottom - window_rect.top > 0) { - ::SetWindowPos(container_, flutter_window_, window_rect.left, - window_rect.top, window_rect.right - window_rect.left, - window_rect.bottom - window_rect.top, SWP_NOACTIVATE); + if (window_rect.right - window_rect.left > 0 && window_rect.bottom - window_rect.top > 0) { + ::SetWindowPos( + container_, flutter_window_, window_rect.left, window_rect.top, window_rect.right - window_rect.left, + window_rect.bottom - window_rect.top, SWP_NOACTIVATE); // Window is minimized (negative coordinates). - if (window_rect.left < 0 && window_rect.top < 0 && - window_rect.right < 0 && window_rect.bottom < 0) { + if (window_rect.left < 0 && window_rect.top < 0 && window_rect.right < 0 && window_rect.bottom < 0) { DisableComposition(); was_window_hidden_due_to_minimize_ = true; } @@ -158,8 +147,7 @@ std::optional MpvCore::WindowProc(HWND hwnd, UINT message, return std::nullopt; } -RECT MpvCore::GetGlobalRect(int32_t left, int32_t top, int32_t right, - int32_t bottom) { +RECT MpvCore::GetGlobalRect(int32_t left, int32_t top, int32_t right, int32_t bottom) { // Expand client area to prevent transparent gaps. left -= static_cast(ceil(device_pixel_ratio_)); top -= static_cast(ceil(device_pixel_ratio_)); @@ -177,8 +165,8 @@ RECT MpvCore::GetGlobalRect(int32_t left, int32_t top, int32_t right, } void MpvCore::EnableComposition() { - ::SetWindowPos(flutter_window_, nullptr, 0, 0, 0, 0, - SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE); + ::SetWindowPos( + flutter_window_, nullptr, 0, 0, 0, 0, SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE); if (!composition_enabled_) { SetWindowComposition(flutter_window_, 2, 0); composition_enabled_ = true; diff --git a/windows/runner/mpv/mpv_core.h b/windows/runner/mpv/mpv_core.h index 49233d1c..f12b5ec5 100644 --- a/windows/runner/mpv/mpv_core.h +++ b/windows/runner/mpv/mpv_core.h @@ -39,8 +39,7 @@ class MpvCore { void SetVisible(bool visible); // Window procedure handler for Flutter window messages. - std::optional WindowProc(HWND hwnd, UINT message, WPARAM wparam, - LPARAM lparam); + std::optional WindowProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam); private: RECT GetGlobalRect(int32_t left, int32_t top, int32_t right, int32_t bottom); diff --git a/windows/runner/mpv/mpv_player.cpp b/windows/runner/mpv/mpv_player.cpp index cb6804c5..0064c4cc 100644 --- a/windows/runner/mpv/mpv_player.cpp +++ b/windows/runner/mpv/mpv_player.cpp @@ -23,9 +23,8 @@ bool MpvPlayer::Initialize(HWND container, HWND flutter_window) { } // Create a child window for mpv to render into. - hwnd_ = ::CreateWindowW(L"STATIC", L"", WS_CHILD | WS_VISIBLE, 0, 0, 100, 100, - container, nullptr, GetModuleHandle(nullptr), - nullptr); + hwnd_ = ::CreateWindowW( + L"STATIC", L"", WS_CHILD | WS_VISIBLE, 0, 0, 100, 100, container, nullptr, GetModuleHandle(nullptr), nullptr); if (!hwnd_) { mpv_destroy(mpv_); mpv_ = nullptr; @@ -118,8 +117,7 @@ void MpvPlayer::Command(const std::vector& args) { mpv_command(mpv_, c_args.data()); } -void MpvPlayer::CommandAsync(const std::vector& args, - CommandCallback callback) { +void MpvPlayer::CommandAsync(const std::vector& args, CommandCallback callback) { if (!mpv_) { if (callback) callback(0); return; @@ -178,9 +176,7 @@ std::string MpvPlayer::GetProperty(const std::string& name) { return result; } -void MpvPlayer::ObserveProperty(const std::string& name, - const std::string& format, - int id) { +void MpvPlayer::ObserveProperty(const std::string& name, const std::string& format, int id) { if (!mpv_) return; // Check if already observing. @@ -213,9 +209,10 @@ void MpvPlayer::SetRect(RECT rect, double device_pixel_ratio) { device_pixel_ratio_ = device_pixel_ratio; if (hwnd_ && container_ && flutter_window_) { - // The rect from Dart is in Flutter client area coordinates (0,0 is top-left of Flutter content). - // The container window is positioned to match the Flutter window's full bounds (including title bar). - // We need to offset the mpv window within the container to align with Flutter's client area. + // The rect from Dart is in Flutter client area coordinates (0,0 is top-left of Flutter + // content). The container window is positioned to match the Flutter window's full bounds + // (including title bar). We need to offset the mpv window within the container to align with + // Flutter's client area. // Get the Flutter window's window rect (screen coordinates, includes title bar) RECT window_rect; @@ -310,17 +307,13 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) { case MPV_EVENT_LOG_MESSAGE: { auto* msg = static_cast(event->data); char log_msg[512]; - snprintf(log_msg, sizeof(log_msg), "MPV [%s] %s: %s", - msg->level, msg->prefix, msg->text); + snprintf(log_msg, sizeof(log_msg), "MPV [%s] %s: %s", msg->level, msg->prefix, msg->text); OutputDebugStringA(log_msg); flutter::EncodableMap data; - data[flutter::EncodableValue("prefix")] = - flutter::EncodableValue(SanitizeUtf8(msg->prefix)); - data[flutter::EncodableValue("level")] = - flutter::EncodableValue(SanitizeUtf8(msg->level)); - data[flutter::EncodableValue("text")] = - flutter::EncodableValue(SanitizeUtf8(msg->text)); + data[flutter::EncodableValue("prefix")] = flutter::EncodableValue(SanitizeUtf8(msg->prefix)); + data[flutter::EncodableValue("level")] = flutter::EncodableValue(SanitizeUtf8(msg->level)); + data[flutter::EncodableValue("text")] = flutter::EncodableValue(SanitizeUtf8(msg->text)); SendEvent("log-message", data); break; } @@ -353,8 +346,7 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) { } // Handle sig-peak for HDR detection - if (strcmp(prop->name, "video-params/sig-peak") == 0 && - prop->format == MPV_FORMAT_DOUBLE && prop->data) { + if (strcmp(prop->name, "video-params/sig-peak") == 0 && prop->format == MPV_FORMAT_DOUBLE && prop->data) { double sigPeak = *static_cast(prop->data); last_sig_peak_ = sigPeak; UpdateHDRMode(sigPeak); @@ -364,8 +356,7 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) { // to null output (e.g. after sleep/wake or device unplug), re-set // audio-device to switch back to the real output. // Mirrors mpv's TOOLS/lua/ao-null-reload.lua for embedded libmpv. - if (strcmp(prop->name, "audio-device-list") == 0 && - GetProperty("current-ao") == "null") { + if (strcmp(prop->name, "audio-device-list") == 0 && GetProperty("current-ao") == "null") { auto device = GetProperty("audio-device"); if (!device.empty()) { mpv_set_property_string(mpv_, "audio-device", device.c_str()); @@ -378,13 +369,10 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) { case MPV_EVENT_END_FILE: { auto* end = static_cast(event->data); flutter::EncodableMap data; - data[flutter::EncodableValue("reason")] = - flutter::EncodableValue(static_cast(end->reason)); + data[flutter::EncodableValue("reason")] = flutter::EncodableValue(static_cast(end->reason)); if (end->reason == MPV_END_FILE_REASON_ERROR) { - data[flutter::EncodableValue("error")] = - flutter::EncodableValue(static_cast(end->error)); - data[flutter::EncodableValue("message")] = - flutter::EncodableValue(SanitizeUtf8(mpv_error_string(end->error))); + data[flutter::EncodableValue("error")] = flutter::EncodableValue(static_cast(end->error)); + data[flutter::EncodableValue("message")] = flutter::EncodableValue(SanitizeUtf8(mpv_error_string(end->error))); } SendEvent("end-file", data); break; @@ -443,8 +431,7 @@ void MpvPlayer::SendPropertyChange(const char* name, mpv_node* data) { } } -void MpvPlayer::SendEvent(const std::string& name, - const flutter::EncodableMap& data) { +void MpvPlayer::SendEvent(const std::string& name, const flutter::EncodableMap& data) { flutter::EncodableMap event; event[flutter::EncodableValue("type")] = flutter::EncodableValue("event"); event[flutter::EncodableValue("name")] = flutter::EncodableValue(name); diff --git a/windows/runner/mpv/mpv_player.h b/windows/runner/mpv/mpv_player.h index f144fa86..142ef5c9 100644 --- a/windows/runner/mpv/mpv_player.h +++ b/windows/runner/mpv/mpv_player.h @@ -2,6 +2,7 @@ #define MPV_PLAYER_H_ #include +#include #include #include @@ -13,16 +14,13 @@ #include #include -#include - namespace mpv { // Wrapper for libmpv that handles initialization, commands, properties, // and event dispatching. class MpvPlayer { public: - using EventCallback = - std::function; + using EventCallback = std::function; MpvPlayer(); ~MpvPlayer(); @@ -53,8 +51,7 @@ class MpvPlayer { std::string GetProperty(const std::string& name); // Observes an mpv property for changes. - void ObserveProperty(const std::string& name, const std::string& format, - int id); + void ObserveProperty(const std::string& name, const std::string& format, int id); // Returns the mpv video window handle. HWND GetHwnd() const { return hwnd_; } @@ -77,8 +74,7 @@ class MpvPlayer { void EventLoop(); void HandleMpvEvent(mpv_event* event); void SendPropertyChange(const char* name, mpv_node* data); - void SendEvent(const std::string& name, - const flutter::EncodableMap& data = {}); + void SendEvent(const std::string& name, const flutter::EncodableMap& data = {}); mpv_handle* mpv_ = nullptr; HWND hwnd_ = nullptr; @@ -101,8 +97,8 @@ class MpvPlayer { std::mutex pending_commands_mutex_; // HDR state - bool hdr_enabled_ = true; // User preference - double last_sig_peak_ = 0.0; // Last known sig-peak for HDR content detection + bool hdr_enabled_ = true; // User preference + double last_sig_peak_ = 0.0; // Last known sig-peak for HDR content detection // HDR methods void SetHDREnabled(bool enabled); diff --git a/windows/runner/mpv/mpv_plugin.cpp b/windows/runner/mpv/mpv_plugin.cpp index cd0964cc..6d6de516 100644 --- a/windows/runner/mpv/mpv_plugin.cpp +++ b/windows/runner/mpv/mpv_plugin.cpp @@ -11,52 +11,40 @@ static flutter::EncodableMap DisplayModeToMap(const mpv::DisplayMode& mode) { return m; } -void MpvPlayerPluginRegisterWithRegistrar( - FlutterDesktopPluginRegistrarRef registrar) { +void MpvPlayerPluginRegisterWithRegistrar(FlutterDesktopPluginRegistrarRef registrar) { mpv::MpvPlayerPlugin::RegisterWithRegistrar( - flutter::PluginRegistrarManager::GetInstance() - ->GetRegistrar(registrar)); + flutter::PluginRegistrarManager::GetInstance()->GetRegistrar(registrar)); } namespace mpv { -void MpvPlayerPlugin::RegisterWithRegistrar( - flutter::PluginRegistrarWindows* registrar) { +void MpvPlayerPlugin::RegisterWithRegistrar(flutter::PluginRegistrarWindows* registrar) { auto plugin = std::make_unique(registrar); registrar->AddPlugin(std::move(plugin)); } -MpvPlayerPlugin::MpvPlayerPlugin(flutter::PluginRegistrarWindows* registrar) - : registrar_(registrar) { +MpvPlayerPlugin::MpvPlayerPlugin(flutter::PluginRegistrarWindows* registrar) : registrar_(registrar) { // Create method channel. - method_channel_ = - std::make_unique>( - registrar->messenger(), "com.plezy/mpv_player", - &flutter::StandardMethodCodec::GetInstance()); + method_channel_ = std::make_unique>( + registrar->messenger(), "com.plezy/mpv_player", &flutter::StandardMethodCodec::GetInstance()); method_channel_->SetMethodCallHandler( - [this](const auto& call, auto result) { - HandleMethodCall(call, std::move(result)); - }); + [this](const auto& call, auto result) { HandleMethodCall(call, std::move(result)); }); // Create event channel. - event_channel_ = - std::make_unique>( - registrar->messenger(), "com.plezy/mpv_player/events", - &flutter::StandardMethodCodec::GetInstance()); + event_channel_ = std::make_unique>( + registrar->messenger(), "com.plezy/mpv_player/events", &flutter::StandardMethodCodec::GetInstance()); - auto handler = std::make_unique< - flutter::StreamHandlerFunctions>( - [this](const flutter::EncodableValue* arguments, - std::unique_ptr>&& - events) -> std::unique_ptr> { + auto handler = std::make_unique>( + [this]( + const flutter::EncodableValue* arguments, + std::unique_ptr>&& events) + -> std::unique_ptr> { event_sink_ = std::move(events); return nullptr; }, [this](const flutter::EncodableValue* arguments) - -> std::unique_ptr< - flutter::StreamHandlerError> { + -> std::unique_ptr> { event_sink_ = nullptr; return nullptr; }); @@ -72,13 +60,9 @@ MpvPlayerPlugin::~MpvPlayerPlugin() { } } -HWND MpvPlayerPlugin::GetChildWindow() { - return registrar_->GetView()->GetNativeWindow(); -} +HWND MpvPlayerPlugin::GetChildWindow() { return registrar_->GetView()->GetNativeWindow(); } -HWND MpvPlayerPlugin::GetWindow() { - return ::GetAncestor(GetChildWindow(), GA_ROOT); -} +HWND MpvPlayerPlugin::GetWindow() { return ::GetAncestor(GetChildWindow(), GA_ROOT); } void MpvPlayerPlugin::HandleMethodCall( const flutter::MethodCall& method_call, @@ -94,11 +78,10 @@ void MpvPlayerPlugin::HandleMethodCall( HWND flutter_window = GetWindow(); - MpvCore::SetInstance( - std::make_unique(flutter_window)); + MpvCore::SetInstance(std::make_unique(flutter_window)); - proc_id_ = registrar_->RegisterTopLevelWindowProcDelegate( - [](HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) { + proc_id_ = + registrar_->RegisterTopLevelWindowProcDelegate([](HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) { auto* core = MpvCore::GetInstance(); if (core) { return core->WindowProc(hwnd, message, wparam, lparam); @@ -121,9 +104,7 @@ void MpvPlayerPlugin::HandleMethodCall( if (success) { // Set up event callback. - player_->SetEventCallback([this](const flutter::EncodableValue& event) { - SendEvent(event); - }); + player_->SetEventCallback([this](const flutter::EncodableValue& event) { SendEvent(event); }); // Register the mpv window with core for z-order management. RECT rect = {0, 0, 100, 100}; @@ -161,8 +142,7 @@ void MpvPlayerPlugin::HandleMethodCall( const auto& map = std::get(*args); auto it = map.find(flutter::EncodableValue("args")); - if (it == map.end() || - !std::holds_alternative(it->second)) { + if (it == map.end() || !std::holds_alternative(it->second)) { result->Error("INVALID_ARGS", "Missing 'args' list"); return; } @@ -177,12 +157,13 @@ void MpvPlayerPlugin::HandleMethodCall( // Use async command to prevent UI blocking during network operations // Move result into shared_ptr for safe capture in callback - auto result_ptr = std::make_shared>>(std::move(result)); + auto result_ptr = + std::make_shared>>(std::move(result)); std::string cmd_name = command_args.empty() ? "unknown" : command_args[0]; player_->CommandAsync(command_args, [result_ptr, cmd_name](int error) { if (error < 0) { - (*result_ptr)->Error("COMMAND_FAILED", - "MPV command failed: " + cmd_name + " (error " + std::to_string(error) + ")"); + (*result_ptr) + ->Error("COMMAND_FAILED", "MPV command failed: " + cmd_name + " (error " + std::to_string(error) + ")"); } else { (*result_ptr)->Success(); } @@ -204,19 +185,16 @@ void MpvPlayerPlugin::HandleMethodCall( auto name_it = map.find(flutter::EncodableValue("name")); auto value_it = map.find(flutter::EncodableValue("value")); - if (name_it == map.end() || - !std::holds_alternative(name_it->second)) { + if (name_it == map.end() || !std::holds_alternative(name_it->second)) { result->Error("INVALID_ARGS", "Missing 'name'"); return; } - if (value_it == map.end() || - !std::holds_alternative(value_it->second)) { + if (value_it == map.end() || !std::holds_alternative(value_it->second)) { result->Error("INVALID_ARGS", "Missing 'value'"); return; } - player_->SetProperty(std::get(name_it->second), - std::get(value_it->second)); + player_->SetProperty(std::get(name_it->second), std::get(value_it->second)); result->Success(); } else if (method == "setLogLevel") { if (!player_ || !player_->IsInitialized()) { @@ -233,8 +211,7 @@ void MpvPlayerPlugin::HandleMethodCall( const auto& map = std::get(*args); auto level_it = map.find(flutter::EncodableValue("level")); - if (level_it == map.end() || - !std::holds_alternative(level_it->second)) { + if (level_it == map.end() || !std::holds_alternative(level_it->second)) { result->Error("INVALID_ARGS", "Missing 'level'"); return; } @@ -256,14 +233,12 @@ void MpvPlayerPlugin::HandleMethodCall( const auto& map = std::get(*args); auto name_it = map.find(flutter::EncodableValue("name")); - if (name_it == map.end() || - !std::holds_alternative(name_it->second)) { + if (name_it == map.end() || !std::holds_alternative(name_it->second)) { result->Error("INVALID_ARGS", "Missing 'name'"); return; } - std::string value = - player_->GetProperty(std::get(name_it->second)); + std::string value = player_->GetProperty(std::get(name_it->second)); if (value.empty()) { result->Success(); } else { @@ -286,25 +261,22 @@ void MpvPlayerPlugin::HandleMethodCall( auto format_it = map.find(flutter::EncodableValue("format")); auto id_it = map.find(flutter::EncodableValue("id")); - if (name_it == map.end() || - !std::holds_alternative(name_it->second)) { + if (name_it == map.end() || !std::holds_alternative(name_it->second)) { result->Error("INVALID_ARGS", "Missing 'name'"); return; } - if (format_it == map.end() || - !std::holds_alternative(format_it->second)) { + if (format_it == map.end() || !std::holds_alternative(format_it->second)) { result->Error("INVALID_ARGS", "Missing 'format'"); return; } - if (id_it == map.end() || - !std::holds_alternative(id_it->second)) { + if (id_it == map.end() || !std::holds_alternative(id_it->second)) { result->Error("INVALID_ARGS", "Missing 'id'"); return; } - player_->ObserveProperty(std::get(name_it->second), - std::get(format_it->second), - std::get(id_it->second)); + player_->ObserveProperty( + std::get(name_it->second), std::get(format_it->second), + std::get(id_it->second)); result->Success(); } else if (method == "setVisible") { const auto* args = method_call.arguments(); @@ -316,8 +288,7 @@ void MpvPlayerPlugin::HandleMethodCall( const auto& map = std::get(*args); auto visible_it = map.find(flutter::EncodableValue("visible")); - if (visible_it == map.end() || - !std::holds_alternative(visible_it->second)) { + if (visible_it == map.end() || !std::holds_alternative(visible_it->second)) { result->Error("INVALID_ARGS", "Missing 'visible'"); return; } @@ -378,7 +349,7 @@ void MpvPlayerPlugin::HandleMethodCall( bool initialized = player_ && player_->IsInitialized(); result->Success(flutter::EncodableValue(initialized)); - // --- Display mode matching --- + // --- Display mode matching --- } else if (method == "getDisplayModes") { HWND hwnd = GetWindow(); auto modes = display_mode_manager_.EnumerateDisplayModes(hwnd); @@ -400,13 +371,12 @@ void MpvPlayerPlugin::HandleMethodCall( const auto& map = std::get(*args); auto get_int = [&map](const char* key) -> int { auto it = map.find(flutter::EncodableValue(key)); - if (it != map.end() && std::holds_alternative(it->second)) - return std::get(it->second); + if (it != map.end() && std::holds_alternative(it->second)) return std::get(it->second); return 0; }; HWND hwnd = GetWindow(); - bool success = display_mode_manager_.SetDisplayMode( - hwnd, get_int("width"), get_int("height"), get_int("refreshRate")); + bool success = + display_mode_manager_.SetDisplayMode(hwnd, get_int("width"), get_int("height"), get_int("refreshRate")); result->Success(flutter::EncodableValue(success)); } else if (method == "restoreDisplayMode") { HWND hwnd = GetWindow(); diff --git a/windows/runner/mpv/mpv_plugin.h b/windows/runner/mpv/mpv_plugin.h index 1136fa06..57fd31fd 100644 --- a/windows/runner/mpv/mpv_plugin.h +++ b/windows/runner/mpv/mpv_plugin.h @@ -16,8 +16,7 @@ #include "mpv_player.h" // C-style registration function for the plugin. -void MpvPlayerPluginRegisterWithRegistrar( - FlutterDesktopPluginRegistrarRef registrar); +void MpvPlayerPluginRegisterWithRegistrar(FlutterDesktopPluginRegistrarRef registrar); namespace mpv { @@ -39,10 +38,8 @@ class MpvPlayerPlugin : public flutter::Plugin { HWND GetChildWindow(); flutter::PluginRegistrarWindows* registrar_; - std::unique_ptr> - method_channel_; - std::unique_ptr> - event_channel_; + std::unique_ptr> method_channel_; + std::unique_ptr> event_channel_; std::unique_ptr> event_sink_; std::unique_ptr player_; diff --git a/windows/runner/mpv/utils.cpp b/windows/runner/mpv/utils.cpp index 1be4e467..8578aa17 100644 --- a/windows/runner/mpv/utils.cpp +++ b/windows/runner/mpv/utils.cpp @@ -56,8 +56,7 @@ typedef struct _ACCENT_POLICY { DWORD AnimationId; } ACCENT_POLICY; -typedef BOOL(WINAPI* _SetWindowCompositionAttribute)( - HWND, WINDOWCOMPOSITIONATTRIBDATA*); +typedef BOOL(WINAPI* _SetWindowCompositionAttribute)(HWND, WINDOWCOMPOSITIONATTRIBDATA*); static _SetWindowCompositionAttribute g_set_window_composition_attribute = NULL; static bool g_set_window_composition_attribute_initialized = false; @@ -71,8 +70,7 @@ static RTL_OSVERSIONINFOW GetWindowsVersion() { static RTL_OSVERSIONINFOW cached = []() { HMODULE hmodule = ::GetModuleHandleW(L"ntdll.dll"); if (hmodule) { - RtlGetVersionPtr rtl_get_version_ptr = - (RtlGetVersionPtr)::GetProcAddress(hmodule, "RtlGetVersion"); + RtlGetVersionPtr rtl_get_version_ptr = (RtlGetVersionPtr)::GetProcAddress(hmodule, "RtlGetVersion"); if (rtl_get_version_ptr != nullptr) { RTL_OSVERSIONINFOW rovi = {0}; rovi.dwOSVersionInfoSize = sizeof(rovi); @@ -87,23 +85,20 @@ static RTL_OSVERSIONINFOW GetWindowsVersion() { return cached; } -void SetWindowComposition(HWND window, int32_t accent_state, - int32_t gradient_color) { +void SetWindowComposition(HWND window, int32_t accent_state, int32_t gradient_color) { if (GetWindowsVersion().dwBuildNumber >= 18362) { if (!g_set_window_composition_attribute_initialized) { auto user32 = ::GetModuleHandleA("user32.dll"); if (user32) { g_set_window_composition_attribute = - reinterpret_cast<_SetWindowCompositionAttribute>( - ::GetProcAddress(user32, "SetWindowCompositionAttribute")); + reinterpret_cast<_SetWindowCompositionAttribute>(::GetProcAddress(user32, "SetWindowCompositionAttribute")); if (g_set_window_composition_attribute) { g_set_window_composition_attribute_initialized = true; } } } if (g_set_window_composition_attribute) { - ACCENT_POLICY accent = {static_cast(accent_state), 2, - static_cast(gradient_color), 0}; + ACCENT_POLICY accent = {static_cast(accent_state), 2, static_cast(gradient_color), 0}; WINDOWCOMPOSITIONATTRIBDATA data; data.Attrib = WCA_ACCENT_POLICY; data.pvData = &accent; diff --git a/windows/runner/mpv/utils.h b/windows/runner/mpv/utils.h index 5fd07796..7f9d3a37 100644 --- a/windows/runner/mpv/utils.h +++ b/windows/runner/mpv/utils.h @@ -11,8 +11,7 @@ namespace mpv { // Sets window composition attribute for transparency. // accent_state = 6 enables per-pixel transparency. // accent_state = 0 makes window opaque. -void SetWindowComposition(HWND window, int32_t accent_state, - int32_t gradient_color); +void SetWindowComposition(HWND window, int32_t accent_state, int32_t gradient_color); } // namespace mpv diff --git a/windows/runner/pch.h b/windows/runner/pch.h index b7c26ecd..3e02fd72 100644 --- a/windows/runner/pch.h +++ b/windows/runner/pch.h @@ -1,19 +1,19 @@ -#ifndef PCH_H -#define PCH_H - -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - +#ifndef PCH_H +#define PCH_H + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + #endif \ No newline at end of file diff --git a/windows/runner/resource.h b/windows/runner/resource.h index 66a65d1e..d5d958dc 100644 --- a/windows/runner/resource.h +++ b/windows/runner/resource.h @@ -2,15 +2,15 @@ // Microsoft Visual C++ generated include file. // Used by Runner.rc // -#define IDI_APP_ICON 101 +#define IDI_APP_ICON 101 // Next default values for new objects // #ifdef APSTUDIO_INVOKED #ifndef APSTUDIO_READONLY_SYMBOLS -#define _APS_NEXT_RESOURCE_VALUE 102 -#define _APS_NEXT_COMMAND_VALUE 40001 -#define _APS_NEXT_CONTROL_VALUE 1001 -#define _APS_NEXT_SYMED_VALUE 101 +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 #endif #endif diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp index be639d6e..bfae7b7d 100644 --- a/windows/runner/utils.cpp +++ b/windows/runner/utils.cpp @@ -9,7 +9,7 @@ void CreateAndAttachConsole() { if (::AllocConsole()) { - FILE *unused; + FILE* unused; freopen_s(&unused, "CONOUT$", "w", stdout); freopen_s(&unused, "CONOUT$", "w", stderr); std::ios::sync_with_stdio(); @@ -41,9 +41,7 @@ std::string Utf8FromUtf16(const wchar_t* utf16_string) { if (utf16_string == nullptr) { return std::string(); } - int raw_length = ::WideCharToMultiByte( - CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, - -1, nullptr, 0, nullptr, nullptr); + int raw_length = ::WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, -1, nullptr, 0, nullptr, nullptr); if (raw_length <= 1) { return std::string(); } @@ -52,8 +50,7 @@ std::string Utf8FromUtf16(const wchar_t* utf16_string) { std::string utf8_string; utf8_string.resize(target_length); int converted_length = ::WideCharToMultiByte( - CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, - input_length, utf8_string.data(), target_length, nullptr, nullptr); + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, input_length, utf8_string.data(), target_length, nullptr, nullptr); if (converted_length == 0) { return std::string(); } diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp index 60608d0f..f6b4dc5a 100644 --- a/windows/runner/win32_window.cpp +++ b/windows/runner/win32_window.cpp @@ -23,7 +23,7 @@ constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; /// A value of 0 indicates apps should use dark mode. A non-zero or missing /// value indicates apps should use light mode. constexpr const wchar_t kGetPreferredBrightnessRegKey[] = - L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; // The number of Win32Window objects that currently exist. @@ -33,9 +33,7 @@ using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); // Scale helper to convert logical scaler values to physical using passed in // scale factor -int Scale(int source, double scale_factor) { - return static_cast(source * scale_factor); -} +int Scale(int source, double scale_factor) { return static_cast(source * scale_factor); } // Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. // This API is only needed for PerMonitor V1 awareness mode. @@ -45,8 +43,7 @@ void EnableFullDpiSupportIfAvailable(HWND hwnd) { return; } auto enable_non_client_dpi_scaling = - reinterpret_cast( - GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + reinterpret_cast(GetProcAddress(user32_module, "EnableNonClientDpiScaling")); if (enable_non_client_dpi_scaling != nullptr) { enable_non_client_dpi_scaling(hwnd); } @@ -95,8 +92,7 @@ const wchar_t* WindowClassRegistrar::GetWindowClass() { window_class.cbClsExtra = 0; window_class.cbWndExtra = 0; window_class.hInstance = GetModuleHandle(nullptr); - window_class.hIcon = - LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hIcon = LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); window_class.hbrBackground = 0; window_class.lpszMenuName = nullptr; window_class.lpfnWndProc = Win32Window::WndProc; @@ -111,34 +107,27 @@ void WindowClassRegistrar::UnregisterWindowClass() { class_registered_ = false; } -Win32Window::Win32Window() { - ++g_active_window_count; -} +Win32Window::Win32Window() { ++g_active_window_count; } Win32Window::~Win32Window() { --g_active_window_count; Destroy(); } -bool Win32Window::Create(const std::wstring& title, - const Point& origin, - const Size& size) { +bool Win32Window::Create(const std::wstring& title, const Point& origin, const Size& size) { Destroy(); - const wchar_t* window_class = - WindowClassRegistrar::GetInstance()->GetWindowClass(); + const wchar_t* window_class = WindowClassRegistrar::GetInstance()->GetWindowClass(); - const POINT target_point = {static_cast(origin.x), - static_cast(origin.y)}; + const POINT target_point = {static_cast(origin.x), static_cast(origin.y)}; HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); double scale_factor = dpi / 96.0; HWND window = CreateWindow( - window_class, title.c_str(), WS_OVERLAPPEDWINDOW, - Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), - Scale(size.width, scale_factor), Scale(size.height, scale_factor), - nullptr, nullptr, GetModuleHandle(nullptr), this); + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), nullptr, nullptr, GetModuleHandle(nullptr), + this); if (!window) { return false; @@ -149,19 +138,14 @@ bool Win32Window::Create(const std::wstring& title, return OnCreate(); } -bool Win32Window::Show() { - return ShowWindow(window_handle_, SW_SHOWNORMAL); -} +bool Win32Window::Show() { return ShowWindow(window_handle_, SW_SHOWNORMAL); } // static -LRESULT CALLBACK Win32Window::WndProc(HWND const window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { +LRESULT CALLBACK +Win32Window::WndProc(HWND const window, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept { if (message == WM_NCCREATE) { auto window_struct = reinterpret_cast(lparam); - SetWindowLongPtr(window, GWLP_USERDATA, - reinterpret_cast(window_struct->lpCreateParams)); + SetWindowLongPtr(window, GWLP_USERDATA, reinterpret_cast(window_struct->lpCreateParams)); auto that = static_cast(window_struct->lpCreateParams); EnableFullDpiSupportIfAvailable(window); @@ -174,10 +158,7 @@ LRESULT CALLBACK Win32Window::WndProc(HWND const window, } LRESULT -Win32Window::MessageHandler(HWND hwnd, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { +Win32Window::MessageHandler(HWND hwnd, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept { switch (message) { case WM_DESTROY: window_handle_ = nullptr; @@ -192,8 +173,8 @@ Win32Window::MessageHandler(HWND hwnd, LONG newWidth = newRectSize->right - newRectSize->left; LONG newHeight = newRectSize->bottom - newRectSize->top; - SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, - newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + SetWindowPos( + hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, newHeight, SWP_NOZORDER | SWP_NOACTIVATE); return 0; } @@ -201,8 +182,7 @@ Win32Window::MessageHandler(HWND hwnd, RECT rect = GetClientArea(); if (child_content_ != nullptr) { // Size and position the child window. - MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, - rect.bottom - rect.top, TRUE); + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top, TRUE); } return 0; } @@ -234,8 +214,7 @@ void Win32Window::Destroy() { } Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { - return reinterpret_cast( - GetWindowLongPtr(window, GWLP_USERDATA)); + return reinterpret_cast(GetWindowLongPtr(window, GWLP_USERDATA)); } void Win32Window::SetChildContent(HWND content) { @@ -243,8 +222,7 @@ void Win32Window::SetChildContent(HWND content) { SetParent(content, window_handle_); RECT frame = GetClientArea(); - MoveWindow(content, frame.left, frame.top, frame.right - frame.left, - frame.bottom - frame.top, true); + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, frame.bottom - frame.top, true); SetFocus(child_content_); } @@ -255,13 +233,9 @@ RECT Win32Window::GetClientArea() { return frame; } -HWND Win32Window::GetHandle() { - return window_handle_; -} +HWND Win32Window::GetHandle() { return window_handle_; } -void Win32Window::SetQuitOnClose(bool quit_on_close) { - quit_on_close_ = quit_on_close; -} +void Win32Window::SetQuitOnClose(bool quit_on_close) { quit_on_close_ = quit_on_close; } bool Win32Window::OnCreate() { // No-op; provided for subclasses. @@ -275,14 +249,12 @@ void Win32Window::OnDestroy() { void Win32Window::UpdateTheme(HWND const window) { DWORD light_mode; DWORD light_mode_size = sizeof(light_mode); - LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, - kGetPreferredBrightnessRegValue, - RRF_RT_REG_DWORD, nullptr, &light_mode, - &light_mode_size); + LSTATUS result = RegGetValue( + HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, kGetPreferredBrightnessRegValue, RRF_RT_REG_DWORD, nullptr, + &light_mode, &light_mode_size); if (result == ERROR_SUCCESS) { BOOL enable_dark_mode = light_mode == 0; - DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, - &enable_dark_mode, sizeof(enable_dark_mode)); + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, &enable_dark_mode, sizeof(enable_dark_mode)); } } diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h index e901dde6..45431362 100644 --- a/windows/runner/win32_window.h +++ b/windows/runner/win32_window.h @@ -21,8 +21,7 @@ class Win32Window { struct Size { unsigned int width; unsigned int height; - Size(unsigned int width, unsigned int height) - : width(width), height(height) {} + Size(unsigned int width, unsigned int height) : width(width), height(height) {} }; Win32Window(); @@ -59,10 +58,7 @@ class Win32Window { // Processes and route salient window messages for mouse handling, // size change and DPI. Delegates handling of these to member overloads that // inheriting classes can handle. - virtual LRESULT MessageHandler(HWND window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept; + virtual LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept; // Called when CreateAndShow is called, allowing subclass window-related // setup. Subclasses should return false if setup fails. @@ -79,10 +75,8 @@ class Win32Window { // non-client DPI scaling so that the non-client area automatically // responds to changes in DPI. All other messages are handled by // MessageHandler. - static LRESULT CALLBACK WndProc(HWND const window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept; + static LRESULT CALLBACK + WndProc(HWND const window, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept; // Retrieves a class instance pointer for |window| static Win32Window* GetThisFromHandle(HWND const window) noexcept;