From 8a0da859cbbc82316a3fa2afbdbeaedb12db0315 Mon Sep 17 00:00:00 2001 From: Lee Hudson Date: Fri, 22 May 2026 13:29:11 +0100 Subject: [PATCH 1/5] Implemented frame rate matching for android --- .../jknaapen/fladder/VideoPlayerActivity.kt | 19 +++ .../fladder/api/PlayerSettingsHelper.g.kt | 7 +- .../fladder/api/VideoPlayerHelper.g.kt | 13 +- .../messengers/VideoPlayerImplementation.kt | 12 ++ .../fladder/utility/RefreshRateHelper.kt | 146 ++++++++++++++++++ android/gradle.properties | 4 + lib/l10n/app_en.arb | 2 + .../settings/video_player_settings.dart | 1 + .../video_player_settings.freezed.dart | 49 ++++-- .../settings/video_player_settings.g.dart | 2 + .../pigeon_player_settings_provider.dart | 1 + .../video_player_settings_provider.dart | 2 + .../settings/player_settings_page.dart | 10 ++ lib/src/player_settings_helper.g.dart | 5 + lib/src/video_player_helper.g.dart | 15 ++ lib/wrappers/players/native_player.dart | 3 + pigeons/player_settings_pigeon.dart | 2 + pigeons/video_player.dart | 6 + 18 files changed, 282 insertions(+), 17 deletions(-) create mode 100644 android/app/src/main/kotlin/nl/jknaapen/fladder/utility/RefreshRateHelper.kt diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/VideoPlayerActivity.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/VideoPlayerActivity.kt index a8ad4bc88..b9af1810f 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/VideoPlayerActivity.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/VideoPlayerActivity.kt @@ -1,6 +1,7 @@ package nl.jknaapen.fladder import android.graphics.PixelFormat +import android.hardware.display.DisplayManager import android.os.Build import android.os.Bundle import android.view.WindowManager @@ -12,12 +13,17 @@ import androidx.annotation.RequiresApi import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext import androidx.media3.common.util.UnstableApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import nl.jknaapen.fladder.composables.controls.CustomVideoControls import nl.jknaapen.fladder.composables.overlays.screensavers.ScreenSaver import nl.jknaapen.fladder.objects.VideoPlayerObject import nl.jknaapen.fladder.player.ExoPlayer import nl.jknaapen.fladder.utility.ScaledContent +import nl.jknaapen.fladder.utility.applyRefreshRate import nl.jknaapen.fladder.utility.leanBackEnabled +import nl.jknaapen.fladder.utility.resetRefreshRate class VideoPlayerActivity : ComponentActivity() { @RequiresApi(Build.VERSION_CODES.O) @@ -40,10 +46,23 @@ class VideoPlayerActivity : ComponentActivity() { } } + fun applyVideoRefreshRate(videoWidth: Int, videoHeight: Int, frameRate: Float) { + val displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager + CoroutineScope(Dispatchers.IO).launch { + applyRefreshRate(window, displayManager, videoWidth, videoHeight, frameRate) + } + } + override fun onPause() { super.onPause() VideoPlayerObject.implementation.pause() } + + override fun onDestroy() { + super.onDestroy() + resetRefreshRate(window) + VideoPlayerObject.currentActivity = null + } } @OptIn(UnstableApi::class) diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/api/PlayerSettingsHelper.g.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/api/PlayerSettingsHelper.g.kt index e60c2c4a6..a88a3f1f5 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/api/PlayerSettingsHelper.g.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/api/PlayerSettingsHelper.g.kt @@ -158,7 +158,8 @@ data class PlayerSettings ( val acceptedOrientations: List, val fillScreen: Boolean, val videoFit: VideoPlayerFit, - val screensaver: Screensaver + val screensaver: Screensaver, + val refreshRateSwitching: Boolean ) { companion object { @@ -173,7 +174,8 @@ data class PlayerSettings ( val fillScreen = pigeonVar_list[7] as Boolean val videoFit = pigeonVar_list[8] as VideoPlayerFit val screensaver = pigeonVar_list[9] as Screensaver - return PlayerSettings(enableTunneling, skipTypes, themeColor, skipForward, skipBackward, autoNextType, acceptedOrientations, fillScreen, videoFit, screensaver) + val refreshRateSwitching = pigeonVar_list[10] as Boolean + return PlayerSettings(enableTunneling, skipTypes, themeColor, skipForward, skipBackward, autoNextType, acceptedOrientations, fillScreen, videoFit, screensaver, refreshRateSwitching) } } fun toList(): List { @@ -188,6 +190,7 @@ data class PlayerSettings ( fillScreen, videoFit, screensaver, + refreshRateSwitching, ) } override fun equals(other: Any?): Boolean { diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/api/VideoPlayerHelper.g.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/api/VideoPlayerHelper.g.kt index 440f8edf4..fdf40fa87 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/api/VideoPlayerHelper.g.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/api/VideoPlayerHelper.g.kt @@ -196,7 +196,10 @@ data class PlayableData ( val previousVideo: SimpleItemModel? = null, val nextVideo: SimpleItemModel? = null, val mediaInfo: MediaInfo, - val url: String + val url: String, + val videoWidth: Long? = null, + val videoHeight: Long? = null, + val videoFrameRate: Double? = null ) { companion object { @@ -215,7 +218,10 @@ data class PlayableData ( val nextVideo = pigeonVar_list[11] as SimpleItemModel? val mediaInfo = pigeonVar_list[12] as MediaInfo val url = pigeonVar_list[13] as String - return PlayableData(currentItem, description, startPosition, defaultAudioTrack, audioTracks, defaultSubtrack, subtitleTracks, trickPlayModel, chapters, segments, previousVideo, nextVideo, mediaInfo, url) + val videoWidth = pigeonVar_list[14] as Long? + val videoHeight = pigeonVar_list[15] as Long? + val videoFrameRate = pigeonVar_list[16] as Double? + return PlayableData(currentItem, description, startPosition, defaultAudioTrack, audioTracks, defaultSubtrack, subtitleTracks, trickPlayModel, chapters, segments, previousVideo, nextVideo, mediaInfo, url, videoWidth, videoHeight, videoFrameRate) } } fun toList(): List { @@ -234,6 +240,9 @@ data class PlayableData ( nextVideo, mediaInfo, url, + videoWidth, + videoHeight, + videoFrameRate, ) } override fun equals(other: Any?): Boolean { diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/messengers/VideoPlayerImplementation.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/messengers/VideoPlayerImplementation.kt index 88f0ed170..bddcc4052 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/messengers/VideoPlayerImplementation.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/messengers/VideoPlayerImplementation.kt @@ -117,6 +117,18 @@ class VideoPlayerImplementation( player?.playWhenReady = play callback(Result.success(true)) subsInitialized = false + + // Apply refresh rate matching if enabled and video metadata is available + if (PlayerSettingsObject.settings.value?.refreshRateSwitching == true) { + val data = playbackData.value + val w = data?.videoWidth?.toInt() + val h = data?.videoHeight?.toInt() + val fps = data?.videoFrameRate?.toFloat() + if (w != null && w > 0 && h != null && h > 0 && fps != null && fps > 0f) { + VideoPlayerObject.currentActivity?.applyVideoRefreshRate(w, h, fps) + } + } + return@postDelayed } catch (e: Exception) { println("Error playing video $e") diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/RefreshRateHelper.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/RefreshRateHelper.kt new file mode 100644 index 000000000..76d232fcf --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/RefreshRateHelper.kt @@ -0,0 +1,146 @@ +package nl.jknaapen.fladder.utility + +import android.hardware.display.DisplayManager +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.Display +import android.view.Window +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.math.roundToInt +import kotlin.time.Duration.Companion.seconds + +private const val TAG = "RefreshRateHelper" + +suspend fun applyRefreshRate( + window: Window, + displayManager: DisplayManager, + videoWidth: Int, + videoHeight: Int, + frameRate: Float, +) = withContext(Dispatchers.IO) { + val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY) + val displayModes = display.supportedModes + .orEmpty() + .map { RefreshRateDisplayMode(it) } + .sortedWith( + compareByDescending { it.physicalWidth * it.physicalHeight } + .thenBy { it.refreshRateRounded } + ) + + val currentMode = display.mode + val targetMode = findDisplayMode( + displayModes = displayModes, + streamWidth = videoWidth, + streamHeight = videoHeight, + targetFrameRate = frameRate, + ) + + Log.d(TAG, "Video: ${videoWidth}x${videoHeight} @ ${frameRate}fps — target mode: $targetMode, current: $currentMode") + + if (targetMode == null || targetMode.modeId == currentMode.modeId) return@withContext + + val listener = DisplayChangeListener(display.displayId) + displayManager.registerDisplayListener(listener, Handler(Looper.getMainLooper())) + try { + withContext(Dispatchers.Main) { + val attrs = window.attributes + attrs.preferredDisplayModeId = targetMode.modeId + window.attributes = attrs + } + withTimeoutOrNull(5.seconds) { listener.deferred.await() } + } finally { + displayManager.unregisterDisplayListener(listener) + } + + // Wait for non-seamless switches (https://developer.android.com/media/optimize/performance/frame-rate) + val targetRateMillis = (targetMode.refreshRate * 1000).roundToInt() + val currentRateMillis = (currentMode.refreshRate * 1000).roundToInt() + val isSeamless = targetRateMillis == currentRateMillis || + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + currentMode.alternativeRefreshRates + .map { (it * 1000).roundToInt() } + .any { it % targetRateMillis == 0 } + } else { + false + } + if (!isSeamless) { + delay(2.seconds) + } +} + +fun resetRefreshRate(window: Window) { + val attrs = window.attributes + attrs.preferredDisplayModeId = 0 + window.attributes = attrs +} + +private fun findDisplayMode( + displayModes: List, + streamWidth: Int, + streamHeight: Int, + targetFrameRate: Float, +): RefreshRateDisplayMode? { + val streamRate = (targetFrameRate * 1000).roundToInt() + val candidates = displayModes + .filter { it.physicalWidth >= streamWidth && it.physicalHeight >= streamHeight } + .filter { frameRateMatches(it.refreshRateRounded, streamRate) } + + // Exact resolution + exact frame rate + return candidates.firstOrNull { + it.physicalWidth == streamWidth && it.physicalHeight == streamHeight && it.refreshRateRounded == streamRate + } + // Next highest resolution + exact frame rate + ?: candidates.lastOrNull { + it.physicalWidth >= streamWidth && it.physicalHeight >= streamHeight && it.refreshRateRounded == streamRate + } + // Exact resolution + acceptable frame rate + ?: candidates.lastOrNull { + it.physicalWidth == streamWidth && it.physicalHeight == streamHeight + } + // Next highest resolution + acceptable frame rate + ?: candidates.lastOrNull { + it.physicalWidth >= streamWidth && it.physicalHeight >= streamHeight + } + // Highest resolution at exact frame rate + ?: displayModes + .filter { it.refreshRateRounded == streamRate } + .maxByOrNull { it.physicalWidth * it.physicalHeight } + // Fallback: highest resolution + ?: displayModes.maxByOrNull { it.physicalWidth * it.physicalHeight } +} + +private fun frameRateMatches(refreshRateRounded: Int, streamRate: Int): Boolean { + return refreshRateRounded % streamRate == 0 || + refreshRateRounded == (streamRate * 2.5).roundToInt() +} + +data class RefreshRateDisplayMode( + val modeId: Int, + val physicalWidth: Int, + val physicalHeight: Int, + val refreshRate: Float, +) { + val refreshRateRounded: Int = (refreshRate * 1000).roundToInt() + + constructor(mode: Display.Mode) : this( + mode.modeId, + mode.physicalWidth, + mode.physicalHeight, + mode.refreshRate, + ) +} + +private class DisplayChangeListener(val displayId: Int) : DisplayManager.DisplayListener { + val deferred = CompletableDeferred() + override fun onDisplayAdded(displayId: Int) {} + override fun onDisplayRemoved(displayId: Int) {} + override fun onDisplayChanged(displayId: Int) { + if (displayId == this.displayId) deferred.complete(Unit) + } +} diff --git a/android/gradle.properties b/android/gradle.properties index 259717082..4147ba381 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,7 @@ org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d2fa5c6d3..e4a548ead 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1686,6 +1686,8 @@ "itemColorsDesc": "Use item's primary color to theme the details page", "mediaTunnelingTitle": "Media tunneling", "mediaTunnelingDesc": "Enable media tunneling for native player", + "refreshRateSwitchingTitle": "Refresh Rate Matching", + "refreshRateSwitchingDesc": "Match display refresh rate to video frame rate", "clientSettingsUseSystemIMETitle": "Use system keyboard", "clientSettingsUseSystemIMEDesc": "Use the built-in keyboard provided by your system", "nextUpInCount": "Next-up in {seconds}", diff --git a/lib/models/settings/video_player_settings.dart b/lib/models/settings/video_player_settings.dart index 4afc33f09..d587d7b5d 100644 --- a/lib/models/settings/video_player_settings.dart +++ b/lib/models/settings/video_player_settings.dart @@ -110,6 +110,7 @@ abstract class VideoPlayerSettingsModel with _$VideoPlayerSettingsModel { @Default(true) bool enablePlayPauseFade, @Default(true) bool enableCrossfade, @Default(400) int crossfadeDurationMs, + @Default(false) bool refreshRateSwitching, }) = _VideoPlayerSettingsModel; double get volume => internalVolume; diff --git a/lib/models/settings/video_player_settings.freezed.dart b/lib/models/settings/video_player_settings.freezed.dart index 0046a082f..20e078a4b 100644 --- a/lib/models/settings/video_player_settings.freezed.dart +++ b/lib/models/settings/video_player_settings.freezed.dart @@ -43,6 +43,7 @@ mixin _$VideoPlayerSettingsModel implements DiagnosticableTreeMixin { bool get enablePlayPauseFade; bool get enableCrossfade; int get crossfadeDurationMs; + bool get refreshRateSwitching; /// Create a copy of VideoPlayerSettingsModel /// with the given fields replaced by the non-null parameter values. @@ -89,12 +90,13 @@ mixin _$VideoPlayerSettingsModel implements DiagnosticableTreeMixin { ..add(DiagnosticsProperty('replayGainVolumeLevel', replayGainVolumeLevel)) ..add(DiagnosticsProperty('enablePlayPauseFade', enablePlayPauseFade)) ..add(DiagnosticsProperty('enableCrossfade', enableCrossfade)) - ..add(DiagnosticsProperty('crossfadeDurationMs', crossfadeDurationMs)); + ..add(DiagnosticsProperty('crossfadeDurationMs', crossfadeDurationMs)) + ..add(DiagnosticsProperty('refreshRateSwitching', refreshRateSwitching)); } @override String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { - return 'VideoPlayerSettingsModel(screenBrightness: $screenBrightness, videoFit: $videoFit, fillScreen: $fillScreen, hardwareAccel: $hardwareAccel, useLibass: $useLibass, enableTunneling: $enableTunneling, bufferSize: $bufferSize, playerOptions: $playerOptions, internalVolume: $internalVolume, allowedOrientations: $allowedOrientations, nextVideoType: $nextVideoType, maxHomeBitrate: $maxHomeBitrate, maxInternetBitrate: $maxInternetBitrate, audioDevice: $audioDevice, segmentSkipSettings: $segmentSkipSettings, hotKeys: $hotKeys, screensaver: $screensaver, enableSpeedBoost: $enableSpeedBoost, speedBoostRate: $speedBoostRate, enableDoubleTapSeek: $enableDoubleTapSeek, enableAdvancedVideoOptions: $enableAdvancedVideoOptions, enableEdgeGestures: $enableEdgeGestures, reverseEdgeGestures: $reverseEdgeGestures, enablePictureInPicture: $enablePictureInPicture, enableReplayGain: $enableReplayGain, replayGainVolumeLevel: $replayGainVolumeLevel, enablePlayPauseFade: $enablePlayPauseFade, enableCrossfade: $enableCrossfade, crossfadeDurationMs: $crossfadeDurationMs)'; + return 'VideoPlayerSettingsModel(screenBrightness: $screenBrightness, videoFit: $videoFit, fillScreen: $fillScreen, hardwareAccel: $hardwareAccel, useLibass: $useLibass, enableTunneling: $enableTunneling, bufferSize: $bufferSize, playerOptions: $playerOptions, internalVolume: $internalVolume, allowedOrientations: $allowedOrientations, nextVideoType: $nextVideoType, maxHomeBitrate: $maxHomeBitrate, maxInternetBitrate: $maxInternetBitrate, audioDevice: $audioDevice, segmentSkipSettings: $segmentSkipSettings, hotKeys: $hotKeys, screensaver: $screensaver, enableSpeedBoost: $enableSpeedBoost, speedBoostRate: $speedBoostRate, enableDoubleTapSeek: $enableDoubleTapSeek, enableAdvancedVideoOptions: $enableAdvancedVideoOptions, enableEdgeGestures: $enableEdgeGestures, reverseEdgeGestures: $reverseEdgeGestures, enablePictureInPicture: $enablePictureInPicture, enableReplayGain: $enableReplayGain, replayGainVolumeLevel: $replayGainVolumeLevel, enablePlayPauseFade: $enablePlayPauseFade, enableCrossfade: $enableCrossfade, crossfadeDurationMs: $crossfadeDurationMs, refreshRateSwitching: $refreshRateSwitching)'; } } @@ -133,7 +135,8 @@ abstract mixin class $VideoPlayerSettingsModelCopyWith<$Res> { ReplayGainVolumeLevel replayGainVolumeLevel, bool enablePlayPauseFade, bool enableCrossfade, - int crossfadeDurationMs}); + int crossfadeDurationMs, + bool refreshRateSwitching}); } /// @nodoc @@ -178,6 +181,7 @@ class _$VideoPlayerSettingsModelCopyWithImpl<$Res> Object? enablePlayPauseFade = null, Object? enableCrossfade = null, Object? crossfadeDurationMs = null, + Object? refreshRateSwitching = null, }) { return _then(_self.copyWith( screenBrightness: freezed == screenBrightness @@ -296,6 +300,10 @@ class _$VideoPlayerSettingsModelCopyWithImpl<$Res> ? _self.crossfadeDurationMs : crossfadeDurationMs // ignore: cast_nullable_to_non_nullable as int, + refreshRateSwitching: null == refreshRateSwitching + ? _self.refreshRateSwitching + : refreshRateSwitching // ignore: cast_nullable_to_non_nullable + as bool, )); } } @@ -422,7 +430,8 @@ extension VideoPlayerSettingsModelPatterns on VideoPlayerSettingsModel { ReplayGainVolumeLevel replayGainVolumeLevel, bool enablePlayPauseFade, bool enableCrossfade, - int crossfadeDurationMs)? + int crossfadeDurationMs, + bool refreshRateSwitching)? $default, { required TResult orElse(), }) { @@ -458,7 +467,8 @@ extension VideoPlayerSettingsModelPatterns on VideoPlayerSettingsModel { _that.replayGainVolumeLevel, _that.enablePlayPauseFade, _that.enableCrossfade, - _that.crossfadeDurationMs); + _that.crossfadeDurationMs, + _that.refreshRateSwitching); case _: return orElse(); } @@ -508,7 +518,8 @@ extension VideoPlayerSettingsModelPatterns on VideoPlayerSettingsModel { ReplayGainVolumeLevel replayGainVolumeLevel, bool enablePlayPauseFade, bool enableCrossfade, - int crossfadeDurationMs) + int crossfadeDurationMs, + bool refreshRateSwitching) $default, ) { final _that = this; @@ -543,7 +554,8 @@ extension VideoPlayerSettingsModelPatterns on VideoPlayerSettingsModel { _that.replayGainVolumeLevel, _that.enablePlayPauseFade, _that.enableCrossfade, - _that.crossfadeDurationMs); + _that.crossfadeDurationMs, + _that.refreshRateSwitching); case _: throw StateError('Unexpected subclass'); } @@ -592,7 +604,8 @@ extension VideoPlayerSettingsModelPatterns on VideoPlayerSettingsModel { ReplayGainVolumeLevel replayGainVolumeLevel, bool enablePlayPauseFade, bool enableCrossfade, - int crossfadeDurationMs)? + int crossfadeDurationMs, + bool refreshRateSwitching)? $default, ) { final _that = this; @@ -627,7 +640,8 @@ extension VideoPlayerSettingsModelPatterns on VideoPlayerSettingsModel { _that.replayGainVolumeLevel, _that.enablePlayPauseFade, _that.enableCrossfade, - _that.crossfadeDurationMs); + _that.crossfadeDurationMs, + _that.refreshRateSwitching); case _: return null; } @@ -668,7 +682,8 @@ class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel this.replayGainVolumeLevel = ReplayGainVolumeLevel.quiet, this.enablePlayPauseFade = true, this.enableCrossfade = true, - this.crossfadeDurationMs = 400}) + this.crossfadeDurationMs = 400, + this.refreshRateSwitching = false}) : _allowedOrientations = allowedOrientations, _segmentSkipSettings = segmentSkipSettings, _hotKeys = hotKeys, @@ -781,6 +796,7 @@ class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel @override @JsonKey() final int crossfadeDurationMs; + final bool refreshRateSwitching; /// Create a copy of VideoPlayerSettingsModel /// with the given fields replaced by the non-null parameter values. @@ -832,12 +848,13 @@ class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel ..add(DiagnosticsProperty('replayGainVolumeLevel', replayGainVolumeLevel)) ..add(DiagnosticsProperty('enablePlayPauseFade', enablePlayPauseFade)) ..add(DiagnosticsProperty('enableCrossfade', enableCrossfade)) - ..add(DiagnosticsProperty('crossfadeDurationMs', crossfadeDurationMs)); + ..add(DiagnosticsProperty('crossfadeDurationMs', crossfadeDurationMs)) + ..add(DiagnosticsProperty('refreshRateSwitching', refreshRateSwitching)); } @override String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { - return 'VideoPlayerSettingsModel(screenBrightness: $screenBrightness, videoFit: $videoFit, fillScreen: $fillScreen, hardwareAccel: $hardwareAccel, useLibass: $useLibass, enableTunneling: $enableTunneling, bufferSize: $bufferSize, playerOptions: $playerOptions, internalVolume: $internalVolume, allowedOrientations: $allowedOrientations, nextVideoType: $nextVideoType, maxHomeBitrate: $maxHomeBitrate, maxInternetBitrate: $maxInternetBitrate, audioDevice: $audioDevice, segmentSkipSettings: $segmentSkipSettings, hotKeys: $hotKeys, screensaver: $screensaver, enableSpeedBoost: $enableSpeedBoost, speedBoostRate: $speedBoostRate, enableDoubleTapSeek: $enableDoubleTapSeek, enableAdvancedVideoOptions: $enableAdvancedVideoOptions, enableEdgeGestures: $enableEdgeGestures, reverseEdgeGestures: $reverseEdgeGestures, enablePictureInPicture: $enablePictureInPicture, enableReplayGain: $enableReplayGain, replayGainVolumeLevel: $replayGainVolumeLevel, enablePlayPauseFade: $enablePlayPauseFade, enableCrossfade: $enableCrossfade, crossfadeDurationMs: $crossfadeDurationMs)'; + return 'VideoPlayerSettingsModel(screenBrightness: $screenBrightness, videoFit: $videoFit, fillScreen: $fillScreen, hardwareAccel: $hardwareAccel, useLibass: $useLibass, enableTunneling: $enableTunneling, bufferSize: $bufferSize, playerOptions: $playerOptions, internalVolume: $internalVolume, allowedOrientations: $allowedOrientations, nextVideoType: $nextVideoType, maxHomeBitrate: $maxHomeBitrate, maxInternetBitrate: $maxInternetBitrate, audioDevice: $audioDevice, segmentSkipSettings: $segmentSkipSettings, hotKeys: $hotKeys, screensaver: $screensaver, enableSpeedBoost: $enableSpeedBoost, speedBoostRate: $speedBoostRate, enableDoubleTapSeek: $enableDoubleTapSeek, enableAdvancedVideoOptions: $enableAdvancedVideoOptions, enableEdgeGestures: $enableEdgeGestures, reverseEdgeGestures: $reverseEdgeGestures, enablePictureInPicture: $enablePictureInPicture, enableReplayGain: $enableReplayGain, replayGainVolumeLevel: $replayGainVolumeLevel, enablePlayPauseFade: $enablePlayPauseFade, enableCrossfade: $enableCrossfade, crossfadeDurationMs: $crossfadeDurationMs, refreshRateSwitching: $refreshRateSwitching)'; } } @@ -878,7 +895,8 @@ abstract mixin class _$VideoPlayerSettingsModelCopyWith<$Res> ReplayGainVolumeLevel replayGainVolumeLevel, bool enablePlayPauseFade, bool enableCrossfade, - int crossfadeDurationMs}); + int crossfadeDurationMs, + bool refreshRateSwitching}); } /// @nodoc @@ -923,6 +941,7 @@ class __$VideoPlayerSettingsModelCopyWithImpl<$Res> Object? enablePlayPauseFade = null, Object? enableCrossfade = null, Object? crossfadeDurationMs = null, + Object? refreshRateSwitching = null, }) { return _then(_VideoPlayerSettingsModel( screenBrightness: freezed == screenBrightness @@ -1041,6 +1060,10 @@ class __$VideoPlayerSettingsModelCopyWithImpl<$Res> ? _self.crossfadeDurationMs : crossfadeDurationMs // ignore: cast_nullable_to_non_nullable as int, + refreshRateSwitching: null == refreshRateSwitching + ? _self.refreshRateSwitching + : refreshRateSwitching // ignore: cast_nullable_to_non_nullable + as bool, )); } } diff --git a/lib/models/settings/video_player_settings.g.dart b/lib/models/settings/video_player_settings.g.dart index c6e599658..9eec628ec 100644 --- a/lib/models/settings/video_player_settings.g.dart +++ b/lib/models/settings/video_player_settings.g.dart @@ -63,6 +63,7 @@ _VideoPlayerSettingsModel _$VideoPlayerSettingsModelFromJson( enableCrossfade: json['enableCrossfade'] as bool? ?? true, crossfadeDurationMs: (json['crossfadeDurationMs'] as num?)?.toInt() ?? 400, + refreshRateSwitching: json['refreshRateSwitching'] as bool? ?? false, ); Map _$VideoPlayerSettingsModelToJson( @@ -102,6 +103,7 @@ Map _$VideoPlayerSettingsModelToJson( 'enablePlayPauseFade': instance.enablePlayPauseFade, 'enableCrossfade': instance.enableCrossfade, 'crossfadeDurationMs': instance.crossfadeDurationMs, + 'refreshRateSwitching': instance.refreshRateSwitching, }; const _$BoxFitEnumMap = { diff --git a/lib/providers/settings/pigeon_player_settings_provider.dart b/lib/providers/settings/pigeon_player_settings_provider.dart index 0a896acd0..993a78e23 100644 --- a/lib/providers/settings/pigeon_player_settings_provider.dart +++ b/lib/providers/settings/pigeon_player_settings_provider.dart @@ -81,6 +81,7 @@ final pigeonPlayerSettingsSyncProvider = Provider((ref) { }, ) .toList(), + refreshRateSwitching: value.refreshRateSwitching, ), ); } diff --git a/lib/providers/settings/video_player_settings_provider.dart b/lib/providers/settings/video_player_settings_provider.dart index ce883c021..5928c5a86 100644 --- a/lib/providers/settings/video_player_settings_provider.dart +++ b/lib/providers/settings/video_player_settings_provider.dart @@ -195,6 +195,8 @@ class VideoPlayerSettingsProviderNotifier extends StateNotifier state = state.copyWith(crossfadeDurationMs: value); + void setRefreshRateSwitching(bool value) => state = state.copyWith(refreshRateSwitching: value); + static VideoPlayerSettingsModel _sanitizeCrossfade(VideoPlayerSettingsModel value) { if (!value.canUseCrossfade && value.enableCrossfade) { return value.copyWith(enableCrossfade: false); diff --git a/lib/screens/settings/player_settings_page.dart b/lib/screens/settings/player_settings_page.dart index a710a9fbb..6007c202c 100644 --- a/lib/screens/settings/player_settings_page.dart +++ b/lib/screens/settings/player_settings_page.dart @@ -401,6 +401,16 @@ class _PlayerSettingsPageState extends ConsumerState { onChanged: (value) => provider.setMediaTunneling(value), ), ), + if (currentPlayer == PlayerOptions.nativePlayer) + SettingsListTile( + label: Text(context.localized.refreshRateSwitchingTitle), + subLabel: Text(context.localized.refreshRateSwitchingDesc), + onTap: () => provider.setRefreshRateSwitching(!videoSettings.refreshRateSwitching), + trailing: Switch( + value: videoSettings.refreshRateSwitching, + onChanged: (value) => provider.setRefreshRateSwitching(value), + ), + ), if (ref.read(argumentsStateProvider).leanBackMode) SettingsListTileEnum( label: Text(context.localized.playerSettingsScreensaverTitle), diff --git a/lib/src/player_settings_helper.g.dart b/lib/src/player_settings_helper.g.dart index 2b44241d0..6cac1c5ce 100644 --- a/lib/src/player_settings_helper.g.dart +++ b/lib/src/player_settings_helper.g.dart @@ -87,6 +87,7 @@ class PlayerSettings { required this.fillScreen, required this.videoFit, required this.screensaver, + required this.refreshRateSwitching, }); bool enableTunneling; @@ -109,6 +110,8 @@ class PlayerSettings { Screensaver screensaver; + bool refreshRateSwitching; + List _toList() { return [ enableTunneling, @@ -121,6 +124,7 @@ class PlayerSettings { fillScreen, videoFit, screensaver, + refreshRateSwitching, ]; } @@ -140,6 +144,7 @@ class PlayerSettings { fillScreen: result[7]! as bool, videoFit: result[8]! as VideoPlayerFit, screensaver: result[9]! as Screensaver, + refreshRateSwitching: result[10]! as bool, ); } diff --git a/lib/src/video_player_helper.g.dart b/lib/src/video_player_helper.g.dart index b717b5ffe..fe8ba84e0 100644 --- a/lib/src/video_player_helper.g.dart +++ b/lib/src/video_player_helper.g.dart @@ -182,6 +182,9 @@ class PlayableData { this.nextVideo, required this.mediaInfo, required this.url, + this.videoWidth, + this.videoHeight, + this.videoFrameRate, }); SimpleItemModel currentItem; @@ -212,6 +215,12 @@ class PlayableData { String url; + int? videoWidth; + + int? videoHeight; + + double? videoFrameRate; + List _toList() { return [ currentItem, @@ -228,6 +237,9 @@ class PlayableData { nextVideo, mediaInfo, url, + videoWidth, + videoHeight, + videoFrameRate, ]; } @@ -251,6 +263,9 @@ class PlayableData { nextVideo: result[11] as SimpleItemModel?, mediaInfo: result[12]! as MediaInfo, url: result[13]! as String, + videoWidth: result[14] as int?, + videoHeight: result[15] as int?, + videoFrameRate: result[16] as double?, ); } diff --git a/lib/wrappers/players/native_player.dart b/lib/wrappers/players/native_player.dart index 6da11dd7a..a17f7354b 100644 --- a/lib/wrappers/players/native_player.dart +++ b/lib/wrappers/players/native_player.dart @@ -207,6 +207,9 @@ class NativePlayer extends BasePlayer implements VideoPlayerListenerCallback { videoInformation: model.item.streamModel?.mediaInfoTag ?? " ", ), url: model.media?.url ?? "", + videoWidth: model.mediaStreams?.videoStreams.firstOrNull?.width, + videoHeight: model.mediaStreams?.videoStreams.firstOrNull?.height, + videoFrameRate: model.mediaStreams?.videoStreams.firstOrNull?.frameRate, ); await player.sendPlayableModel(playableData); } diff --git a/pigeons/player_settings_pigeon.dart b/pigeons/player_settings_pigeon.dart index 1953a0d6c..e232316bd 100644 --- a/pigeons/player_settings_pigeon.dart +++ b/pigeons/player_settings_pigeon.dart @@ -23,6 +23,7 @@ class PlayerSettings { final bool fillScreen; final VideoPlayerFit videoFit; final Screensaver screensaver; + final bool refreshRateSwitching; const PlayerSettings({ required this.enableTunneling, @@ -35,6 +36,7 @@ class PlayerSettings { required this.fillScreen, required this.videoFit, required this.screensaver, + required this.refreshRateSwitching, }); } diff --git a/pigeons/video_player.dart b/pigeons/video_player.dart index 693228e86..b041276d9 100644 --- a/pigeons/video_player.dart +++ b/pigeons/video_player.dart @@ -59,6 +59,9 @@ class PlayableData { final SimpleItemModel? nextVideo; final MediaInfo mediaInfo; final String url; + final int? videoWidth; + final int? videoHeight; + final double? videoFrameRate; PlayableData({ required this.currentItem, @@ -75,6 +78,9 @@ class PlayableData { this.nextVideo, required this.mediaInfo, required this.url, + this.videoWidth, + this.videoHeight, + this.videoFrameRate, }); } From a302c957f0a9310f11dc398629631623720726ea Mon Sep 17 00:00:00 2001 From: Lee Hudson Date: Sat, 23 May 2026 13:32:11 +0100 Subject: [PATCH 2/5] Made playback resume after frame rate change --- .../jknaapen/fladder/VideoPlayerActivity.kt | 9 ++------ .../messengers/VideoPlayerImplementation.kt | 21 ++++++++++++++----- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/VideoPlayerActivity.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/VideoPlayerActivity.kt index b9af1810f..c0ac6a7d5 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/VideoPlayerActivity.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/VideoPlayerActivity.kt @@ -13,9 +13,6 @@ import androidx.annotation.RequiresApi import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext import androidx.media3.common.util.UnstableApi -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import nl.jknaapen.fladder.composables.controls.CustomVideoControls import nl.jknaapen.fladder.composables.overlays.screensavers.ScreenSaver import nl.jknaapen.fladder.objects.VideoPlayerObject @@ -46,11 +43,9 @@ class VideoPlayerActivity : ComponentActivity() { } } - fun applyVideoRefreshRate(videoWidth: Int, videoHeight: Int, frameRate: Float) { + suspend fun applyVideoRefreshRate(videoWidth: Int, videoHeight: Int, frameRate: Float) { val displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager - CoroutineScope(Dispatchers.IO).launch { - applyRefreshRate(window, displayManager, videoWidth, videoHeight, frameRate) - } + applyRefreshRate(window, displayManager, videoWidth, videoHeight, frameRate) } override fun onPause() { diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/messengers/VideoPlayerImplementation.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/messengers/VideoPlayerImplementation.kt index bddcc4052..79805c9e3 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/messengers/VideoPlayerImplementation.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/messengers/VideoPlayerImplementation.kt @@ -12,10 +12,14 @@ import androidx.media3.common.MediaItem import androidx.media3.common.MimeTypes import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import nl.jknaapen.fladder.objects.PlayerSettingsObject import nl.jknaapen.fladder.objects.VideoPlayerObject import nl.jknaapen.fladder.utility.clearAudioTrack @@ -105,6 +109,7 @@ class VideoPlayerImplementation( val mediaItem = mediaItemBuilder.build() + player?.playWhenReady = false player?.stop() player?.clearMediaItems() player?.setMediaItem(mediaItem) @@ -114,21 +119,27 @@ class VideoPlayerImplementation( if (startPosition > 0L) { player?.seekTo(startPosition) } - player?.playWhenReady = play callback(Result.success(true)) subsInitialized = false - // Apply refresh rate matching if enabled and video metadata is available - if (PlayerSettingsObject.settings.value?.refreshRateSwitching == true) { + // Apply refresh rate before starting playback so the mode switch doesn't interrupt video + val activity = VideoPlayerObject.currentActivity + if (PlayerSettingsObject.settings.value?.refreshRateSwitching == true && activity != null) { val data = playbackData.value val w = data?.videoWidth?.toInt() val h = data?.videoHeight?.toInt() val fps = data?.videoFrameRate?.toFloat() if (w != null && w > 0 && h != null && h > 0 && fps != null && fps > 0f) { - VideoPlayerObject.currentActivity?.applyVideoRefreshRate(w, h, fps) + CoroutineScope(Dispatchers.IO).launch { + activity.applyVideoRefreshRate(w, h, fps) + withContext(Dispatchers.Main) { + player?.playWhenReady = play + } + } + return@postDelayed } } - + player?.playWhenReady = play return@postDelayed } catch (e: Exception) { println("Error playing video $e") From 7fc4efcf6278da93ccef51e2b0891fc7383cce94 Mon Sep 17 00:00:00 2001 From: Lee Hudson Date: Sat, 23 May 2026 14:04:14 +0100 Subject: [PATCH 3/5] Fix video texture artifact on player exit The video texture was being released while still visible during the pop animation. closePlayer() now awaits endOfFrame before navigating so the widget tree can rebuild first, and the video widget hides itself when VideoPlayerState.disposed is set. --- .../components/video_player_next_wrapper.dart | 3 ++- .../video_player/tv_player_controls.dart | 3 ++- lib/screens/video_player/video_player.dart | 20 ++++++++++++------- .../video_player/video_player_controls.dart | 3 ++- 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/lib/screens/video_player/components/video_player_next_wrapper.dart b/lib/screens/video_player/components/video_player_next_wrapper.dart index 721e9f37f..bc5fe7677 100644 --- a/lib/screens/video_player/components/video_player_next_wrapper.dart +++ b/lib/screens/video_player/components/video_player_next_wrapper.dart @@ -124,7 +124,8 @@ class _VideoPlayerNextWrapperState extends ConsumerState Future closePlayer() async { clearOverlaySettings(); ref.read(videoPlayerProvider).stop(); - Navigator.of(context).pop(); + await WidgetsBinding.instance.endOfFrame; + if (context.mounted) Navigator.of(context).pop(); } Future clearOverlaySettings() async { diff --git a/lib/screens/video_player/tv_player_controls.dart b/lib/screens/video_player/tv_player_controls.dart index abf380490..5986b54e3 100644 --- a/lib/screens/video_player/tv_player_controls.dart +++ b/lib/screens/video_player/tv_player_controls.dart @@ -638,7 +638,8 @@ class _TvPlayerControlsState extends ConsumerState { Future closePlayer() async { clearOverlaySettings(); ref.read(videoPlayerProvider).stop(); - Navigator.of(context).pop(); + await WidgetsBinding.instance.endOfFrame; + if (context.mounted) Navigator.of(context).pop(); } Future clearOverlaySettings() async { diff --git a/lib/screens/video_player/video_player.dart b/lib/screens/video_player/video_player.dart index e751dace7..0e6651447 100644 --- a/lib/screens/video_player/video_player.dart +++ b/lib/screens/video_player/video_player.dart @@ -106,13 +106,19 @@ class _VideoPlayerState extends ConsumerState with WidgetsBindingOb }, ); - final player = Padding( - padding: fillScreen ? EdgeInsets.zero : EdgeInsets.only(left: padding.left, right: padding.right), - child: playerController.videoWidget( - const Key("VideoPlayer"), - fillScreen ? (MediaQuery.of(context).orientation == Orientation.portrait ? videoFit : BoxFit.cover) : videoFit, - ), - ); + final playerState = ref.watch(mediaPlaybackProvider.select((v) => v.state)); + + final player = playerState == VideoPlayerState.disposed + ? const SizedBox.shrink() + : Padding( + padding: fillScreen ? EdgeInsets.zero : EdgeInsets.only(left: padding.left, right: padding.right), + child: playerController.videoWidget( + const Key("VideoPlayer"), + fillScreen + ? (MediaQuery.of(context).orientation == Orientation.portrait ? videoFit : BoxFit.cover) + : videoFit, + ), + ); return BackIntentDpad( child: Material( diff --git a/lib/screens/video_player/video_player_controls.dart b/lib/screens/video_player/video_player_controls.dart index c5ef094f6..db7e477db 100644 --- a/lib/screens/video_player/video_player_controls.dart +++ b/lib/screens/video_player/video_player_controls.dart @@ -777,7 +777,8 @@ class _DesktopControlsState extends ConsumerState { Future closePlayer() async { clearOverlaySettings(); ref.read(videoPlayerProvider).stop(); - Navigator.of(context).pop(); + await WidgetsBinding.instance.endOfFrame; + if (context.mounted) Navigator.of(context).pop(); } Future clearOverlaySettings() async { From ea030c71d989ecad2a996feed6d08b8033333f9c Mon Sep 17 00:00:00 2001 From: Lee Hudson Date: Fri, 12 Jun 2026 13:16:03 +0100 Subject: [PATCH 4/5] Restore original display mode gracefully on player exit Resetting the refresh rate fire-and-forget in onDestroy raced the activity teardown: the HDMI link retrained while the surface was being destroyed, which could black out pass-through soundbar/AVR chains until a power cycle. applyRefreshRate now returns the previous mode id, and finish() is overridden to switch back to that mode and wait for the change to settle (display listener + non-seamless delay) while the window is still alive, only then completing the finish. onDestroy keeps a fire-and-forget reset as a fallback. Co-Authored-By: Claude Opus 4.8 --- .../jknaapen/fladder/VideoPlayerActivity.kt | 38 +++++++++++++++- .../fladder/utility/RefreshRateHelper.kt | 45 +++++++++++++++++-- 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/VideoPlayerActivity.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/VideoPlayerActivity.kt index c0ac6a7d5..416bac299 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/VideoPlayerActivity.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/VideoPlayerActivity.kt @@ -12,7 +12,9 @@ import androidx.annotation.OptIn import androidx.annotation.RequiresApi import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.lifecycleScope import androidx.media3.common.util.UnstableApi +import kotlinx.coroutines.launch import nl.jknaapen.fladder.composables.controls.CustomVideoControls import nl.jknaapen.fladder.composables.overlays.screensavers.ScreenSaver import nl.jknaapen.fladder.objects.VideoPlayerObject @@ -21,8 +23,13 @@ import nl.jknaapen.fladder.utility.ScaledContent import nl.jknaapen.fladder.utility.applyRefreshRate import nl.jknaapen.fladder.utility.leanBackEnabled import nl.jknaapen.fladder.utility.resetRefreshRate +import nl.jknaapen.fladder.utility.resetRefreshRateAndWait class VideoPlayerActivity : ComponentActivity() { + // Display mode active before refresh rate matching switched it; restored on exit. + private var originalModeId: Int? = null + private var finishingWithModeReset = false + @RequiresApi(Build.VERSION_CODES.O) override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() @@ -45,7 +52,31 @@ class VideoPlayerActivity : ComponentActivity() { suspend fun applyVideoRefreshRate(videoWidth: Int, videoHeight: Int, frameRate: Float) { val displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager - applyRefreshRate(window, displayManager, videoWidth, videoHeight, frameRate) + val previousModeId = applyRefreshRate(window, displayManager, videoWidth, videoHeight, frameRate) + // Keep the first captured mode across episode transitions so we restore the true original. + if (previousModeId != null && originalModeId == null) { + originalModeId = previousModeId + } + } + + override fun finish() { + // Restore the original display mode and let the HDMI handshake settle while the window + // and video surface are still alive. Switching during teardown can leave pass-through + // soundbars/AVRs with a black screen that needs a power cycle. + val modeId = originalModeId + if (modeId == null || finishingWithModeReset) { + super.finish() + return + } + finishingWithModeReset = true + // Player may already be released when finishing via disposeActivity(). + runCatching { VideoPlayerObject.implementation.player?.playWhenReady = false } + val displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager + lifecycleScope.launch { + resetRefreshRateAndWait(window, displayManager, modeId) + originalModeId = null + super.finish() + } } override fun onPause() { @@ -55,7 +86,10 @@ class VideoPlayerActivity : ComponentActivity() { override fun onDestroy() { super.onDestroy() - resetRefreshRate(window) + // Fallback for destruction without finish(); normally the mode is already restored. + if (originalModeId != null) { + resetRefreshRate(window) + } VideoPlayerObject.currentActivity = null } } diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/RefreshRateHelper.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/RefreshRateHelper.kt index 76d232fcf..5fcd212bf 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/RefreshRateHelper.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/RefreshRateHelper.kt @@ -17,13 +17,18 @@ import kotlin.time.Duration.Companion.seconds private const val TAG = "RefreshRateHelper" +/** + * Switches the display to a mode matching the video, waiting for the switch to settle. + * Returns the mode id that was active before the switch, or null if no switch was performed, + * so the caller can restore it later via [resetRefreshRateAndWait]. + */ suspend fun applyRefreshRate( window: Window, displayManager: DisplayManager, videoWidth: Int, videoHeight: Int, frameRate: Float, -) = withContext(Dispatchers.IO) { +): Int? = withContext(Dispatchers.IO) { val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY) val displayModes = display.supportedModes .orEmpty() @@ -43,14 +48,46 @@ suspend fun applyRefreshRate( Log.d(TAG, "Video: ${videoWidth}x${videoHeight} @ ${frameRate}fps — target mode: $targetMode, current: $currentMode") - if (targetMode == null || targetMode.modeId == currentMode.modeId) return@withContext + if (targetMode == null || targetMode.modeId == currentMode.modeId) return@withContext null + + switchModeAndWait(window, displayManager, display, targetMode.modeId, targetMode.refreshRate, currentMode) + currentMode.modeId +} + +/** + * Restores the display mode that was active before [applyRefreshRate], waiting for the switch + * to settle. Must run while the window is still alive — switching during activity teardown + * races the HDMI re-handshake and can black out pass-through audio chains. + */ +suspend fun resetRefreshRateAndWait( + window: Window, + displayManager: DisplayManager, + originalModeId: Int, +) = withContext(Dispatchers.IO) { + val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY) + val currentMode = display.mode + if (currentMode.modeId == originalModeId) return@withContext + val targetMode = display.supportedModes.orEmpty().firstOrNull { it.modeId == originalModeId } + ?: return@withContext + + Log.d(TAG, "Restoring display mode $targetMode, current: $currentMode") + switchModeAndWait(window, displayManager, display, targetMode.modeId, targetMode.refreshRate, currentMode) +} +private suspend fun switchModeAndWait( + window: Window, + displayManager: DisplayManager, + display: Display, + targetModeId: Int, + targetRefreshRate: Float, + currentMode: Display.Mode, +) { val listener = DisplayChangeListener(display.displayId) displayManager.registerDisplayListener(listener, Handler(Looper.getMainLooper())) try { withContext(Dispatchers.Main) { val attrs = window.attributes - attrs.preferredDisplayModeId = targetMode.modeId + attrs.preferredDisplayModeId = targetModeId window.attributes = attrs } withTimeoutOrNull(5.seconds) { listener.deferred.await() } @@ -59,7 +96,7 @@ suspend fun applyRefreshRate( } // Wait for non-seamless switches (https://developer.android.com/media/optimize/performance/frame-rate) - val targetRateMillis = (targetMode.refreshRate * 1000).roundToInt() + val targetRateMillis = (targetRefreshRate * 1000).roundToInt() val currentRateMillis = (currentMode.refreshRate * 1000).roundToInt() val isSeamless = targetRateMillis == currentRateMillis || if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { From c36ec5d0261f09ad4348666402cd03b5496874e6 Mon Sep 17 00:00:00 2001 From: Lee Hudson Date: Fri, 12 Jun 2026 13:28:17 +0100 Subject: [PATCH 5/5] Remove Flutter migrator flags from gradle.properties android.builtInKotlin and android.newDsl were inserted by a newer Flutter checkout's migrator before the project was built with the pinned 3.35.7, which doesn't reference them. Co-Authored-By: Claude Opus 4.8 --- android/gradle.properties | 4 ---- 1 file changed, 4 deletions(-) diff --git a/android/gradle.properties b/android/gradle.properties index 4147ba381..259717082 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,7 +1,3 @@ org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true -# This builtInKotlin flag was added automatically by Flutter migrator -android.builtInKotlin=false -# This newDsl flag was added automatically by Flutter migrator -android.newDsl=false