chore: add native formatting checks

This commit is contained in:
edde746
2026-05-01 05:56:49 +02:00
parent 9bd5732f2b
commit 024af35bf5
69 changed files with 8848 additions and 8633 deletions
+129 -130
View File
@@ -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))) }
}
+82 -85
View File
@@ -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()
}
}
@@ -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
}
@@ -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
@@ -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)
}
@@ -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()
}
}