Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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()
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,8 @@ data class PlayerSettings (
val acceptedOrientations: List<PlayerOrientations>,
val fillScreen: Boolean,
val videoFit: VideoPlayerFit,
val screensaver: Screensaver
val screensaver: Screensaver,
val refreshRateSwitching: Boolean
)
{
companion object {
Expand All @@ -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<Any?> {
Expand All @@ -188,6 +190,7 @@ data class PlayerSettings (
fillScreen,
videoFit,
screensaver,
refreshRateSwitching,
)
}
override fun equals(other: Any?): Boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<Any?> {
Expand All @@ -234,6 +240,9 @@ data class PlayableData (
nextVideo,
mediaInfo,
url,
videoWidth,
videoHeight,
videoFrameRate,
)
}
override fun equals(other: Any?): Boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -105,6 +109,7 @@ class VideoPlayerImplementation(

val mediaItem = mediaItemBuilder.build()

player?.playWhenReady = false
player?.stop()
player?.clearMediaItems()
player?.setMediaItem(mediaItem)
Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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<RefreshRateDisplayMode> { 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<RefreshRateDisplayMode>,
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<Unit>()
override fun onDisplayAdded(displayId: Int) {}
override fun onDisplayRemoved(displayId: Int) {}
override fun onDisplayChanged(displayId: Int) {
if (displayId == this.displayId) deferred.complete(Unit)
}
}
2 changes: 2 additions & 0 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down
1 change: 1 addition & 0 deletions lib/models/settings/video_player_settings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading