refactor: extract shared Android utilities from player cores
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user