refactor: extract shared Android utilities from player cores

This commit is contained in:
edde746
2026-03-01 10:40:59 +01:00
parent 796db94896
commit 3a5cab956d
7 changed files with 458 additions and 707 deletions
@@ -21,6 +21,7 @@ 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 java.io.File
@@ -37,17 +38,7 @@ class MainActivity : FlutterActivity() {
// 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)
if (savedTheme != null) {
val color = when (savedTheme) {
"oled" -> android.graphics.Color.BLACK
"dark" -> android.graphics.Color.parseColor("#0E0F12")
"light" -> android.graphics.Color.parseColor("#F7F7F8")
else -> null
}
if (color != null) {
window.decorView.setBackgroundColor(color)
}
}
ThemeHelper.themeColor(savedTheme)?.let { window.decorView.setBackgroundColor(it) }
super.onCreate(savedInstanceState)
@@ -187,15 +178,7 @@ class MainActivity : FlutterActivity() {
// Persist for next cold start & update window background now
getSharedPreferences("plezy_prefs", Context.MODE_PRIVATE)
.edit().putString("splash_theme", mode).apply()
val color = when (mode) {
"oled" -> android.graphics.Color.BLACK
"dark" -> android.graphics.Color.parseColor("#0E0F12")
"light" -> android.graphics.Color.parseColor("#F7F7F8")
else -> null
}
if (color != null) {
window.decorView.setBackgroundColor(color)
}
ThemeHelper.themeColor(mode)?.let { window.decorView.setBackgroundColor(it) }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val themeId = when (mode) {
@@ -5,26 +5,19 @@ import android.app.ActivityManager
import android.content.Context
import android.graphics.Color
import android.graphics.PixelFormat
import android.hardware.display.DisplayManager
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Gravity
import android.view.Surface
import android.view.SurfaceView
import android.view.TextureView
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.view.WindowManager
import android.widget.FrameLayout
import androidx.annotation.OptIn
import androidx.annotation.RequiresApi
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
@@ -48,14 +41,15 @@ import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.extractor.mkv.MatroskaExtractor
import androidx.media3.ui.CaptionStyleCompat
import androidx.media3.ui.SubtitleView
import com.edde746.plezy.shared.AudioFocusManager
import com.edde746.plezy.shared.FlutterOverlayHelper
import com.edde746.plezy.shared.FrameRateManager
import io.github.peerless2012.ass.media.AssHandler
import io.github.peerless2012.ass.media.extractor.AssMatroskaExtractor
import io.github.peerless2012.ass.media.factory.AssRenderersFactory
import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory
import io.github.peerless2012.ass.media.type.AssRenderType
import io.github.peerless2012.ass.media.widget.AssSubtitleView
import java.math.BigDecimal
import java.math.RoundingMode
interface ExoPlayerDelegate {
fun onPropertyChange(name: String, value: Any?)
@@ -79,7 +73,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
companion object {
private const val TAG = "ExoPlayerCore"
private const val SHORT_VIDEO_LENGTH_MS = 300000L // 5 minutes
private const val WATCHDOG_CHECK_INTERVAL_MS = 1000L
private const val WATCHDOG_TIMEOUT_MS = 8000L
}
@@ -105,15 +99,11 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private set
// Frame rate matching
private var currentVideoFps: Float = 0f
private var displayListener: DisplayManager.DisplayListener? = null
private var frameRateManager: FrameRateManager? = null
private val handler = Handler(Looper.getMainLooper())
// Audio focus
private var audioManager: AudioManager? = null
private var audioFocusRequest: AudioFocusRequest? = null
private var hasAudioFocus: Boolean = false
private var wasPlayingBeforeFocusLoss: Boolean = false
private var audioFocusManager: AudioFocusManager? = null
// Track state for event emission
private var lastPosition: Long = 0
@@ -126,39 +116,6 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private var currentMediaUri: String? = null
private var currentHeaders: Map<String, String>? = null
private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
when (focusChange) {
AudioManager.AUDIOFOCUS_GAIN -> {
emitLog("debug", "audio", "Focus gained")
hasAudioFocus = true
if (wasPlayingBeforeFocusLoss && isInitialized) {
exoPlayer?.play()
wasPlayingBeforeFocusLoss = false
}
}
AudioManager.AUDIOFOCUS_LOSS -> {
emitLog("debug", "audio", "Focus lost permanently")
hasAudioFocus = false
if (isInitialized) {
wasPlayingBeforeFocusLoss = exoPlayer?.isPlaying == true
exoPlayer?.pause()
}
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
emitLog("debug", "audio", "Focus lost transiently")
hasAudioFocus = false
if (isInitialized) {
wasPlayingBeforeFocusLoss = exoPlayer?.isPlaying == true
exoPlayer?.pause()
}
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
emitLog("debug", "audio", "Focus lost transiently (can duck)")
// Don't pause — let the system handle volume ducking for notifications
}
}
}
private fun emitLog(level: String, prefix: String, message: String) {
when (level) {
"error" -> Log.e(TAG, "[$prefix] $message")
@@ -197,44 +154,9 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
contentView.post {
if (!isInitialized) return@post
var flutterContainer: ViewGroup? = null
for (i in 0 until contentView.childCount) {
val child = contentView.getChildAt(i)
if (child is ViewGroup && child.javaClass.name.contains("FlutterView")) {
flutterContainer = child
break
}
}
if (flutterContainer == null) {
for (i in contentView.childCount - 1 downTo 0) {
val child = contentView.getChildAt(i)
if (child is ViewGroup && child != surfaceContainer && child.childCount > 0) {
flutterContainer = child
break
}
}
}
flutterContainer?.let { container ->
contentView.bringChildToFront(container)
for (j in 0 until container.childCount) {
val flutterChild = container.getChildAt(j)
if (flutterChild is SurfaceView) {
// Don't use setZOrderOnTop - let Flutter render in normal view order
// This allows SubtitleView (added via addContentView) to render above
flutterChild.setZOrderOnTop(false)
flutterChild.setZOrderMediaOverlay(true)
flutterChild.holder.setFormat(PixelFormat.TRANSLUCENT)
break
} else if (flutterChild is TextureView) {
flutterChild.isOpaque = false
break
}
}
}
val container = FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer)
?: return@post
FlutterOverlayHelper.configureFlutterZOrder(contentView, container, zOrderOnTop = false)
}
}
@@ -263,7 +185,25 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
tunnelingUserEnabled = tunnelingEnabled
try {
audioManager = activity.getSystemService(Context.AUDIO_SERVICE) as AudioManager
audioFocusManager = AudioFocusManager(
context = activity,
handler = handler,
onPause = { if (isInitialized) exoPlayer?.pause() },
onResume = { if (isInitialized) exoPlayer?.play() },
isPaused = { exoPlayer?.isPlaying != true },
log = { emitLog("debug", "audio", it) }
)
frameRateManager = FrameRateManager(
activity = activity,
handler = handler,
onDisplayChanged = {
if (exoPlayer?.isPlaying == false && audioFocusManager?.wasPlayingBeforeFocusLoss == true) {
Log.d(TAG, "Display changed, resuming playback")
exoPlayer?.play()
}
},
log = { emitLog("info", "framerate", it) }
)
// Create FrameLayout container for video (enables centering for aspect ratio)
surfaceContainer = FrameLayout(activity).apply {
@@ -307,25 +247,9 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
contentView.addView(surfaceContainer, 0)
// Find FlutterView and configure z-order
// Video SurfaceView is at the bottom (setZOrderOnTop=false, setZOrderMediaOverlay=false)
// Flutter SurfaceView uses setZOrderMediaOverlay to render above video and subtitles
for (i in 0 until contentView.childCount) {
val child = contentView.getChildAt(i)
if (child is ViewGroup && child.javaClass.name.contains("FlutterView")) {
contentView.bringChildToFront(child)
for (j in 0 until child.childCount) {
val flutterChild = child.getChildAt(j)
if (flutterChild is SurfaceView) {
// Use setZOrderMediaOverlay instead of setZOrderOnTop
// This puts Flutter above video but below normal views
flutterChild.setZOrderOnTop(false)
flutterChild.setZOrderMediaOverlay(true)
flutterChild.holder.setFormat(PixelFormat.TRANSLUCENT)
break
}
}
break
}
// Video SurfaceView is at the bottom, Flutter uses setZOrderMediaOverlay to render above
FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer)?.let { container ->
FlutterOverlayHelper.configureFlutterZOrder(contentView, container, zOrderOnTop = false)
}
ensureFlutterOverlayOnTop()
@@ -1222,212 +1146,18 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
// Audio Focus
fun requestAudioFocus(): Boolean {
val am = audioManager ?: return false
fun requestAudioFocus(): Boolean = audioFocusManager?.requestAudioFocus() ?: false
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
am.requestAudioFocus(focusRequest)
} else {
@Suppress("DEPRECATION")
am.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() {
val am = audioManager ?: return
Log.d(TAG, "Abandoning audio focus")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest?.let { am.abandonAudioFocusRequest(it) }
audioFocusRequest = null
} else {
@Suppress("DEPRECATION")
am.abandonAudioFocus(audioFocusChangeListener)
}
hasAudioFocus = false
wasPlayingBeforeFocusLoss = false
}
fun abandonAudioFocus() { audioFocusManager?.abandonAudioFocus() }
// Frame Rate Matching
private fun getDisplayManager(): DisplayManager {
return activity.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
}
fun setVideoFrameRate(fps: Float, videoDurationMs: Long) {
currentVideoFps = fps
if (fps <= 0f) {
Log.d(TAG, "setVideoFrameRate: Invalid fps ($fps), skipping")
return
}
val surface = surfaceView?.holder?.surface
if (surface == null) {
Log.d(TAG, "setVideoFrameRate: Surface not available")
return
}
emitLog("info", "framerate", "fps=$fps, duration=${videoDurationMs}ms, API=${Build.VERSION.SDK_INT}")
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> setFrameRateS(fps, surface, videoDurationMs)
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> setFrameRateR(fps, surface)
Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> setFrameRateM(fps)
}
frameRateManager?.setVideoFrameRate(fps, videoDurationMs, surfaceView?.holder?.surface)
}
fun clearVideoFrameRate() {
Log.d(TAG, "clearVideoFrameRate")
currentVideoFps = 0f
displayListener?.let {
getDisplayManager().unregisterDisplayListener(it)
displayListener = null
}
}
private fun registerDisplayListener() {
displayListener?.let {
getDisplayManager().unregisterDisplayListener(it)
}
displayListener = object : DisplayManager.DisplayListener {
override fun onDisplayAdded(displayId: Int) = Unit
override fun onDisplayRemoved(displayId: Int) = Unit
override fun onDisplayChanged(displayId: Int) {
handler.postDelayed({
if (exoPlayer?.isPlaying == false && wasPlayingBeforeFocusLoss) {
Log.d(TAG, "Display changed, resuming playback")
exoPlayer?.play()
}
}, 2000L)
getDisplayManager().unregisterDisplayListener(this)
displayListener = null
}
}
getDisplayManager().registerDisplayListener(displayListener, handler)
}
@RequiresApi(Build.VERSION_CODES.R)
private fun setFrameRateR(fps: Float, surface: Surface) {
Log.d(TAG, "setFrameRateR: Setting frame rate to $fps")
surface.setFrameRate(fps, Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE)
registerDisplayListener()
}
@RequiresApi(Build.VERSION_CODES.S)
private fun setFrameRateS(fps: Float, surface: Surface, videoDurationMs: Long) {
Log.d(TAG, "setFrameRateS: fps=$fps, duration=${videoDurationMs}ms")
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
)
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) {
emitLog("info", "framerate", "Seamless switch available for ${fps}fps")
surface.setFrameRate(
fps,
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
Surface.CHANGE_FRAME_RATE_ALWAYS
)
registerDisplayListener()
} 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()
} 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
)
}
}
}
@RequiresApi(Build.VERSION_CODES.M)
private fun setFrameRateM(fps: Float) {
Log.d(TAG, "setFrameRateM: fps=$fps")
val wm = activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager
@Suppress("DEPRECATION")
val display = wm.defaultDisplay ?: return
display.supportedModes?.let { supportedModes ->
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 == 0f) {
modeToUse = mode
break
}
}
if (modeToUse != currentMode) {
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()
}
}
frameRateManager?.clearVideoFrameRate()
}
// Stats
@@ -1506,9 +1236,10 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
stopFrameWatchdog()
stopPositionUpdates()
clearVideoFrameRate()
abandonAudioFocus()
audioManager = null
frameRateManager?.clearVideoFrameRate()
frameRateManager = null
audioFocusManager?.release()
audioFocusManager = null
tunnelingDisabledForCodec = false
pendingStartPositionMs = 0L
@@ -3,11 +3,6 @@ package com.edde746.plezy.mpv
import android.app.Activity
import android.content.Context
import android.graphics.Color
import android.graphics.PixelFormat
import android.hardware.display.DisplayManager
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import android.os.Handler
import android.os.Looper
@@ -18,13 +13,11 @@ import android.view.SurfaceView
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.view.TextureView
import android.view.WindowManager
import androidx.annotation.RequiresApi
import com.edde746.plezy.shared.AudioFocusManager
import com.edde746.plezy.shared.FlutterOverlayHelper
import com.edde746.plezy.shared.FrameRateManager
import dev.jdtech.mpv.MPVLib
import io.flutter.plugin.common.MethodChannel
import java.math.BigDecimal
import java.math.RoundingMode
import java.util.concurrent.Executors
interface MpvPlayerDelegate {
@@ -39,7 +32,6 @@ class MpvPlayerCore(private val activity: Activity) :
companion object {
private const val TAG = "MpvPlayerCore"
private const val SHORT_VIDEO_LENGTH_MS = 300000L // 5 minutes
// Guards MPVLib.create/destroy which share global native state
private val mpvLock = Object()
@@ -61,69 +53,11 @@ class MpvPlayerCore(private val activity: Activity) :
private val commandExecutor = Executors.newSingleThreadExecutor()
// Frame rate matching
private var currentVideoFps: Float = 0f
private var displayListener: DisplayManager.DisplayListener? = null
private var frameRateManager: FrameRateManager? = null
private val handler = Handler(Looper.getMainLooper())
// Audio focus
private var audioManager: AudioManager? = null
private var audioFocusRequest: AudioFocusRequest? = null
private var hasAudioFocus: Boolean = false
private var wasPlayingBeforeFocusLoss: Boolean = false
private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
when (focusChange) {
AudioManager.AUDIOFOCUS_GAIN -> {
Log.d(TAG, "Audio focus gained")
hasAudioFocus = true
// Resume playback if we were playing before focus loss
if (wasPlayingBeforeFocusLoss && isInitialized) {
try {
MPVLib.setPropertyBoolean("pause", false)
wasPlayingBeforeFocusLoss = false
} catch (e: Exception) {
Log.w(TAG, "Failed to resume playback after focus gain", e)
}
}
}
AudioManager.AUDIOFOCUS_LOSS -> {
Log.d(TAG, "Audio focus lost permanently")
hasAudioFocus = false
// Pause playback on permanent focus loss
if (isInitialized) {
try {
val isPaused = MPVLib.getPropertyBoolean("pause")
wasPlayingBeforeFocusLoss = !isPaused
if (!isPaused) {
MPVLib.setPropertyBoolean("pause", true)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to pause on focus loss", e)
}
}
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
Log.d(TAG, "Audio focus lost transiently")
hasAudioFocus = false
// Pause playback, remember to resume
if (isInitialized) {
try {
val isPaused = MPVLib.getPropertyBoolean("pause")
wasPlayingBeforeFocusLoss = !isPaused
if (!isPaused) {
MPVLib.setPropertyBoolean("pause", true)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to pause on transient focus loss", e)
}
}
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
Log.d(TAG, "Audio focus lost transiently (can duck), continuing playback")
// Don't pause — let the system handle volume ducking for notifications
}
}
}
private var audioFocusManager: AudioFocusManager? = null
private var flutterOverlayApplied = false
@@ -132,51 +66,14 @@ class MpvPlayerCore(private val activity: Activity) :
val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
contentView.post {
if (!isInitialized) return@post
var flutterContainer: ViewGroup? = null
// First pass: look for FlutterView by name (debug builds)
for (i in 0 until contentView.childCount) {
val child = contentView.getChildAt(i)
if (child is ViewGroup && child.javaClass.name.contains("FlutterView")) {
flutterContainer = child
break
}
}
// Fallback for release (FlutterView may be obfuscated): pick the last ViewGroup
// that is not our mpv container and has children.
if (flutterContainer == null) {
for (i in contentView.childCount - 1 downTo 0) {
val child = contentView.getChildAt(i)
if (child is ViewGroup && child != surfaceContainer && child.childCount > 0) {
flutterContainer = child
break
}
}
}
flutterContainer?.let { container ->
// Skip if Flutter container is already the topmost child
if (contentView.getChildAt(contentView.childCount - 1) == container) {
flutterOverlayApplied = true
return@post
}
contentView.bringChildToFront(container)
for (j in 0 until container.childCount) {
val flutterChild = container.getChildAt(j)
if (flutterChild is SurfaceView) {
flutterChild.setZOrderOnTop(true)
flutterChild.setZOrderMediaOverlay(true)
flutterChild.holder.setFormat(PixelFormat.TRANSLUCENT)
break
} else if (flutterChild is TextureView) {
// TextureView uses alpha composition; ensure it stays above.
flutterChild.isOpaque = false
break
}
}
val container = FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer)
?: return@post
if (contentView.getChildAt(contentView.childCount - 1) == container) {
flutterOverlayApplied = true
return@post
}
FlutterOverlayHelper.configureFlutterZOrder(contentView, container, zOrderOnTop = true)
flutterOverlayApplied = true
}
}
@@ -191,8 +88,41 @@ class MpvPlayerCore(private val activity: Activity) :
disposing = false
pendingSurface = null
// Initialize AudioManager for audio focus handling
audioManager = activity.getSystemService(Context.AUDIO_SERVICE) as AudioManager
// Initialize audio focus handling
audioFocusManager = AudioFocusManager(
context = activity,
handler = handler,
onPause = {
if (isInitialized) {
try { MPVLib.setPropertyBoolean("pause", true) }
catch (e: Exception) { Log.w(TAG, "Failed to pause on focus loss", e) }
}
},
onResume = {
if (isInitialized) {
try { MPVLib.setPropertyBoolean("pause", false) }
catch (e: Exception) { Log.w(TAG, "Failed to resume after focus gain", e) }
}
},
isPaused = {
try { MPVLib.getPropertyBoolean("pause") }
catch (e: Exception) { true }
}
)
frameRateManager = FrameRateManager(
activity = activity,
handler = handler,
onDisplayChanged = {
try {
if (MPVLib.getPropertyBoolean("pause")) {
Log.d(TAG, "Display changed, resuming playback")
MPVLib.setPropertyBoolean("pause", false)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to resume after display change", e)
}
}
)
// Create FrameLayout container for video (matches ExoPlayer pattern)
// Setting visibility on container instead of SurfaceView directly allows
@@ -225,24 +155,10 @@ class MpvPlayerCore(private val activity: Activity) :
val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
contentView.addView(surfaceContainer, 0)
// Find FlutterView and its internal FlutterSurfaceView, set it on top
for (i in 0 until contentView.childCount) {
val child = contentView.getChildAt(i)
if (child is ViewGroup && child.javaClass.name.contains("FlutterView")) {
contentView.bringChildToFront(child)
// Look inside FlutterView for FlutterSurfaceView
for (j in 0 until child.childCount) {
val flutterChild = child.getChildAt(j)
if (flutterChild is SurfaceView) {
// Put Flutter in media overlay layer (above our video which is in normal layer)
flutterChild.setZOrderOnTop(true)
flutterChild.setZOrderMediaOverlay(true)
flutterChild.holder.setFormat(PixelFormat.TRANSLUCENT)
break
}
}
break
}
// Find FlutterView and set it on top of our video surface
FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer)?.let { container ->
FlutterOverlayHelper.configureFlutterZOrder(contentView, container, zOrderOnTop = true)
flutterOverlayApplied = true
}
// Repeat after layout settles to catch late-added Flutter surfaces (release builds)
ensureFlutterOverlayOnTop()
@@ -331,65 +247,9 @@ class MpvPlayerCore(private val activity: Activity) :
// Audio Focus
/**
* Request audio focus before starting playback.
* This will cause other media apps to pause.
* @return true if audio focus was granted
*/
fun requestAudioFocus(): Boolean {
val am = audioManager ?: return false
fun requestAudioFocus(): Boolean = audioFocusManager?.requestAudioFocus() ?: false
Log.d(TAG, "Requesting audio focus")
val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Android 8.0+ uses AudioFocusRequest
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
am.requestAudioFocus(focusRequest)
} else {
// Legacy API for older Android versions
@Suppress("DEPRECATION")
am.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
}
/**
* Abandon audio focus when playback stops.
* This allows other apps to resume their audio.
*/
fun abandonAudioFocus() {
val am = audioManager ?: return
Log.d(TAG, "Abandoning audio focus")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest?.let { am.abandonAudioFocusRequest(it) }
audioFocusRequest = null
} else {
@Suppress("DEPRECATION")
am.abandonAudioFocus(audioFocusChangeListener)
}
hasAudioFocus = false
wasPlayingBeforeFocusLoss = false
}
fun abandonAudioFocus() { audioFocusManager?.abandonAudioFocus() }
// SurfaceHolder.Callback
@@ -601,191 +461,12 @@ class MpvPlayerCore(private val activity: Activity) :
// Frame Rate Matching
private fun getDisplayManager(): DisplayManager {
return activity.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
}
/**
* Set the video frame rate for display refresh rate matching.
* Based on VLC Android's FrameRateManager implementation.
*/
fun setVideoFrameRate(fps: Float, videoDurationMs: Long) {
currentVideoFps = fps
if (fps <= 0f) {
Log.d(TAG, "setVideoFrameRate: Invalid fps ($fps), skipping")
return
}
val surface = surfaceView?.holder?.surface
if (surface == null) {
Log.d(TAG, "setVideoFrameRate: Surface not available")
return
}
Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=${videoDurationMs}ms, API=${Build.VERSION.SDK_INT}")
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> setFrameRateS(fps, surface, videoDurationMs)
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> setFrameRateR(fps, surface)
Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> setFrameRateM(fps)
}
frameRateManager?.setVideoFrameRate(fps, videoDurationMs, surfaceView?.holder?.surface)
}
/**
* Clear frame rate setting and cleanup display listener.
*/
fun clearVideoFrameRate() {
Log.d(TAG, "clearVideoFrameRate")
currentVideoFps = 0f
displayListener?.let {
getDisplayManager().unregisterDisplayListener(it)
displayListener = null
}
}
/**
* Create and register display listener for mode switch completion.
* Resumes playback after display mode change (needed for HDMI/projectors).
*/
private fun registerDisplayListener() {
displayListener?.let {
getDisplayManager().unregisterDisplayListener(it)
}
displayListener = object : DisplayManager.DisplayListener {
override fun onDisplayAdded(displayId: Int) = Unit
override fun onDisplayRemoved(displayId: Int) = Unit
override fun onDisplayChanged(displayId: Int) {
// Mode switch may pause playback (HDMI), wait and resume
handler.postDelayed({
try {
val isPaused = MPVLib.getPropertyBoolean("pause")
if (isPaused) {
Log.d(TAG, "Display changed, resuming playback")
MPVLib.setPropertyBoolean("pause", false)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to resume playback after display change", e)
}
}, 2000L) // Wait 2 seconds for mode switch to complete
getDisplayManager().unregisterDisplayListener(this)
displayListener = null
}
}
getDisplayManager().registerDisplayListener(displayListener, handler)
}
@RequiresApi(Build.VERSION_CODES.R)
private fun setFrameRateR(fps: Float, surface: Surface) {
Log.d(TAG, "setFrameRateR: Setting frame rate to $fps")
surface.setFrameRate(fps, Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE)
registerDisplayListener()
}
@RequiresApi(Build.VERSION_CODES.S)
private fun setFrameRateS(fps: Float, surface: Surface, videoDurationMs: Long) {
Log.d(TAG, "setFrameRateS: fps=$fps, duration=${videoDurationMs}ms")
// For short videos (<5min), only switch if seamless
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
)
return
}
// For longer videos, check if switch will be seamless
var seamless = false
activity.display?.mode?.alternativeRefreshRates?.let { refreshRates ->
for (rate in refreshRates) {
// Check if rates match or are integer multiples
if (fps.toString().startsWith(rate.toString()) ||
rate.toString().startsWith(fps.toString()) ||
rate % fps == 0f) {
seamless = true
break
}
}
}
if (seamless) {
Log.d(TAG, "Seamless switch available, using CHANGE_FRAME_RATE_ALWAYS")
surface.setFrameRate(
fps,
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
Surface.CHANGE_FRAME_RATE_ALWAYS
)
registerDisplayListener()
} else {
// Non-seamless: only switch if user enabled it at OS level
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()
} else {
Log.d(TAG, "Non-seamless switch not allowed by user preference, using seamless-only")
surface.setFrameRate(
fps,
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS
)
}
}
}
@RequiresApi(Build.VERSION_CODES.M)
private fun setFrameRateM(fps: Float) {
Log.d(TAG, "setFrameRateM: fps=$fps")
val wm = activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager
val display = wm.defaultDisplay ?: return
display.supportedModes?.let { supportedModes ->
val currentMode = display.mode
var modeToUse = currentMode
for (mode in supportedModes) {
// Skip modes with different resolution
if (mode.physicalHeight != currentMode.physicalHeight ||
mode.physicalWidth != currentMode.physicalWidth) {
continue
}
Log.d(TAG, "Supported mode: ${mode.modeId} - ${mode.refreshRate}Hz")
// Check for exact match
if (BigDecimal(fps.toString()).setScale(1, RoundingMode.FLOOR) ==
BigDecimal(mode.refreshRate.toString()).setScale(1, RoundingMode.FLOOR)) {
modeToUse = mode
Log.d(TAG, "Found exact match: ${mode.refreshRate}Hz")
break
}
// Check for integer multiple (e.g., 48Hz for 24fps)
else if (mode.refreshRate % fps == 0f) {
modeToUse = mode
Log.d(TAG, "Found integer multiple: ${mode.refreshRate}Hz")
break
}
}
if (modeToUse != currentMode) {
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()
} else {
Log.d(TAG, "No better mode found, staying at ${currentMode.refreshRate}Hz")
}
}
frameRateManager?.clearVideoFrameRate()
}
// Cleanup
@@ -798,12 +479,11 @@ class MpvPlayerCore(private val activity: Activity) :
// Shutdown command executor
commandExecutor.shutdown()
// Clean up frame rate listener
clearVideoFrameRate()
// Release audio focus
abandonAudioFocus()
audioManager = null
// Clean up frame rate and audio focus
frameRateManager?.clearVideoFrameRate()
frameRateManager = null
audioFocusManager?.release()
audioFocusManager = null
if (nativeReady) {
try {
@@ -0,0 +1,105 @@
package com.edde746.plezy.shared
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
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) }
) {
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 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)")
}
}
}
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)
}
hasAudioFocus = false
wasPlayingBeforeFocusLoss = false
}
fun release() {
abandonAudioFocus()
}
}
@@ -0,0 +1,59 @@
package com.edde746.plezy.shared
import android.graphics.PixelFormat
import android.view.SurfaceView
import android.view.TextureView
import android.view.View
import android.view.ViewGroup
object FlutterOverlayHelper {
/**
* Find the FlutterView container in the content view hierarchy.
* First tries to match by class name (works in debug builds),
* then falls back to picking the last ViewGroup with children
* (needed for release builds where FlutterView may be obfuscated).
*/
fun findFlutterContainer(contentView: ViewGroup, excludeView: View? = null): ViewGroup? {
// First pass: look for FlutterView by name (debug builds)
for (i in 0 until contentView.childCount) {
val child = contentView.getChildAt(i)
if (child is ViewGroup && child.javaClass.name.contains("FlutterView")) {
return child
}
}
// Fallback for release (FlutterView may be obfuscated): pick the last ViewGroup
// that is not our video container and has children.
for (i in contentView.childCount - 1 downTo 0) {
val child = contentView.getChildAt(i)
if (child is ViewGroup && child != excludeView && child.childCount > 0) {
return child
}
}
return null
}
/**
* Configure z-ordering so the Flutter UI renders above the video surface.
*
* @param zOrderOnTop true for MPV (Flutter needs to be fully on top),
* false for ExoPlayer (subtitle layer sits between video and Flutter)
*/
fun configureFlutterZOrder(contentView: ViewGroup, container: ViewGroup, zOrderOnTop: Boolean) {
contentView.bringChildToFront(container)
for (j in 0 until container.childCount) {
val flutterChild = container.getChildAt(j)
if (flutterChild is SurfaceView) {
flutterChild.setZOrderOnTop(zOrderOnTop)
flutterChild.setZOrderMediaOverlay(true)
flutterChild.holder.setFormat(PixelFormat.TRANSLUCENT)
break
} else if (flutterChild is TextureView) {
flutterChild.isOpaque = false
break
}
}
}
}
@@ -0,0 +1,181 @@
package com.edde746.plezy.shared
import android.app.Activity
import android.content.Context
import android.hardware.display.DisplayManager
import android.os.Build
import android.os.Handler
import android.util.Log
import android.view.Surface
import android.view.WindowManager
import androidx.annotation.RequiresApi
import java.math.BigDecimal
import java.math.RoundingMode
class FrameRateManager(
private val activity: Activity,
private val handler: Handler,
private val onDisplayChanged: () -> Unit,
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 var currentVideoFps: Float = 0f
private var displayListener: DisplayManager.DisplayListener? = null
private fun getDisplayManager(): DisplayManager {
return activity.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
}
fun setVideoFrameRate(fps: Float, videoDurationMs: Long, surface: Surface?) {
currentVideoFps = fps
if (fps <= 0f) {
Log.d(TAG, "setVideoFrameRate: Invalid fps ($fps), skipping")
return
}
if (surface == null) {
Log.d(TAG, "setVideoFrameRate: Surface not available")
return
}
log("fps=$fps, duration=${videoDurationMs}ms, API=${Build.VERSION.SDK_INT}")
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> setFrameRateS(fps, surface, videoDurationMs)
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> setFrameRateR(fps, surface)
Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> setFrameRateM(fps)
}
}
fun clearVideoFrameRate() {
Log.d(TAG, "clearVideoFrameRate")
currentVideoFps = 0f
displayListener?.let {
getDisplayManager().unregisterDisplayListener(it)
displayListener = null
}
}
private fun registerDisplayListener() {
displayListener?.let {
getDisplayManager().unregisterDisplayListener(it)
}
displayListener = object : DisplayManager.DisplayListener {
override fun onDisplayAdded(displayId: Int) = Unit
override fun onDisplayRemoved(displayId: Int) = Unit
override fun onDisplayChanged(displayId: Int) {
handler.postDelayed({
onDisplayChanged()
}, 2000L)
getDisplayManager().unregisterDisplayListener(this)
displayListener = null
}
}
getDisplayManager().registerDisplayListener(displayListener, handler)
}
@RequiresApi(Build.VERSION_CODES.R)
private fun setFrameRateR(fps: Float, surface: Surface) {
Log.d(TAG, "setFrameRateR: Setting frame rate to $fps")
surface.setFrameRate(fps, Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE)
registerDisplayListener()
}
@RequiresApi(Build.VERSION_CODES.S)
private fun setFrameRateS(fps: Float, surface: Surface, videoDurationMs: Long) {
Log.d(TAG, "setFrameRateS: fps=$fps, duration=${videoDurationMs}ms")
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
)
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()
} 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()
} 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
)
}
}
}
@RequiresApi(Build.VERSION_CODES.M)
private fun setFrameRateM(fps: Float) {
Log.d(TAG, "setFrameRateM: fps=$fps")
val wm = activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager
@Suppress("DEPRECATION")
val display = wm.defaultDisplay ?: return
display.supportedModes?.let { supportedModes ->
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 == 0f) {
modeToUse = mode
break
}
}
if (modeToUse != currentMode) {
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()
}
}
}
}
@@ -0,0 +1,12 @@
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
}
}