diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 64283fe6..ae3e3fd8 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -194,6 +194,8 @@ dependencies {
// FrameExtractor: seek-bar frames for HLS (preview/HlsFramePreview.kt).
implementation("androidx.media3:media3-inspector:$media3")
implementation("androidx.media3:media3-datasource-okhttp:$media3")
+ // MediaSessionCompat for the player: headset and Bluetooth buttons.
+ implementation("androidx.media:media:1.7.0")
// CloudStream provider runtime (Android-only feature). The `library` module
// carries MainAPI/APIHolder/`app` HTTP client/extractors/BasePlugin so .cs3
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 8cb51c74..04266d17 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -67,7 +67,8 @@
android:hardwareAccelerated="true"
android:supportsPictureInPicture="true"
android:resizeableActivity="true"
- android:windowSoftInputMode="adjustResize">
+ android:windowSoftInputMode="adjustResize"
+ android:enableOnBackInvokedCallback="true">
+
diff --git a/android/app/src/main/kotlin/com/soplay/sozo/AudioFocusHelper.kt b/android/app/src/main/kotlin/com/soplay/sozo/AudioFocusHelper.kt
new file mode 100644
index 00000000..46fba4cc
--- /dev/null
+++ b/android/app/src/main/kotlin/com/soplay/sozo/AudioFocusHelper.kt
@@ -0,0 +1,96 @@
+package com.soplay.sozo
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.media.AudioAttributes
+import android.media.AudioFocusRequest
+import android.media.AudioManager
+import android.os.Build
+
+/**
+ * Audio focus and ACTION_AUDIO_BECOMING_NOISY for the players that do not
+ * handle them themselves (libmpv). Events go to [onEvent] as the wire names
+ * the Dart AudioFocusPolicy reads: gain, loss, loss_transient, duck, noisy.
+ */
+class AudioFocusHelper(
+ private val context: Context,
+ private val onEvent: (String) -> Unit,
+) {
+ private val audio = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
+ private var hasFocus = false
+ private var request: AudioFocusRequest? = null
+ private var noisyReceiver: BroadcastReceiver? = null
+
+ private val listener = AudioManager.OnAudioFocusChangeListener { change ->
+ when (change) {
+ AudioManager.AUDIOFOCUS_GAIN -> {
+ hasFocus = true
+ onEvent("gain")
+ }
+ AudioManager.AUDIOFOCUS_LOSS -> {
+ hasFocus = false
+ onEvent("loss")
+ }
+ AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> onEvent("loss_transient")
+ AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> onEvent("duck")
+ }
+ }
+
+ fun request(): Boolean {
+ if (hasFocus) return true
+ val granted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val req = request ?: AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
+ .setAudioAttributes(
+ AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_MEDIA)
+ .setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
+ .build()
+ )
+ .setOnAudioFocusChangeListener(listener)
+ .build()
+ .also { request = it }
+ audio.requestAudioFocus(req)
+ } else {
+ @Suppress("DEPRECATION")
+ audio.requestAudioFocus(listener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN)
+ }
+ hasFocus = granted == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
+ return hasFocus
+ }
+
+ fun abandon() {
+ if (!hasFocus) return
+ hasFocus = false
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ request?.let { audio.abandonAudioFocusRequest(it) }
+ } else {
+ @Suppress("DEPRECATION")
+ audio.abandonAudioFocus(listener)
+ }
+ }
+
+ fun watchNoisy() {
+ if (noisyReceiver != null) return
+ val receiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) onEvent("noisy")
+ }
+ }
+ val filter = IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
+ } else {
+ @Suppress("UnspecifiedRegisterReceiverFlag")
+ context.registerReceiver(receiver, filter)
+ }
+ noisyReceiver = receiver
+ }
+
+ fun release() {
+ abandon()
+ noisyReceiver?.let { runCatching { context.unregisterReceiver(it) } }
+ noisyReceiver = null
+ }
+}
diff --git a/android/app/src/main/kotlin/com/soplay/sozo/MainActivity.kt b/android/app/src/main/kotlin/com/soplay/sozo/MainActivity.kt
index a3161af5..e65b338f 100644
--- a/android/app/src/main/kotlin/com/soplay/sozo/MainActivity.kt
+++ b/android/app/src/main/kotlin/com/soplay/sozo/MainActivity.kt
@@ -11,12 +11,15 @@ import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.content.res.Configuration
+import android.graphics.Rect
import android.net.Uri
import android.media.AudioManager
import android.graphics.drawable.Icon
import android.os.Build
import android.os.Bundle
import android.provider.Settings
+import android.util.Rational
+import androidx.lifecycle.Lifecycle
import androidx.annotation.RequiresApi
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
@@ -66,6 +69,20 @@ class MainActivity : FlutterFragmentActivity() {
private var glassPower: GlassPower? = null
private var deeplinkSettingsChannel: MethodChannel? = null
private var pipReceiver: BroadcastReceiver? = null
+
+ private val playerSystemChannelName = "soplay/player_system"
+ private var playerSystemChannel: MethodChannel? = null
+ private var audioFocus: AudioFocusHelper? = null
+ private var mediaSession: PlayerMediaSession? = null
+
+ // Auto-enter PiP on Home while playing. On 12+ the system does it from
+ // the params; before that, onUserLeaveHint does.
+ private var autoPip = false
+ private var pipAspect = Rational(16, 9)
+ private var pipSourceRect: Rect? = null
+ private var pipActions: List = emptyList()
+ private var pipLabels: Map = emptyMap()
+ private var pipExitPending = false
private var notificationPermissionResult: MethodChannel.Result? = null
// CloudStream plugin host (Android-only feature). Lazy so the runtime only
@@ -224,11 +241,24 @@ class MainActivity : FlutterFragmentActivity() {
val isPlaying = call.argument("isPlaying") ?: true
val hasPrev = call.argument("hasPrev") ?: false
val hasNext = call.argument("hasNext") ?: false
+ val seekSeconds = call.argument("seekSeconds")?.toInt() ?: 10
+ call.argument