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..416bac299 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 @@ -11,15 +12,24 @@ 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 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 +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() @@ -40,10 +50,48 @@ class VideoPlayerActivity : ComponentActivity() { } } + suspend fun applyVideoRefreshRate(videoWidth: Int, videoHeight: Int, frameRate: Float) { + val displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager + 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() { super.onPause() VideoPlayerObject.implementation.pause() } + + override fun onDestroy() { + super.onDestroy() + // Fallback for destruction without finish(); normally the mode is already restored. + if (originalModeId != null) { + 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..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,9 +119,27 @@ class VideoPlayerImplementation( if (startPosition > 0L) { player?.seekTo(startPosition) } - player?.playWhenReady = play callback(Result.success(true)) subsInitialized = false + + // 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) { + 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") 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..5fcd212bf --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/RefreshRateHelper.kt @@ -0,0 +1,183 @@ +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" + +/** + * 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, +): Int? = 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 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 = targetModeId + 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 = (targetRefreshRate * 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/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1892ce825..c03fc4331 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1712,6 +1712,8 @@ "posterColorsDesc": "Use generated colors for posters in place of the default color", "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 996491e8c..026a17b5e 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, @Default(false) bool ambientBlur, }) = _VideoPlayerSettingsModel; diff --git a/lib/models/settings/video_player_settings.freezed.dart b/lib/models/settings/video_player_settings.freezed.dart index 61568659c..1ce3f23f1 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; bool get ambientBlur; /// Create a copy of VideoPlayerSettingsModel @@ -88,12 +89,13 @@ mixin _$VideoPlayerSettingsModel implements DiagnosticableTreeMixin { ..add(DiagnosticsProperty('enablePlayPauseFade', enablePlayPauseFade)) ..add(DiagnosticsProperty('enableCrossfade', enableCrossfade)) ..add(DiagnosticsProperty('crossfadeDurationMs', crossfadeDurationMs)) + ..add(DiagnosticsProperty('refreshRateSwitching', refreshRateSwitching)) ..add(DiagnosticsProperty('ambientBlur', ambientBlur)); } @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, ambientBlur: $ambientBlur)'; + 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, ambientBlur: $ambientBlur)'; } } @@ -133,6 +135,7 @@ abstract mixin class $VideoPlayerSettingsModelCopyWith<$Res> { bool enablePlayPauseFade, bool enableCrossfade, int crossfadeDurationMs, + bool refreshRateSwitching, bool ambientBlur}); } @@ -177,6 +180,7 @@ class _$VideoPlayerSettingsModelCopyWithImpl<$Res> implements $VideoPlayerSettin Object? enablePlayPauseFade = null, Object? enableCrossfade = null, Object? crossfadeDurationMs = null, + Object? refreshRateSwitching = null, Object? ambientBlur = null, }) { return _then(_self.copyWith( @@ -296,6 +300,10 @@ class _$VideoPlayerSettingsModelCopyWithImpl<$Res> implements $VideoPlayerSettin ? _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, ambientBlur: null == ambientBlur ? _self.ambientBlur : ambientBlur // ignore: cast_nullable_to_non_nullable @@ -427,6 +435,7 @@ extension VideoPlayerSettingsModelPatterns on VideoPlayerSettingsModel { bool enablePlayPauseFade, bool enableCrossfade, int crossfadeDurationMs, + bool refreshRateSwitching, bool ambientBlur)? $default, { required TResult orElse(), @@ -464,6 +473,7 @@ extension VideoPlayerSettingsModelPatterns on VideoPlayerSettingsModel { _that.enablePlayPauseFade, _that.enableCrossfade, _that.crossfadeDurationMs, + _that.refreshRateSwitching, _that.ambientBlur); case _: return orElse(); @@ -515,6 +525,7 @@ extension VideoPlayerSettingsModelPatterns on VideoPlayerSettingsModel { bool enablePlayPauseFade, bool enableCrossfade, int crossfadeDurationMs, + bool refreshRateSwitching, bool ambientBlur) $default, ) { @@ -551,6 +562,7 @@ extension VideoPlayerSettingsModelPatterns on VideoPlayerSettingsModel { _that.enablePlayPauseFade, _that.enableCrossfade, _that.crossfadeDurationMs, + _that.refreshRateSwitching, _that.ambientBlur); case _: throw StateError('Unexpected subclass'); @@ -601,6 +613,7 @@ extension VideoPlayerSettingsModelPatterns on VideoPlayerSettingsModel { bool enablePlayPauseFade, bool enableCrossfade, int crossfadeDurationMs, + bool refreshRateSwitching, bool ambientBlur)? $default, ) { @@ -637,6 +650,7 @@ extension VideoPlayerSettingsModelPatterns on VideoPlayerSettingsModel { _that.enablePlayPauseFade, _that.enableCrossfade, _that.crossfadeDurationMs, + _that.refreshRateSwitching, _that.ambientBlur); case _: return null; @@ -677,6 +691,7 @@ class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel with Diagnostic this.enablePlayPauseFade = true, this.enableCrossfade = true, this.crossfadeDurationMs = 400, + this.refreshRateSwitching = false, this.ambientBlur = false}) : _allowedOrientations = allowedOrientations, _segmentSkipSettings = segmentSkipSettings, @@ -789,6 +804,9 @@ class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel with Diagnostic final int crossfadeDurationMs; @override @JsonKey() + final bool refreshRateSwitching; + @override + @JsonKey() final bool ambientBlur; /// Create a copy of VideoPlayerSettingsModel @@ -839,12 +857,13 @@ class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel with Diagnostic ..add(DiagnosticsProperty('enablePlayPauseFade', enablePlayPauseFade)) ..add(DiagnosticsProperty('enableCrossfade', enableCrossfade)) ..add(DiagnosticsProperty('crossfadeDurationMs', crossfadeDurationMs)) + ..add(DiagnosticsProperty('refreshRateSwitching', refreshRateSwitching)) ..add(DiagnosticsProperty('ambientBlur', ambientBlur)); } @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, ambientBlur: $ambientBlur)'; + 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, ambientBlur: $ambientBlur)'; } } @@ -885,6 +904,7 @@ abstract mixin class _$VideoPlayerSettingsModelCopyWith<$Res> implements $VideoP bool enablePlayPauseFade, bool enableCrossfade, int crossfadeDurationMs, + bool refreshRateSwitching, bool ambientBlur}); } @@ -929,6 +949,7 @@ class __$VideoPlayerSettingsModelCopyWithImpl<$Res> implements _$VideoPlayerSett Object? enablePlayPauseFade = null, Object? enableCrossfade = null, Object? crossfadeDurationMs = null, + Object? refreshRateSwitching = null, Object? ambientBlur = null, }) { return _then(_VideoPlayerSettingsModel( @@ -1048,6 +1069,10 @@ class __$VideoPlayerSettingsModelCopyWithImpl<$Res> implements _$VideoPlayerSett ? _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, ambientBlur: null == ambientBlur ? _self.ambientBlur : ambientBlur // ignore: cast_nullable_to_non_nullable diff --git a/lib/models/settings/video_player_settings.g.dart b/lib/models/settings/video_player_settings.g.dart index ea3c322c1..31924cc5e 100644 --- a/lib/models/settings/video_player_settings.g.dart +++ b/lib/models/settings/video_player_settings.g.dart @@ -46,6 +46,7 @@ _VideoPlayerSettingsModel _$VideoPlayerSettingsModelFromJson(Map _$VideoPlayerSettingsModelToJson(_VideoPlayerSettingsModel 'enablePlayPauseFade': instance.enablePlayPauseFade, 'enableCrossfade': instance.enableCrossfade, 'crossfadeDurationMs': instance.crossfadeDurationMs, + 'refreshRateSwitching': instance.refreshRateSwitching, 'ambientBlur': instance.ambientBlur, }; 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 4a7e50b90..d5fb03ed3 100644 --- a/lib/providers/settings/video_player_settings_provider.dart +++ b/lib/providers/settings/video_player_settings_provider.dart @@ -171,6 +171,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 4b4abc93f..b6d89562f 100644 --- a/lib/screens/settings/player_settings_page.dart +++ b/lib/screens/settings/player_settings_page.dart @@ -428,6 +428,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/screens/video_player/components/video_player_next_wrapper.dart b/lib/screens/video_player/components/video_player_next_wrapper.dart index 529a467e4..9e72295c9 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 b6091af35..e4ea85697 100644 --- a/lib/screens/video_player/video_player.dart +++ b/lib/screens/video_player/video_player.dart @@ -95,13 +95,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 6a556751f..e50f4d5db 100644 --- a/lib/screens/video_player/video_player_controls.dart +++ b/lib/screens/video_player/video_player_controls.dart @@ -778,7 +778,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 { 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 ffa249336..79517e9a5 100644 --- a/lib/wrappers/players/native_player.dart +++ b/lib/wrappers/players/native_player.dart @@ -213,6 +213,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, }); } diff --git a/test/video_player_settings_test.dart b/test/video_player_settings_test.dart new file mode 100644 index 000000000..547fccf56 --- /dev/null +++ b/test/video_player_settings_test.dart @@ -0,0 +1,31 @@ +import 'package:fladder/models/settings/video_player_settings.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('older settings default refresh rate switching and ambient blur to off', () { + final settings = VideoPlayerSettingsModel.fromJson({}); + + expect(settings.refreshRateSwitching, isFalse); + expect(settings.ambientBlur, isFalse); + }); + + test('refresh rate switching and ambient blur persist independently', () { + for (final refreshRateSwitching in [false, true]) { + for (final ambientBlur in [false, true]) { + final settings = VideoPlayerSettingsModel( + refreshRateSwitching: refreshRateSwitching, + ambientBlur: ambientBlur, + ); + final restored = VideoPlayerSettingsModel.fromJson(settings.toJson()); + + expect(restored.refreshRateSwitching, refreshRateSwitching); + expect(restored.ambientBlur, ambientBlur); + expect(restored.copyWith(refreshRateSwitching: !refreshRateSwitching).ambientBlur, ambientBlur); + expect(restored.copyWith(ambientBlur: !ambientBlur).refreshRateSwitching, refreshRateSwitching); + expect( + restored.copyWith(refreshRateSwitching: !refreshRateSwitching).refreshRateSwitching, !refreshRateSwitching); + expect(restored.copyWith(ambientBlur: !ambientBlur).ambientBlur, !ambientBlur); + } + } + }); +}