From 3a5cab956d8ce7c9c41472ca008599a388d586f4 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 1 Mar 2026 10:40:59 +0100 Subject: [PATCH] refactor: extract shared Android utilities from player cores --- .../kotlin/com/edde746/plezy/MainActivity.kt | 23 +- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 347 ++------------ .../com/edde746/plezy/mpv/MpvPlayerCore.kt | 438 +++--------------- .../edde746/plezy/shared/AudioFocusManager.kt | 105 +++++ .../plezy/shared/FlutterOverlayHelper.kt | 59 +++ .../edde746/plezy/shared/FrameRateManager.kt | 181 ++++++++ .../com/edde746/plezy/shared/ThemeHelper.kt | 12 + 7 files changed, 458 insertions(+), 707 deletions(-) create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/shared/AudioFocusManager.kt create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/shared/FlutterOverlayHelper.kt create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/shared/FrameRateManager.kt create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/shared/ThemeHelper.kt diff --git a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt index 839862f8..b929e00b 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt @@ -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) { diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index 41aafc70..3fad0444 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -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? = 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(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 diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt index 71f78c1d..f2b0a172 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt @@ -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(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(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 { diff --git a/android/app/src/main/kotlin/com/edde746/plezy/shared/AudioFocusManager.kt b/android/app/src/main/kotlin/com/edde746/plezy/shared/AudioFocusManager.kt new file mode 100644 index 00000000..c5446615 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/shared/AudioFocusManager.kt @@ -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() + } +} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/shared/FlutterOverlayHelper.kt b/android/app/src/main/kotlin/com/edde746/plezy/shared/FlutterOverlayHelper.kt new file mode 100644 index 00000000..9be9e46e --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/shared/FlutterOverlayHelper.kt @@ -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 + } + } + } +} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/shared/FrameRateManager.kt b/android/app/src/main/kotlin/com/edde746/plezy/shared/FrameRateManager.kt new file mode 100644 index 00000000..91d17c9e --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/shared/FrameRateManager.kt @@ -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() + } + } + } +} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/shared/ThemeHelper.kt b/android/app/src/main/kotlin/com/edde746/plezy/shared/ThemeHelper.kt new file mode 100644 index 00000000..584475f6 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/shared/ThemeHelper.kt @@ -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 + } +}