chore: add native formatting checks
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
BasedOnStyle: Google
|
||||
AlignAfterOpenBracket: AlwaysBreak
|
||||
ColumnLimit: 120
|
||||
IndentWidth: 2
|
||||
ContinuationIndentWidth: 4
|
||||
DerivePointerAlignment: false
|
||||
LineEnding: LF
|
||||
PointerAlignment: Left
|
||||
SortIncludes: true
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"indentation" : {
|
||||
"spaces" : 2
|
||||
},
|
||||
"lineLength" : 120,
|
||||
"maximumBlankLines" : 1,
|
||||
"respectsExistingLineBreaks" : true,
|
||||
"rules" : {},
|
||||
"version" : 1
|
||||
}
|
||||
+5
-3
@@ -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
|
||||
|
||||
|
||||
+129
-130
@@ -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))) }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include <jni.h>
|
||||
#include <android/log.h>
|
||||
#include <jni.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <new>
|
||||
|
||||
@@ -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<size_t>(len)];
|
||||
if (buf == nullptr) return nullptr;
|
||||
|
||||
env->GetByteArrayRegion(payload, 0, len, reinterpret_cast<jbyte *>(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<size_t>(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<size_t>(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<size_t>(len)];
|
||||
if (buf == nullptr) return nullptr;
|
||||
|
||||
env->GetByteArrayRegion(payload, 0, len, reinterpret_cast<jbyte*>(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<uint8_t>(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<size_t>(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<size_t>(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<jsize>(out->len));
|
||||
if (result != nullptr) {
|
||||
env->SetByteArrayRegion(result, 0, static_cast<jsize>(out->len),
|
||||
reinterpret_cast<const jbyte *>(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<uint8_t>(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<jsize>(out->len));
|
||||
if (result != nullptr) {
|
||||
env->SetByteArrayRegion(result, 0, static_cast<jsize>(out->len), reinterpret_cast<const jbyte*>(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);
|
||||
}
|
||||
|
||||
@@ -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<ViewGroup>(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<ViewGroup>(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<String>("filePath")
|
||||
val packageName = call.argument<String>("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<String>("filePath")
|
||||
val packageName = call.argument<String>("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<String>("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<Int>("width") ?: 16
|
||||
val height = call.argument<Int>("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<Boolean>("ready") ?: false
|
||||
autoPipWidth = call.argument<Int>("width") ?: 16
|
||||
autoPipHeight = call.argument<Int>("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<String>("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<Int>("width") ?: 16
|
||||
val height = call.argument<Int>("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<Boolean>("ready") ?: false
|
||||
autoPipWidth = call.argument<Int>("width") ?: 16
|
||||
autoPipHeight = call.argument<Int>("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()
|
||||
}
|
||||
}
|
||||
|
||||
+132
-130
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+398
-363
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+233
-239
@@ -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)
|
||||
}
|
||||
|
||||
+95
-88
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<String, Int>()
|
||||
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<MethodChannel.Result>()
|
||||
|
||||
@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<String, Int>()
|
||||
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<MethodChannel.Result>()
|
||||
@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<String>("name")
|
||||
val value = call.argument<String>("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<String>("name")
|
||||
val value = call.argument<String>("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<String>("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<String>("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<String>("name")
|
||||
val format = call.argument<String>("format")
|
||||
val id = call.argument<Int>("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<List<String>>("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<Boolean>("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<Double>("fps")?.toFloat() ?: 0f
|
||||
val duration = call.argument<Number>("duration")?.toLong() ?: 0L
|
||||
val extraDelayMs = call.argument<Number>("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<String>("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<String>("name")
|
||||
val format = call.argument<String>("format")
|
||||
val id = call.argument<Int>("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<List<String>>("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<Boolean>("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<Double>("fps")?.toFloat() ?: 0f
|
||||
val duration = call.argument<Number>("duration")?.toLong() ?: 0L
|
||||
val extraDelayMs = call.argument<Number>("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<String>("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<String, Any>?) {
|
||||
val event = mutableMapOf<String, Any>(
|
||||
"type" to "event",
|
||||
"name" to name
|
||||
)
|
||||
data?.let { event["data"] = it }
|
||||
eventSink?.success(event)
|
||||
}
|
||||
override fun onEvent(name: String, data: Map<String, Any>?) {
|
||||
val event = mutableMapOf<String, Any>(
|
||||
"type" to "event",
|
||||
"name" to name
|
||||
)
|
||||
data?.let { event["data"] = it }
|
||||
eventSink?.success(event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.edde746.plezy.shared
|
||||
|
||||
interface PlayerDelegate {
|
||||
fun onPropertyChange(name: String, value: Any?)
|
||||
fun onEvent(name: String, data: Map<String, Any>?)
|
||||
fun onPropertyChange(name: String, value: Any?)
|
||||
fun onEvent(name: String, data: Map<String, Any>?)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<List<Map<String, Any?>>>("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<String>("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<String, Any?>): 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<List<Map<String, Any?>>>("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<String>("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<String, Any?>): 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<WatchNextItem>): Boolean = try {
|
||||
val ops = ArrayList<ContentProviderOperation>()
|
||||
|
||||
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<WatchNextItem>): Boolean {
|
||||
return try {
|
||||
val ops = ArrayList<ContentProviderOperation>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#include "mpv_player.h"
|
||||
|
||||
#include <flutter_linux/flutter_linux.h>
|
||||
#include <epoxy/gl.h>
|
||||
#include <epoxy/egl.h>
|
||||
#include <epoxy/gl.h>
|
||||
#include <flutter_linux/flutter_linux.h>
|
||||
#include <gdk/gdk.h>
|
||||
#ifdef GDK_WINDOWING_X11
|
||||
#include <gdk/gdkx.h>
|
||||
@@ -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<char*>(MPV_RENDER_API_TYPE_OPENGL)},
|
||||
{MPV_RENDER_PARAM_API_TYPE, const_cast<char*>(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<std::string>& args) {
|
||||
mpv_command(mpv_, c_args.data());
|
||||
}
|
||||
|
||||
void MpvPlayer::CommandAsync(const std::vector<std::string>& args,
|
||||
CommandCallback callback) {
|
||||
void MpvPlayer::CommandAsync(const std::vector<std::string>& 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<char**>(prop->data) : nullptr;
|
||||
node.u.string = prop->data ? *static_cast<char**>(prop->data) : nullptr;
|
||||
break;
|
||||
case MPV_FORMAT_FLAG:
|
||||
node.u.flag = prop->data ? *static_cast<int*>(prop->data) : 0;
|
||||
@@ -527,13 +516,11 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) {
|
||||
case MPV_EVENT_END_FILE: {
|
||||
auto* end = static_cast<mpv_event_end_file*>(event->data);
|
||||
FlValue* data = fl_value_new_map();
|
||||
fl_value_set_string_take(data, "reason",
|
||||
fl_value_new_int(static_cast<int>(end->reason)));
|
||||
fl_value_set_string_take(data, "reason", fl_value_new_int(static_cast<int>(end->reason)));
|
||||
if (end->reason == MPV_END_FILE_REASON_ERROR) {
|
||||
fl_value_set_string_take(data, "error",
|
||||
fl_value_new_int(static_cast<int>(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<int>(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;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
#ifndef MPV_PLAYER_H_
|
||||
#define MPV_PLAYER_H_
|
||||
|
||||
#include <epoxy/egl.h>
|
||||
#include <epoxy/gl.h>
|
||||
#include <gtk/gtk.h>
|
||||
#include <mpv/client.h>
|
||||
#include <mpv/render.h>
|
||||
#include <mpv/render_gl.h>
|
||||
#include <gtk/gtk.h>
|
||||
#include <epoxy/gl.h>
|
||||
#include <epoxy/egl.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <functional>
|
||||
@@ -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);
|
||||
|
||||
+56
-108
@@ -1,8 +1,9 @@
|
||||
#include "mpv_plugin.h"
|
||||
#include "mpv_texture.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "mpv_texture.h"
|
||||
|
||||
struct _MpvPlugin {
|
||||
GObject parent_instance;
|
||||
|
||||
@@ -12,7 +13,7 @@ struct _MpvPlugin {
|
||||
FlTextureRegistrar* texture_registrar;
|
||||
|
||||
std::unique_ptr<mpv::MpvPlayer> 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<mpv::MpvPlayer>();
|
||||
|
||||
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<std::string> 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<int>(fl_value_get_int(id_value)));
|
||||
self->player->ObserveProperty(
|
||||
fl_value_get_string(name_value), fl_value_get_string(format_value),
|
||||
static_cast<int>(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());
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#include "mpv_texture.h"
|
||||
|
||||
#include <epoxy/gl.h>
|
||||
#include <epoxy/egl.h>
|
||||
#include <epoxy/gl.h>
|
||||
|
||||
// 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)); }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "my_application.h"
|
||||
|
||||
#include <flutter_linux/flutter_linux.h>
|
||||
|
||||
#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));
|
||||
}
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION,
|
||||
GtkApplication)
|
||||
G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, GtkApplication)
|
||||
|
||||
/**
|
||||
* my_application_new:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
|
||||
@interface PIPViewController : NSViewController
|
||||
|
||||
@property (nonatomic, copy, nullable) NSString *name;
|
||||
@property (nonatomic, weak, nullable) id<PIPViewControllerDelegate> 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<PIPViewControllerDelegate> 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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-3
@@ -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'"
|
||||
|
||||
Executable
+163
@@ -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
|
||||
@@ -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<CChar>) -> 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<String> = ["pause", "eof-reached", "paused-for-cache"]
|
||||
/// Properties that must still flow to Dart while backgrounded (state-critical).
|
||||
private static let criticalProperties: Set<String> = [
|
||||
"pause", "eof-reached", "paused-for-cache",
|
||||
]
|
||||
|
||||
let queue = DispatchQueue(label: "mpv", qos: .userInitiated)
|
||||
private let queueKey = DispatchSpecificKey<Void>()
|
||||
let queue = DispatchQueue(label: "mpv", qos: .userInitiated)
|
||||
private let queueKey = DispatchSpecificKey<Void>()
|
||||
|
||||
private var pendingCommands: [UInt64: (Result<Void, Error>) -> Void] = [:]
|
||||
private let pendingCommandsLock = NSLock()
|
||||
private var nextRequestId: UInt64 = 1
|
||||
private var pendingCommands: [UInt64: (Result<Void, Error>) -> 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<MpvPlayerCoreBase>.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<MpvPlayerCoreBase>.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, Error>) -> 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, Error>) -> Void) {
|
||||
guard let mpv, !args.isEmpty else {
|
||||
completion(.success(()))
|
||||
return
|
||||
}
|
||||
var cargs: [UnsafeMutablePointer<CChar>?] = 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<CChar>?] = 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<CChar>?] = ([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<CChar>?] = ([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<CChar>?.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<CChar>?.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..<Int(list.num) {
|
||||
if let item = convertNode(list.values[index]) {
|
||||
array.append(item)
|
||||
}
|
||||
}
|
||||
return array
|
||||
|
||||
case MPV_FORMAT_NODE_MAP:
|
||||
guard let list = node.u.list?.pointee else { return nil }
|
||||
var dictionary = [String: Any]()
|
||||
for index in 0..<Int(list.num) {
|
||||
if let key = list.keys?[index].map({ safeString($0) }),
|
||||
let value = convertNode(list.values[index]) {
|
||||
dictionary[key] = value
|
||||
}
|
||||
}
|
||||
return dictionary
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
if name == "video-params/sig-peak", let sigPeak = value as? Double {
|
||||
lastSigPeak = sigPeak
|
||||
DispatchQueue.main.async {
|
||||
self.updateEDRMode(sigPeak: sigPeak)
|
||||
}
|
||||
}
|
||||
|
||||
func checkError(_ status: CInt) {
|
||||
if status < 0 {
|
||||
print("[MpvPlayerCore] MPV error: \(safeString(mpv_error_string(status)))")
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.onPropertyChange(name: name, value: value)
|
||||
}
|
||||
}
|
||||
|
||||
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..<Int(list.num) {
|
||||
if let item = convertNode(list.values[index]) {
|
||||
array.append(item)
|
||||
}
|
||||
}
|
||||
return array
|
||||
|
||||
case MPV_FORMAT_NODE_MAP:
|
||||
guard let list = node.u.list?.pointee else { return nil }
|
||||
var dictionary = [String: Any]()
|
||||
for index in 0..<Int(list.num) {
|
||||
if let key = list.keys?[index].map({ safeString($0) }),
|
||||
let value = convertNode(list.values[index])
|
||||
{
|
||||
dictionary[key] = value
|
||||
}
|
||||
}
|
||||
return dictionary
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func checkError(_ status: CInt) {
|
||||
if status < 0 {
|
||||
print("[MpvPlayerCore] MPV error: \(safeString(mpv_error_string(status)))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,105 +1,121 @@
|
||||
#if os(iOS) || os(tvOS)
|
||||
import Flutter
|
||||
import Flutter
|
||||
#elseif os(macOS)
|
||||
import FlutterMacOS
|
||||
import FlutterMacOS
|
||||
#endif
|
||||
|
||||
/// Protocol for shared MpvPlayerPlugin method handlers across iOS, tvOS, and macOS.
|
||||
/// Platform-specific methods (PiP, initialization, window finding) remain
|
||||
/// in the per-platform MpvPlayerPlugin files.
|
||||
protocol MpvPluginShared: AnyObject, MpvPlayerDelegate {
|
||||
var coreBase: MpvPlayerCoreBase? { get }
|
||||
var eventSink: FlutterEventSink? { get }
|
||||
var nameToId: [String: Int] { get set }
|
||||
var coreBase: MpvPlayerCoreBase? { get }
|
||||
var eventSink: FlutterEventSink? { get }
|
||||
var nameToId: [String: Int] { get set }
|
||||
|
||||
func setPlayerVisible(_ visible: Bool)
|
||||
func updatePlayerFrame()
|
||||
func setPlayerVisible(_ visible: Bool)
|
||||
func updatePlayerFrame()
|
||||
}
|
||||
|
||||
extension MpvPluginShared {
|
||||
|
||||
func handleGetProperty(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let args = call.arguments as? [String: Any],
|
||||
let name = args["name"] as? String else {
|
||||
result(FlutterError(code: "INVALID_ARGS", message: "Missing 'name' argument", details: nil))
|
||||
return
|
||||
}
|
||||
result(coreBase?.getProperty(name))
|
||||
func handleGetProperty(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let args = call.arguments as? [String: Any],
|
||||
let name = args["name"] as? String
|
||||
else {
|
||||
result(
|
||||
FlutterError(code: "INVALID_ARGS", message: "Missing 'name' argument", details: nil)
|
||||
)
|
||||
return
|
||||
}
|
||||
result(coreBase?.getProperty(name))
|
||||
}
|
||||
|
||||
func handleObserveProperty(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let args = call.arguments as? [String: Any],
|
||||
let name = args["name"] as? String,
|
||||
let format = args["format"] as? String,
|
||||
let id = args["id"] as? Int
|
||||
else {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "INVALID_ARGS", message: "Missing 'name', 'format', or 'id' argument",
|
||||
details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
func handleObserveProperty(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let args = call.arguments as? [String: Any],
|
||||
let name = args["name"] as? String,
|
||||
let format = args["format"] as? String,
|
||||
let id = args["id"] as? Int else {
|
||||
result(FlutterError(code: "INVALID_ARGS", message: "Missing 'name', 'format', or 'id' argument", details: nil))
|
||||
return
|
||||
}
|
||||
nameToId[name] = id
|
||||
coreBase?.observeProperty(name, format: format)
|
||||
result(nil)
|
||||
}
|
||||
|
||||
nameToId[name] = id
|
||||
coreBase?.observeProperty(name, format: format)
|
||||
func handleCommand(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let args = call.arguments as? [String: Any],
|
||||
let commandArgs = args["args"] as? [String]
|
||||
else {
|
||||
result(
|
||||
FlutterError(code: "INVALID_ARGS", message: "Missing 'args' argument", details: nil)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
coreBase?.commandAsync(commandArgs) { commandResult in
|
||||
switch commandResult {
|
||||
case .success:
|
||||
result(nil)
|
||||
case .failure(let error):
|
||||
result(
|
||||
FlutterError(
|
||||
code: "COMMAND_FAILED", message: error.localizedDescription, details: nil))
|
||||
}
|
||||
} ?? result(nil)
|
||||
}
|
||||
|
||||
func handleSetVisible(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let args = call.arguments as? [String: Any],
|
||||
let visible = args["visible"] as? Bool
|
||||
else {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "INVALID_ARGS", message: "Missing 'visible' argument", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
func handleCommand(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let args = call.arguments as? [String: Any],
|
||||
let commandArgs = args["args"] as? [String] else {
|
||||
result(FlutterError(code: "INVALID_ARGS", message: "Missing 'args' argument", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
coreBase?.commandAsync(commandArgs) { commandResult in
|
||||
switch commandResult {
|
||||
case .success:
|
||||
result(nil)
|
||||
case .failure(let error):
|
||||
result(FlutterError(code: "COMMAND_FAILED", message: error.localizedDescription, details: nil))
|
||||
}
|
||||
} ?? result(nil)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.setPlayerVisible(visible)
|
||||
if visible { self?.updatePlayerFrame() }
|
||||
result(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func handleSetVisible(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let args = call.arguments as? [String: Any],
|
||||
let visible = args["visible"] as? Bool else {
|
||||
result(FlutterError(code: "INVALID_ARGS", message: "Missing 'visible' argument", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.setPlayerVisible(visible)
|
||||
if visible { self?.updatePlayerFrame() }
|
||||
result(nil)
|
||||
}
|
||||
func handleUpdateFrame(result: @escaping FlutterResult) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.updatePlayerFrame()
|
||||
result(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func handleUpdateFrame(result: @escaping FlutterResult) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.updatePlayerFrame()
|
||||
result(nil)
|
||||
}
|
||||
func handleSetLogLevel(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let args = call.arguments as? [String: Any],
|
||||
let level = args["level"] as? String
|
||||
else {
|
||||
result(FlutterError(code: "INVALID_ARGS", message: "Missing 'level'", details: nil))
|
||||
return
|
||||
}
|
||||
coreBase?.setLogLevel(level)
|
||||
result(nil)
|
||||
}
|
||||
|
||||
func handleSetLogLevel(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let args = call.arguments as? [String: Any],
|
||||
let level = args["level"] as? String else {
|
||||
result(FlutterError(code: "INVALID_ARGS", message: "Missing 'level'", details: nil))
|
||||
return
|
||||
}
|
||||
coreBase?.setLogLevel(level)
|
||||
result(nil)
|
||||
}
|
||||
// MARK: - MpvPlayerDelegate
|
||||
|
||||
// MARK: - MpvPlayerDelegate
|
||||
func onPropertyChange(name: String, value: Any?) {
|
||||
guard let eventSink = eventSink, let propId = nameToId[name] else { return }
|
||||
eventSink([propId, value as Any])
|
||||
}
|
||||
|
||||
func onPropertyChange(name: String, value: Any?) {
|
||||
guard let eventSink = eventSink, let propId = nameToId[name] else { return }
|
||||
eventSink([propId, value as Any])
|
||||
}
|
||||
|
||||
func onEvent(name: String, data: [String: Any]?) {
|
||||
guard let eventSink = eventSink else { return }
|
||||
var event: [String: Any] = ["type": "event", "name": name]
|
||||
if let data = data { event["data"] = data }
|
||||
eventSink(event)
|
||||
}
|
||||
func onEvent(name: String, data: [String: Any]?) {
|
||||
guard let eventSink = eventSink else { return }
|
||||
var event: [String: Any] = ["type": "event", "name": name]
|
||||
if let data = data { event["data"] = data }
|
||||
eventSink(event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
#ifndef SANITIZE_UTF8_H_
|
||||
#define SANITIZE_UTF8_H_
|
||||
|
||||
#include <simdutf.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <simdutf.h>
|
||||
|
||||
// Sanitize a C string that may contain invalid UTF-8 sequences.
|
||||
// Uses simdutf for SIMD-accelerated validation (fast path for valid strings),
|
||||
|
||||
@@ -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<const BYTE*>(&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<const BYTE*>(&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<BYTE*>(&wp), &size) == ERROR_SUCCESS &&
|
||||
if (RegQueryValueExW(hKey, kWindowPlacementValue, nullptr, nullptr, reinterpret_cast<BYTE*>(&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<flutter::FlutterViewController>(
|
||||
frame.right - frame.left, frame.bottom - frame.top, project_);
|
||||
flutter_controller_ =
|
||||
std::make_unique<flutter::FlutterViewController>(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<LRESULT> result =
|
||||
flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam,
|
||||
lparam);
|
||||
std::optional<LRESULT> 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<flutter::MethodChannel<flutter::EncodableValue>>(
|
||||
messenger, "plezy/window",
|
||||
&flutter::StandardMethodCodec::GetInstance());
|
||||
window_channel_ = std::make_unique<flutter::MethodChannel<flutter::EncodableValue>>(
|
||||
messenger, "plezy/window", &flutter::StandardMethodCodec::GetInstance());
|
||||
|
||||
window_channel_->SetMethodCallHandler(
|
||||
[this](const flutter::MethodCall<flutter::EncodableValue>& call,
|
||||
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>>
|
||||
result) {
|
||||
const std::string& name = call.method_name();
|
||||
if (name == "setFullScreen") {
|
||||
bool value = false;
|
||||
if (const auto* args =
|
||||
std::get_if<flutter::EncodableMap>(call.arguments())) {
|
||||
auto it = args->find(flutter::EncodableValue("isFullScreen"));
|
||||
if (it != args->end()) {
|
||||
if (const bool* b = std::get_if<bool>(&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<flutter::EncodableValue>& call,
|
||||
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result) {
|
||||
const std::string& name = call.method_name();
|
||||
if (name == "setFullScreen") {
|
||||
bool value = false;
|
||||
if (const auto* args = std::get_if<flutter::EncodableMap>(call.arguments())) {
|
||||
auto it = args->find(flutter::EncodableValue("isFullScreen"));
|
||||
if (it != args->end()) {
|
||||
if (const bool* b = std::get_if<bool>(&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<flutter::EncodableValue>(is_fullscreen_));
|
||||
window_channel_->InvokeMethod("onFullScreenChanged", std::make_unique<flutter::EncodableValue>(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_ = {};
|
||||
|
||||
@@ -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::FlutterViewController> flutter_controller_;
|
||||
|
||||
// Method channel exposing window controls to Dart (plezy/window).
|
||||
std::unique_ptr<flutter::MethodChannel<flutter::EncodableValue>>
|
||||
window_channel_;
|
||||
std::unique_ptr<flutter::MethodChannel<flutter::EncodableValue>> window_channel_;
|
||||
|
||||
// Fullscreen state tracking for monitor-aware native fullscreen.
|
||||
// Maximize state lives inside `placement_before_fullscreen_.showCmd`.
|
||||
|
||||
@@ -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<std::string> command_line_arguments =
|
||||
GetCommandLineArguments();
|
||||
std::vector<std::string> 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)) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#include "display_mode_manager.h"
|
||||
|
||||
#include "sdk_26100.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
#include "sdk_26100.h"
|
||||
|
||||
namespace mpv {
|
||||
|
||||
@@ -44,14 +44,12 @@ std::vector<DISPLAYCONFIG_PATH_INFO> 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<DISPLAYCONFIG_PATH_INFO> DisplayModeManager::GetDisplayConfigPaths()
|
||||
return paths;
|
||||
}
|
||||
|
||||
std::optional<DisplayConfigId> DisplayModeManager::GetDisplayTargetId(
|
||||
const std::wstring& gdi_device_name) {
|
||||
std::optional<DisplayConfigId> 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<DisplayConfigId> 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<DisplayMode> 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_TYPE>(
|
||||
DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO_2);
|
||||
info.header.type = static_cast<DISPLAYCONFIG_DEVICE_INFO_TYPE>(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_TYPE>(
|
||||
DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO_2);
|
||||
info.header.type = static_cast<DISPLAYCONFIG_DEVICE_INFO_TYPE>(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_TYPE>(
|
||||
DISPLAYCONFIG_DEVICE_INFO_SET_HDR_STATE);
|
||||
state.header.type = static_cast<DISPLAYCONFIG_DEVICE_INFO_TYPE>(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<const BYTE*>(&value), sizeof(value));
|
||||
LONG result = RegSetValueExW(key, value_name, 0, REG_DWORD, reinterpret_cast<const BYTE*>(&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<const BYTE*>(value.c_str()),
|
||||
static_cast<DWORD>((value.size() + 1) * sizeof(wchar_t)));
|
||||
LONG result = RegSetValueExW(
|
||||
key, value_name, 0, REG_SZ, reinterpret_cast<const BYTE*>(value.c_str()),
|
||||
static_cast<DWORD>((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<BYTE*>(&value), &size);
|
||||
LONG result = RegQueryValueExW(key, value_name, nullptr, &type, reinterpret_cast<BYTE*>(&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<BYTE*>(&value[0]), &size);
|
||||
LONG result = RegQueryValueExW(key, value_name, nullptr, nullptr, reinterpret_cast<BYTE*>(&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<DisplayMode>& modes,
|
||||
DWORD current_width,
|
||||
DWORD current_height) {
|
||||
DWORD DisplayModeManager::FindBestRefreshRate(
|
||||
double video_fps, const std::vector<DisplayMode>& 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;
|
||||
}
|
||||
|
||||
@@ -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<DisplayMode>& modes,
|
||||
DWORD current_width, DWORD current_height);
|
||||
static DWORD FindBestRefreshRate(
|
||||
double video_fps, const std::vector<DisplayMode>& modes, DWORD current_width, DWORD current_height);
|
||||
|
||||
private:
|
||||
// Get the GDI device name for the monitor containing the window.
|
||||
|
||||
@@ -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<LONG_PTR>(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<LONG_PTR>(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> MpvContainer::instance_ =
|
||||
std::make_unique<MpvContainer>();
|
||||
std::unique_ptr<MpvContainer> MpvContainer::instance_ = std::make_unique<MpvContainer>();
|
||||
|
||||
} // namespace mpv
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -9,12 +9,9 @@ namespace mpv {
|
||||
|
||||
MpvCore* MpvCore::GetInstance() { return instance_.get(); }
|
||||
|
||||
void MpvCore::SetInstance(std::unique_ptr<MpvCore> instance) {
|
||||
instance_ = std::move(instance);
|
||||
}
|
||||
void MpvCore::SetInstance(std::unique_ptr<MpvCore> 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<HRESULT> MpvCore::WindowProc(HWND hwnd, UINT message,
|
||||
WPARAM wparam, LPARAM lparam) {
|
||||
std::optional<HRESULT> 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<HRESULT> 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<HRESULT> 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<HRESULT> 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<int32_t>(ceil(device_pixel_ratio_));
|
||||
top -= static_cast<int32_t>(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;
|
||||
|
||||
@@ -39,8 +39,7 @@ class MpvCore {
|
||||
void SetVisible(bool visible);
|
||||
|
||||
// Window procedure handler for Flutter window messages.
|
||||
std::optional<HRESULT> WindowProc(HWND hwnd, UINT message, WPARAM wparam,
|
||||
LPARAM lparam);
|
||||
std::optional<HRESULT> WindowProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam);
|
||||
|
||||
private:
|
||||
RECT GetGlobalRect(int32_t left, int32_t top, int32_t right, int32_t bottom);
|
||||
|
||||
@@ -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<std::string>& args) {
|
||||
mpv_command(mpv_, c_args.data());
|
||||
}
|
||||
|
||||
void MpvPlayer::CommandAsync(const std::vector<std::string>& args,
|
||||
CommandCallback callback) {
|
||||
void MpvPlayer::CommandAsync(const std::vector<std::string>& 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<mpv_event_log_message*>(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<double*>(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<mpv_event_end_file*>(event->data);
|
||||
flutter::EncodableMap data;
|
||||
data[flutter::EncodableValue("reason")] =
|
||||
flutter::EncodableValue(static_cast<int>(end->reason));
|
||||
data[flutter::EncodableValue("reason")] = flutter::EncodableValue(static_cast<int>(end->reason));
|
||||
if (end->reason == MPV_END_FILE_REASON_ERROR) {
|
||||
data[flutter::EncodableValue("error")] =
|
||||
flutter::EncodableValue(static_cast<int>(end->error));
|
||||
data[flutter::EncodableValue("message")] =
|
||||
flutter::EncodableValue(SanitizeUtf8(mpv_error_string(end->error)));
|
||||
data[flutter::EncodableValue("error")] = flutter::EncodableValue(static_cast<int>(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);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#define MPV_PLAYER_H_
|
||||
|
||||
#include <Windows.h>
|
||||
#include <flutter/encodable_value.h>
|
||||
#include <mpv/client.h>
|
||||
|
||||
#include <atomic>
|
||||
@@ -13,16 +14,13 @@
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include <flutter/encodable_value.h>
|
||||
|
||||
namespace mpv {
|
||||
|
||||
// Wrapper for libmpv that handles initialization, commands, properties,
|
||||
// and event dispatching.
|
||||
class MpvPlayer {
|
||||
public:
|
||||
using EventCallback =
|
||||
std::function<void(const flutter::EncodableValue&)>;
|
||||
using EventCallback = std::function<void(const flutter::EncodableValue&)>;
|
||||
|
||||
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);
|
||||
|
||||
@@ -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<flutter::PluginRegistrarWindows>(registrar));
|
||||
flutter::PluginRegistrarManager::GetInstance()->GetRegistrar<flutter::PluginRegistrarWindows>(registrar));
|
||||
}
|
||||
|
||||
namespace mpv {
|
||||
|
||||
void MpvPlayerPlugin::RegisterWithRegistrar(
|
||||
flutter::PluginRegistrarWindows* registrar) {
|
||||
void MpvPlayerPlugin::RegisterWithRegistrar(flutter::PluginRegistrarWindows* registrar) {
|
||||
auto plugin = std::make_unique<MpvPlayerPlugin>(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<flutter::MethodChannel<flutter::EncodableValue>>(
|
||||
registrar->messenger(), "com.plezy/mpv_player",
|
||||
&flutter::StandardMethodCodec::GetInstance());
|
||||
method_channel_ = std::make_unique<flutter::MethodChannel<flutter::EncodableValue>>(
|
||||
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<flutter::EventChannel<flutter::EncodableValue>>(
|
||||
registrar->messenger(), "com.plezy/mpv_player/events",
|
||||
&flutter::StandardMethodCodec::GetInstance());
|
||||
event_channel_ = std::make_unique<flutter::EventChannel<flutter::EncodableValue>>(
|
||||
registrar->messenger(), "com.plezy/mpv_player/events", &flutter::StandardMethodCodec::GetInstance());
|
||||
|
||||
auto handler = std::make_unique<
|
||||
flutter::StreamHandlerFunctions<flutter::EncodableValue>>(
|
||||
[this](const flutter::EncodableValue* arguments,
|
||||
std::unique_ptr<flutter::EventSink<flutter::EncodableValue>>&&
|
||||
events) -> std::unique_ptr<flutter::StreamHandlerError<
|
||||
flutter::EncodableValue>> {
|
||||
auto handler = std::make_unique<flutter::StreamHandlerFunctions<flutter::EncodableValue>>(
|
||||
[this](
|
||||
const flutter::EncodableValue* arguments,
|
||||
std::unique_ptr<flutter::EventSink<flutter::EncodableValue>>&& events)
|
||||
-> std::unique_ptr<flutter::StreamHandlerError<flutter::EncodableValue>> {
|
||||
event_sink_ = std::move(events);
|
||||
return nullptr;
|
||||
},
|
||||
[this](const flutter::EncodableValue* arguments)
|
||||
-> std::unique_ptr<
|
||||
flutter::StreamHandlerError<flutter::EncodableValue>> {
|
||||
-> std::unique_ptr<flutter::StreamHandlerError<flutter::EncodableValue>> {
|
||||
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<flutter::EncodableValue>& method_call,
|
||||
@@ -94,11 +78,10 @@ void MpvPlayerPlugin::HandleMethodCall(
|
||||
|
||||
HWND flutter_window = GetWindow();
|
||||
|
||||
MpvCore::SetInstance(
|
||||
std::make_unique<MpvCore>(flutter_window));
|
||||
MpvCore::SetInstance(std::make_unique<MpvCore>(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<flutter::EncodableMap>(*args);
|
||||
auto it = map.find(flutter::EncodableValue("args"));
|
||||
if (it == map.end() ||
|
||||
!std::holds_alternative<flutter::EncodableList>(it->second)) {
|
||||
if (it == map.end() || !std::holds_alternative<flutter::EncodableList>(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::unique_ptr<flutter::MethodResult<flutter::EncodableValue>>>(std::move(result));
|
||||
auto result_ptr =
|
||||
std::make_shared<std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>>>(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<std::string>(name_it->second)) {
|
||||
if (name_it == map.end() || !std::holds_alternative<std::string>(name_it->second)) {
|
||||
result->Error("INVALID_ARGS", "Missing 'name'");
|
||||
return;
|
||||
}
|
||||
if (value_it == map.end() ||
|
||||
!std::holds_alternative<std::string>(value_it->second)) {
|
||||
if (value_it == map.end() || !std::holds_alternative<std::string>(value_it->second)) {
|
||||
result->Error("INVALID_ARGS", "Missing 'value'");
|
||||
return;
|
||||
}
|
||||
|
||||
player_->SetProperty(std::get<std::string>(name_it->second),
|
||||
std::get<std::string>(value_it->second));
|
||||
player_->SetProperty(std::get<std::string>(name_it->second), std::get<std::string>(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<flutter::EncodableMap>(*args);
|
||||
auto level_it = map.find(flutter::EncodableValue("level"));
|
||||
|
||||
if (level_it == map.end() ||
|
||||
!std::holds_alternative<std::string>(level_it->second)) {
|
||||
if (level_it == map.end() || !std::holds_alternative<std::string>(level_it->second)) {
|
||||
result->Error("INVALID_ARGS", "Missing 'level'");
|
||||
return;
|
||||
}
|
||||
@@ -256,14 +233,12 @@ void MpvPlayerPlugin::HandleMethodCall(
|
||||
const auto& map = std::get<flutter::EncodableMap>(*args);
|
||||
auto name_it = map.find(flutter::EncodableValue("name"));
|
||||
|
||||
if (name_it == map.end() ||
|
||||
!std::holds_alternative<std::string>(name_it->second)) {
|
||||
if (name_it == map.end() || !std::holds_alternative<std::string>(name_it->second)) {
|
||||
result->Error("INVALID_ARGS", "Missing 'name'");
|
||||
return;
|
||||
}
|
||||
|
||||
std::string value =
|
||||
player_->GetProperty(std::get<std::string>(name_it->second));
|
||||
std::string value = player_->GetProperty(std::get<std::string>(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<std::string>(name_it->second)) {
|
||||
if (name_it == map.end() || !std::holds_alternative<std::string>(name_it->second)) {
|
||||
result->Error("INVALID_ARGS", "Missing 'name'");
|
||||
return;
|
||||
}
|
||||
if (format_it == map.end() ||
|
||||
!std::holds_alternative<std::string>(format_it->second)) {
|
||||
if (format_it == map.end() || !std::holds_alternative<std::string>(format_it->second)) {
|
||||
result->Error("INVALID_ARGS", "Missing 'format'");
|
||||
return;
|
||||
}
|
||||
if (id_it == map.end() ||
|
||||
!std::holds_alternative<int32_t>(id_it->second)) {
|
||||
if (id_it == map.end() || !std::holds_alternative<int32_t>(id_it->second)) {
|
||||
result->Error("INVALID_ARGS", "Missing 'id'");
|
||||
return;
|
||||
}
|
||||
|
||||
player_->ObserveProperty(std::get<std::string>(name_it->second),
|
||||
std::get<std::string>(format_it->second),
|
||||
std::get<int32_t>(id_it->second));
|
||||
player_->ObserveProperty(
|
||||
std::get<std::string>(name_it->second), std::get<std::string>(format_it->second),
|
||||
std::get<int32_t>(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<flutter::EncodableMap>(*args);
|
||||
auto visible_it = map.find(flutter::EncodableValue("visible"));
|
||||
|
||||
if (visible_it == map.end() ||
|
||||
!std::holds_alternative<bool>(visible_it->second)) {
|
||||
if (visible_it == map.end() || !std::holds_alternative<bool>(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<flutter::EncodableMap>(*args);
|
||||
auto get_int = [&map](const char* key) -> int {
|
||||
auto it = map.find(flutter::EncodableValue(key));
|
||||
if (it != map.end() && std::holds_alternative<int32_t>(it->second))
|
||||
return std::get<int32_t>(it->second);
|
||||
if (it != map.end() && std::holds_alternative<int32_t>(it->second)) return std::get<int32_t>(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();
|
||||
|
||||
@@ -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<flutter::MethodChannel<flutter::EncodableValue>>
|
||||
method_channel_;
|
||||
std::unique_ptr<flutter::EventChannel<flutter::EncodableValue>>
|
||||
event_channel_;
|
||||
std::unique_ptr<flutter::MethodChannel<flutter::EncodableValue>> method_channel_;
|
||||
std::unique_ptr<flutter::EventChannel<flutter::EncodableValue>> event_channel_;
|
||||
std::unique_ptr<flutter::EventSink<flutter::EncodableValue>> event_sink_;
|
||||
|
||||
std::unique_ptr<MpvPlayer> player_;
|
||||
|
||||
@@ -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>(accent_state), 2,
|
||||
static_cast<DWORD>(gradient_color), 0};
|
||||
ACCENT_POLICY accent = {static_cast<ACCENT_STATE>(accent_state), 2, static_cast<DWORD>(gradient_color), 0};
|
||||
WINDOWCOMPOSITIONATTRIBDATA data;
|
||||
data.Attrib = WCA_ACCENT_POLICY;
|
||||
data.pvData = &accent;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
|
||||
#include <Windows.h>
|
||||
#include <dwmapi.h>
|
||||
|
||||
#include <optional>
|
||||
#include <memory>
|
||||
#include <io.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <functional>
|
||||
#include <iostream>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <functional>
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
#include <map>
|
||||
|
||||
#endif
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<int>(source * scale_factor);
|
||||
}
|
||||
int Scale(int source, double scale_factor) { return static_cast<int>(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<EnableNonClientDpiScaling*>(
|
||||
GetProcAddress(user32_module, "EnableNonClientDpiScaling"));
|
||||
reinterpret_cast<EnableNonClientDpiScaling*>(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<LONG>(origin.x),
|
||||
static_cast<LONG>(origin.y)};
|
||||
const POINT target_point = {static_cast<LONG>(origin.x), static_cast<LONG>(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<CREATESTRUCT*>(lparam);
|
||||
SetWindowLongPtr(window, GWLP_USERDATA,
|
||||
reinterpret_cast<LONG_PTR>(window_struct->lpCreateParams));
|
||||
SetWindowLongPtr(window, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(window_struct->lpCreateParams));
|
||||
|
||||
auto that = static_cast<Win32Window*>(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<Win32Window*>(
|
||||
GetWindowLongPtr(window, GWLP_USERDATA));
|
||||
return reinterpret_cast<Win32Window*>(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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user