diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt index e74082aa..bef7b020 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt @@ -1,16 +1,26 @@ package com.edde746.plezy.mpv import android.app.Activity +import android.content.Context import android.graphics.Color import android.graphics.PixelFormat +import android.hardware.display.DisplayManager +import android.os.Build +import android.os.Handler +import android.os.Looper import android.util.Log +import android.view.Surface import android.view.SurfaceHolder import android.view.SurfaceView import android.view.View import android.view.ViewGroup import android.view.ViewTreeObserver import android.view.TextureView +import android.view.WindowManager +import androidx.annotation.RequiresApi import dev.jdtech.mpv.MPVLib +import java.math.BigDecimal +import java.math.RoundingMode interface MpvPlayerDelegate { fun onPropertyChange(name: String, value: Any?) @@ -24,6 +34,7 @@ class MpvPlayerCore(private val activity: Activity) : companion object { private const val TAG = "MpvPlayerCore" + private const val SHORT_VIDEO_LENGTH_MS = 300000L // 5 minutes } private var surfaceView: SurfaceView? = null @@ -34,6 +45,11 @@ class MpvPlayerCore(private val activity: Activity) : var isInitialized: Boolean = false private set + // Frame rate matching + private var currentVideoFps: Float = 0f + private var displayListener: DisplayManager.DisplayListener? = null + private val handler = Handler(Looper.getMainLooper()) + private fun ensureFlutterOverlayOnTop() { val contentView = activity.findViewById(android.R.id.content) contentView.post { @@ -300,11 +316,203 @@ class MpvPlayerCore(private val activity: Activity) : } } + // Frame Rate Matching + + private fun getDisplayManager(): DisplayManager { + return activity.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager + } + + /** + * Set the video frame rate for display refresh rate matching. + * Based on VLC Android's FrameRateManager implementation. + */ + fun setVideoFrameRate(fps: Float, videoDurationMs: Long) { + currentVideoFps = fps + if (fps <= 0f) { + Log.d(TAG, "setVideoFrameRate: Invalid fps ($fps), skipping") + return + } + + val surface = surfaceView?.holder?.surface + if (surface == null) { + Log.d(TAG, "setVideoFrameRate: Surface not available") + return + } + + Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=${videoDurationMs}ms, API=${Build.VERSION.SDK_INT}") + + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> setFrameRateS(fps, surface, videoDurationMs) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> setFrameRateR(fps, surface) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> setFrameRateM(fps) + } + } + + /** + * Clear frame rate setting and cleanup display listener. + */ + fun clearVideoFrameRate() { + Log.d(TAG, "clearVideoFrameRate") + currentVideoFps = 0f + displayListener?.let { + getDisplayManager().unregisterDisplayListener(it) + displayListener = null + } + } + + /** + * Create and register display listener for mode switch completion. + * Resumes playback after display mode change (needed for HDMI/projectors). + */ + private fun registerDisplayListener() { + displayListener?.let { + getDisplayManager().unregisterDisplayListener(it) + } + + displayListener = object : DisplayManager.DisplayListener { + override fun onDisplayAdded(displayId: Int) = Unit + override fun onDisplayRemoved(displayId: Int) = Unit + override fun onDisplayChanged(displayId: Int) { + // Mode switch may pause playback (HDMI), wait and resume + handler.postDelayed({ + try { + val isPaused = MPVLib.getPropertyBoolean("pause") + if (isPaused) { + Log.d(TAG, "Display changed, resuming playback") + MPVLib.setPropertyBoolean("pause", false) + } + } catch (e: Exception) { + Log.w(TAG, "Failed to resume playback after display change", e) + } + }, 2000L) // Wait 2 seconds for mode switch to complete + getDisplayManager().unregisterDisplayListener(this) + displayListener = null + } + } + getDisplayManager().registerDisplayListener(displayListener, handler) + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun setFrameRateR(fps: Float, surface: Surface) { + Log.d(TAG, "setFrameRateR: Setting frame rate to $fps") + surface.setFrameRate(fps, Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE) + registerDisplayListener() + } + + @RequiresApi(Build.VERSION_CODES.S) + private fun setFrameRateS(fps: Float, surface: Surface, videoDurationMs: Long) { + Log.d(TAG, "setFrameRateS: fps=$fps, duration=${videoDurationMs}ms") + + // For short videos (<5min), only switch if seamless + if (videoDurationMs < SHORT_VIDEO_LENGTH_MS) { + Log.d(TAG, "Short video, using seamless-only switching") + surface.setFrameRate( + fps, + Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, + Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS + ) + return + } + + // For longer videos, check if switch will be seamless + var seamless = false + activity.display?.mode?.alternativeRefreshRates?.let { refreshRates -> + for (rate in refreshRates) { + // Check if rates match or are integer multiples + if (fps.toString().startsWith(rate.toString()) || + rate.toString().startsWith(fps.toString()) || + rate % fps == 0f) { + seamless = true + break + } + } + } + + if (seamless) { + Log.d(TAG, "Seamless switch available, using CHANGE_FRAME_RATE_ALWAYS") + surface.setFrameRate( + fps, + Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, + Surface.CHANGE_FRAME_RATE_ALWAYS + ) + registerDisplayListener() + } else { + // Non-seamless: only switch if user enabled it at OS level + val userPreference = getDisplayManager().matchContentFrameRateUserPreference + if (userPreference == DisplayManager.MATCH_CONTENT_FRAMERATE_ALWAYS) { + Log.d(TAG, "User preference allows non-seamless switch") + surface.setFrameRate( + fps, + Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, + Surface.CHANGE_FRAME_RATE_ALWAYS + ) + registerDisplayListener() + } else { + Log.d(TAG, "Non-seamless switch not allowed by user preference, using seamless-only") + surface.setFrameRate( + fps, + Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, + Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS + ) + } + } + } + + @RequiresApi(Build.VERSION_CODES.M) + private fun setFrameRateM(fps: Float) { + Log.d(TAG, "setFrameRateM: fps=$fps") + val wm = activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager + val display = wm.defaultDisplay ?: return + + display.supportedModes?.let { supportedModes -> + val currentMode = display.mode + var modeToUse = currentMode + + for (mode in supportedModes) { + // Skip modes with different resolution + if (mode.physicalHeight != currentMode.physicalHeight || + mode.physicalWidth != currentMode.physicalWidth) { + continue + } + + Log.d(TAG, "Supported mode: ${mode.modeId} - ${mode.refreshRate}Hz") + + // Check for exact match + if (BigDecimal(fps.toString()).setScale(1, RoundingMode.FLOOR) == + BigDecimal(mode.refreshRate.toString()).setScale(1, RoundingMode.FLOOR)) { + modeToUse = mode + Log.d(TAG, "Found exact match: ${mode.refreshRate}Hz") + break + } + // Check for integer multiple (e.g., 48Hz for 24fps) + else if (mode.refreshRate % fps == 0f) { + modeToUse = mode + Log.d(TAG, "Found integer multiple: ${mode.refreshRate}Hz") + break + } + } + + if (modeToUse != currentMode) { + Log.d(TAG, "Switching to mode ${modeToUse.modeId} (${modeToUse.refreshRate}Hz)") + activity.window?.attributes?.let { attrs -> + attrs.preferredDisplayModeId = modeToUse.modeId + activity.window?.attributes = attrs + } + registerDisplayListener() + } else { + Log.d(TAG, "No better mode found, staying at ${currentMode.refreshRate}Hz") + } + } + } + // Cleanup fun dispose() { Log.d(TAG, "Disposing") + // Clean up frame rate listener + clearVideoFrameRate() + MPVLib.removeObserver(this) MPVLib.removeLogObserver(this) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt index ec91e944..f5d57ae0 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt @@ -94,6 +94,8 @@ class MpvPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, "observeProperty" -> handleObserveProperty(call, result) "command" -> handleCommand(call, result) "setVisible" -> handleSetVisible(call, result) + "setVideoFrameRate" -> handleSetVideoFrameRate(call, result) + "clearVideoFrameRate" -> handleClearVideoFrameRate(result) "isInitialized" -> result.success(playerCore?.isInitialized ?: false) else -> result.notImplemented() } @@ -202,6 +204,21 @@ class MpvPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, result.success(null) } + private fun handleSetVideoFrameRate(call: MethodCall, result: MethodChannel.Result) { + val fps = call.argument("fps")?.toFloat() ?: 0f + val duration = call.argument("duration")?.toLong() ?: 0L + + Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration") + playerCore?.setVideoFrameRate(fps, duration) + result.success(null) + } + + private fun handleClearVideoFrameRate(result: MethodChannel.Result) { + Log.d(TAG, "clearVideoFrameRate") + playerCore?.clearVideoFrameRate() + result.success(null) + } + // MpvPlayerDelegate override fun onPropertyChange(name: String, value: Any?) { diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 3107e3c5..ce6d017b 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 7 -/// Strings: 3603 (514 per locale) +/// Strings: 3617 (516 per locale) /// -/// Built on 2026-01-08 at 05:05 UTC +/// Built on 2026-01-09 at 12:57 UTC // coverage:ignore-file // ignore_for_file: type=lint @@ -385,6 +385,8 @@ class _StringsSettingsEn { String get maxVolumeHint => 'Enter max volume (100-300)'; String get discordRichPresence => 'Discord Rich Presence'; String get discordRichPresenceDescription => 'Show what you\'re watching on Discord'; + String get matchContentFrameRate => 'Match Content Frame Rate'; + String get matchContentFrameRateDescription => 'Adjust display refresh rate to match video content, reducing judder and saving battery'; } // Path: search @@ -1265,6 +1267,8 @@ class _StringsSettingsDe implements _StringsSettingsEn { @override String get maxVolumeHint => 'Maximale Lautstärke eingeben (100-300)'; @override String get discordRichPresence => 'Discord Rich Presence'; @override String get discordRichPresenceDescription => 'Zeige auf Discord, was du gerade schaust'; + @override String get matchContentFrameRate => 'Inhalts-Bildrate anpassen'; + @override String get matchContentFrameRateDescription => 'Bildwiederholfrequenz des Displays an den Videoinhalt anpassen, reduziert Ruckeln und spart Akku'; } // Path: search @@ -2146,6 +2150,8 @@ class _StringsSettingsIt implements _StringsSettingsEn { @override String get maxVolumeHint => 'Inserisci volume massimo (100-300)'; @override String get discordRichPresence => 'Discord Rich Presence'; @override String get discordRichPresenceDescription => 'Mostra su Discord cosa stai guardando'; + @override String get matchContentFrameRate => 'Adatta frequenza fotogrammi'; + @override String get matchContentFrameRateDescription => 'Regola la frequenza di aggiornamento del display in base al contenuto video, riducendo i tremolii e risparmiando batteria'; } // Path: search @@ -3027,6 +3033,8 @@ class _StringsSettingsKo implements _StringsSettingsEn { @override String get maxVolumeHint => '최대 볼륨 입력 (100-300)'; @override String get discordRichPresence => 'Discord Rich Presence'; @override String get discordRichPresenceDescription => 'Discord에서 시청 중인 콘텐츠 표시'; + @override String get matchContentFrameRate => '콘텐츠 프레임 레이트 맞춤'; + @override String get matchContentFrameRateDescription => '비디오 콘텐츠에 맞게 디스플레이 주사율을 조정하여 떨림을 줄이고 배터리를 절약합니다'; } // Path: search @@ -3908,6 +3916,8 @@ class _StringsSettingsNl implements _StringsSettingsEn { @override String get maxVolumeHint => 'Voer maximaal volume in (100-300)'; @override String get discordRichPresence => 'Discord Rich Presence'; @override String get discordRichPresenceDescription => 'Toon op Discord wat je aan het kijken bent'; + @override String get matchContentFrameRate => 'Inhoudsframesnelheid afstemmen'; + @override String get matchContentFrameRateDescription => 'Pas de schermverversingssnelheid aan op de video-inhoud, vermindert haperingen en bespaart batterij'; } // Path: search @@ -4789,6 +4799,8 @@ class _StringsSettingsSv implements _StringsSettingsEn { @override String get maxVolumeHint => 'Ange maximal volym (100-300)'; @override String get discordRichPresence => 'Discord Rich Presence'; @override String get discordRichPresenceDescription => 'Visa vad du tittar på i Discord'; + @override String get matchContentFrameRate => 'Matcha innehållets bildfrekvens'; + @override String get matchContentFrameRateDescription => 'Justera skärmens uppdateringsfrekvens för att matcha videoinnehållet, minskar hackighet och sparar batteri'; } // Path: search @@ -5670,6 +5682,8 @@ class _StringsSettingsZh implements _StringsSettingsEn { @override String get maxVolumeHint => '输入最大音量 (100-300)'; @override String get discordRichPresence => 'Discord 动态状态'; @override String get discordRichPresenceDescription => '在 Discord 上显示您正在观看的内容'; + @override String get matchContentFrameRate => '匹配内容帧率'; + @override String get matchContentFrameRateDescription => '调整显示刷新率以匹配视频内容,减少画面抖动并节省电量'; } // Path: search @@ -6446,6 +6460,8 @@ extension on Translations { case 'settings.maxVolumeHint': return 'Enter max volume (100-300)'; case 'settings.discordRichPresence': return 'Discord Rich Presence'; case 'settings.discordRichPresenceDescription': return 'Show what you\'re watching on Discord'; + case 'settings.matchContentFrameRate': return 'Match Content Frame Rate'; + case 'settings.matchContentFrameRateDescription': return 'Adjust display refresh rate to match video content, reducing judder and saving battery'; case 'search.hint': return 'Search movies, shows, music...'; case 'search.tryDifferentTerm': return 'Try a different search term'; case 'search.searchYourMedia': return 'Search your media'; @@ -6968,6 +6984,8 @@ extension on _StringsDe { case 'settings.maxVolumeHint': return 'Maximale Lautstärke eingeben (100-300)'; case 'settings.discordRichPresence': return 'Discord Rich Presence'; case 'settings.discordRichPresenceDescription': return 'Zeige auf Discord, was du gerade schaust'; + case 'settings.matchContentFrameRate': return 'Inhalts-Bildrate anpassen'; + case 'settings.matchContentFrameRateDescription': return 'Bildwiederholfrequenz des Displays an den Videoinhalt anpassen, reduziert Ruckeln und spart Akku'; case 'search.hint': return 'Filme, Serien, Musik suchen...'; case 'search.tryDifferentTerm': return 'Anderen Suchbegriff versuchen'; case 'search.searchYourMedia': return 'In den eigenen Medien suchen'; @@ -7491,6 +7509,8 @@ extension on _StringsIt { case 'settings.maxVolumeHint': return 'Inserisci volume massimo (100-300)'; case 'settings.discordRichPresence': return 'Discord Rich Presence'; case 'settings.discordRichPresenceDescription': return 'Mostra su Discord cosa stai guardando'; + case 'settings.matchContentFrameRate': return 'Adatta frequenza fotogrammi'; + case 'settings.matchContentFrameRateDescription': return 'Regola la frequenza di aggiornamento del display in base al contenuto video, riducendo i tremolii e risparmiando batteria'; case 'search.hint': return 'Cerca film. spettacoli, musica...'; case 'search.tryDifferentTerm': return 'Prova altri termini di ricerca'; case 'search.searchYourMedia': return 'Cerca nei tuoi media'; @@ -8014,6 +8034,8 @@ extension on _StringsKo { case 'settings.maxVolumeHint': return '최대 볼륨 입력 (100-300)'; case 'settings.discordRichPresence': return 'Discord Rich Presence'; case 'settings.discordRichPresenceDescription': return 'Discord에서 시청 중인 콘텐츠 표시'; + case 'settings.matchContentFrameRate': return '콘텐츠 프레임 레이트 맞춤'; + case 'settings.matchContentFrameRateDescription': return '비디오 콘텐츠에 맞게 디스플레이 주사율을 조정하여 떨림을 줄이고 배터리를 절약합니다'; case 'search.hint': return '영화, 시리즈, 음악 등을 검색하세요...'; case 'search.tryDifferentTerm': return '다른 검색어를 시도해 보세요'; case 'search.searchYourMedia': return '미디어 검색'; @@ -8537,6 +8559,8 @@ extension on _StringsNl { case 'settings.maxVolumeHint': return 'Voer maximaal volume in (100-300)'; case 'settings.discordRichPresence': return 'Discord Rich Presence'; case 'settings.discordRichPresenceDescription': return 'Toon op Discord wat je aan het kijken bent'; + case 'settings.matchContentFrameRate': return 'Inhoudsframesnelheid afstemmen'; + case 'settings.matchContentFrameRateDescription': return 'Pas de schermverversingssnelheid aan op de video-inhoud, vermindert haperingen en bespaart batterij'; case 'search.hint': return 'Zoek films, series, muziek...'; case 'search.tryDifferentTerm': return 'Probeer een andere zoekterm'; case 'search.searchYourMedia': return 'Zoek in je media'; @@ -9060,6 +9084,8 @@ extension on _StringsSv { case 'settings.maxVolumeHint': return 'Ange maximal volym (100-300)'; case 'settings.discordRichPresence': return 'Discord Rich Presence'; case 'settings.discordRichPresenceDescription': return 'Visa vad du tittar på i Discord'; + case 'settings.matchContentFrameRate': return 'Matcha innehållets bildfrekvens'; + case 'settings.matchContentFrameRateDescription': return 'Justera skärmens uppdateringsfrekvens för att matcha videoinnehållet, minskar hackighet och sparar batteri'; case 'search.hint': return 'Sök filmer, serier, musik...'; case 'search.tryDifferentTerm': return 'Prova en annan sökterm'; case 'search.searchYourMedia': return 'Sök i dina media'; @@ -9583,6 +9609,8 @@ extension on _StringsZh { case 'settings.maxVolumeHint': return '输入最大音量 (100-300)'; case 'settings.discordRichPresence': return 'Discord 动态状态'; case 'settings.discordRichPresenceDescription': return '在 Discord 上显示您正在观看的内容'; + case 'settings.matchContentFrameRate': return '匹配内容帧率'; + case 'settings.matchContentFrameRateDescription': return '调整显示刷新率以匹配视频内容,减少画面抖动并节省电量'; case 'search.hint': return '搜索电影、系列、音乐...'; case 'search.tryDifferentTerm': return '尝试不同的搜索词'; case 'search.searchYourMedia': return '搜索媒体'; diff --git a/lib/i18n/strings.i18n.json b/lib/i18n/strings.i18n.json index ff885723..2d2baa7d 100644 --- a/lib/i18n/strings.i18n.json +++ b/lib/i18n/strings.i18n.json @@ -155,7 +155,9 @@ "maxVolumePercent": "${percent}%", "maxVolumeHint": "Enter max volume (100-300)", "discordRichPresence": "Discord Rich Presence", - "discordRichPresenceDescription": "Show what you're watching on Discord" + "discordRichPresenceDescription": "Show what you're watching on Discord", + "matchContentFrameRate": "Match Content Frame Rate", + "matchContentFrameRateDescription": "Adjust display refresh rate to match video content, reducing judder and saving battery" }, "search": { "hint": "Search movies, shows, music...", diff --git a/lib/i18n/strings_de.i18n.json b/lib/i18n/strings_de.i18n.json index ad3574f5..9c08c119 100644 --- a/lib/i18n/strings_de.i18n.json +++ b/lib/i18n/strings_de.i18n.json @@ -156,7 +156,9 @@ "maxVolumePercent": "${percent}%", "maxVolumeHint": "Maximale Lautstärke eingeben (100-300)", "discordRichPresence": "Discord Rich Presence", - "discordRichPresenceDescription": "Zeige auf Discord, was du gerade schaust" + "discordRichPresenceDescription": "Zeige auf Discord, was du gerade schaust", + "matchContentFrameRate": "Inhalts-Bildrate anpassen", + "matchContentFrameRateDescription": "Bildwiederholfrequenz des Displays an den Videoinhalt anpassen, reduziert Ruckeln und spart Akku" }, "search": { "hint": "Filme, Serien, Musik suchen...", diff --git a/lib/i18n/strings_it.i18n.json b/lib/i18n/strings_it.i18n.json index ea832d5b..42a61d20 100644 --- a/lib/i18n/strings_it.i18n.json +++ b/lib/i18n/strings_it.i18n.json @@ -156,7 +156,9 @@ "maxVolumePercent": "${percent}%", "maxVolumeHint": "Inserisci volume massimo (100-300)", "discordRichPresence": "Discord Rich Presence", - "discordRichPresenceDescription": "Mostra su Discord cosa stai guardando" + "discordRichPresenceDescription": "Mostra su Discord cosa stai guardando", + "matchContentFrameRate": "Adatta frequenza fotogrammi", + "matchContentFrameRateDescription": "Regola la frequenza di aggiornamento del display in base al contenuto video, riducendo i tremolii e risparmiando batteria" }, "search": { "hint": "Cerca film. spettacoli, musica...", diff --git a/lib/i18n/strings_ko.i18n.json b/lib/i18n/strings_ko.i18n.json index 76ea192e..91eaaaa0 100644 --- a/lib/i18n/strings_ko.i18n.json +++ b/lib/i18n/strings_ko.i18n.json @@ -156,7 +156,9 @@ "maxVolumePercent": "${percent}%", "maxVolumeHint": "최대 볼륨 입력 (100-300)", "discordRichPresence": "Discord Rich Presence", - "discordRichPresenceDescription": "Discord에서 시청 중인 콘텐츠 표시" + "discordRichPresenceDescription": "Discord에서 시청 중인 콘텐츠 표시", + "matchContentFrameRate": "콘텐츠 프레임 레이트 맞춤", + "matchContentFrameRateDescription": "비디오 콘텐츠에 맞게 디스플레이 주사율을 조정하여 떨림을 줄이고 배터리를 절약합니다" }, "search": { "hint": "영화, 시리즈, 음악 등을 검색하세요...", diff --git a/lib/i18n/strings_nl.i18n.json b/lib/i18n/strings_nl.i18n.json index b38454be..23eaeaf9 100644 --- a/lib/i18n/strings_nl.i18n.json +++ b/lib/i18n/strings_nl.i18n.json @@ -156,7 +156,9 @@ "maxVolumePercent": "${percent}%", "maxVolumeHint": "Voer maximaal volume in (100-300)", "discordRichPresence": "Discord Rich Presence", - "discordRichPresenceDescription": "Toon op Discord wat je aan het kijken bent" + "discordRichPresenceDescription": "Toon op Discord wat je aan het kijken bent", + "matchContentFrameRate": "Inhoudsframesnelheid afstemmen", + "matchContentFrameRateDescription": "Pas de schermverversingssnelheid aan op de video-inhoud, vermindert haperingen en bespaart batterij" }, "search": { "hint": "Zoek films, series, muziek...", diff --git a/lib/i18n/strings_sv.i18n.json b/lib/i18n/strings_sv.i18n.json index 004a7986..be1fc155 100644 --- a/lib/i18n/strings_sv.i18n.json +++ b/lib/i18n/strings_sv.i18n.json @@ -156,7 +156,9 @@ "maxVolumePercent": "${percent}%", "maxVolumeHint": "Ange maximal volym (100-300)", "discordRichPresence": "Discord Rich Presence", - "discordRichPresenceDescription": "Visa vad du tittar på i Discord" + "discordRichPresenceDescription": "Visa vad du tittar på i Discord", + "matchContentFrameRate": "Matcha innehållets bildfrekvens", + "matchContentFrameRateDescription": "Justera skärmens uppdateringsfrekvens för att matcha videoinnehållet, minskar hackighet och sparar batteri" }, "search": { "hint": "Sök filmer, serier, musik...", diff --git a/lib/i18n/strings_zh.i18n.json b/lib/i18n/strings_zh.i18n.json index 40379317..599025da 100644 --- a/lib/i18n/strings_zh.i18n.json +++ b/lib/i18n/strings_zh.i18n.json @@ -156,7 +156,9 @@ "maxVolumePercent": "${percent}%", "maxVolumeHint": "输入最大音量 (100-300)", "discordRichPresence": "Discord 动态状态", - "discordRichPresenceDescription": "在 Discord 上显示您正在观看的内容" + "discordRichPresenceDescription": "在 Discord 上显示您正在观看的内容", + "matchContentFrameRate": "匹配内容帧率", + "matchContentFrameRateDescription": "调整显示刷新率以匹配视频内容,减少画面抖动并节省电量" }, "search": { "hint": "搜索电影、系列、音乐...", diff --git a/lib/mpv/player/player.dart b/lib/mpv/player/player.dart index 7e55fffd..aa8ad570 100644 --- a/lib/mpv/player/player.dart +++ b/lib/mpv/player/player.dart @@ -170,6 +170,28 @@ abstract class Player { /// On other platforms, this is a no-op. Future updateFrame(); + // ============================================ + // Frame Rate Matching (Android) + // ============================================ + + /// Set the video frame rate for display refresh rate matching. + /// + /// On Android, this hints the system to adjust the display refresh rate + /// to match the video content's frame rate, reducing judder and saving + /// battery on LTPO displays. + /// + /// [fps] - The video frame rate (e.g., 23.976, 24, 30, 60). + /// [durationMs] - The video duration in milliseconds. + /// + /// On other platforms, this is a no-op. + Future setVideoFrameRate(double fps, int durationMs); + + /// Clear the video frame rate hint and restore default display mode. + /// + /// Call this when playback ends to restore the normal display refresh rate. + /// On other platforms, this is a no-op. + Future clearVideoFrameRate(); + // ============================================ // Lifecycle // ============================================ diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index 358ea9af..0a78714e 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -580,6 +580,28 @@ class PlayerNative implements Player { } } + // ============================================ + // Frame Rate Matching (Android) + // ============================================ + + @override + Future setVideoFrameRate(double fps, int durationMs) async { + _checkDisposed(); + if (!Platform.isAndroid) return; + if (!_initialized) return; + + await _methodChannel.invokeMethod('setVideoFrameRate', {'fps': fps, 'duration': durationMs}); + } + + @override + Future clearVideoFrameRate() async { + _checkDisposed(); + if (!Platform.isAndroid) return; + if (!_initialized) return; + + await _methodChannel.invokeMethod('clearVideoFrameRate'); + } + // ============================================ // Lifecycle // ============================================ diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index a39bb25f..5131d493 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -63,6 +63,7 @@ class _SettingsScreenState extends State { bool _videoPlayerNavigationEnabled = false; int _maxVolume = 100; bool _enableDiscordRPC = false; + bool _matchContentFrameRate = false; // Update checking state bool _isCheckingForUpdate = false; @@ -95,6 +96,7 @@ class _SettingsScreenState extends State { _videoPlayerNavigationEnabled = _settingsService.getVideoPlayerNavigationEnabled(); _maxVolume = _settingsService.getMaxVolume(); _enableDiscordRPC = _settingsService.getEnableDiscordRPC(); + _matchContentFrameRate = _settingsService.getMatchContentFrameRate(); _isLoading = false; }); } @@ -253,6 +255,19 @@ class _SettingsScreenState extends State { await _settingsService.setEnableHardwareDecoding(value); }, ), + if (Platform.isAndroid) + SwitchListTile( + secondary: const AppIcon(Symbols.display_settings_rounded, fill: 1), + title: Text(t.settings.matchContentFrameRate), + subtitle: Text(t.settings.matchContentFrameRateDescription), + value: _matchContentFrameRate, + onChanged: (value) async { + setState(() { + _matchContentFrameRate = value; + }); + await _settingsService.setMatchContentFrameRate(value); + }, + ), ListTile( leading: const AppIcon(Symbols.memory_rounded, fill: 1), title: Text(t.settings.bufferSize), diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 02c02e3e..06d9507b 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -398,9 +398,14 @@ class VideoPlayerScreenState extends State with WidgetsBindin }); // Listen to playback restart to detect first frame ready - _playbackRestartSubscription = player!.streams.playbackRestart.listen((_) { + _playbackRestartSubscription = player!.streams.playbackRestart.listen((_) async { if (!_hasFirstFrame.value) { _hasFirstFrame.value = true; + + // Apply frame rate matching on Android if enabled + if (Platform.isAndroid && settingsService.getMatchContentFrameRate()) { + await _applyFrameRateMatching(); + } } }); @@ -434,6 +439,44 @@ class VideoPlayerScreenState extends State with WidgetsBindin } } + /// Apply frame rate matching on Android by setting the display refresh rate + /// to match the video content's frame rate. + Future _applyFrameRateMatching() async { + if (player == null || !Platform.isAndroid) return; + + try { + final fpsStr = await player!.getProperty('container-fps'); + final fps = double.tryParse(fpsStr ?? ''); + if (fps == null || fps <= 0) { + appLogger.d('Frame rate matching: No valid fps available ($fpsStr)'); + return; + } + + final durationMs = player!.state.duration.inMilliseconds; + await player!.setVideoFrameRate(fps, durationMs); + + // Set MPV video-sync mode for smoother playback when display is synced + await player!.setProperty('video-sync', 'display-tempo'); + + appLogger.d('Frame rate matching: Set display to ${fps}fps (duration: ${durationMs}ms)'); + } catch (e) { + appLogger.w('Failed to apply frame rate matching', error: e); + } + } + + /// Clear frame rate matching and restore default display mode + Future _clearFrameRateMatching() async { + if (player == null || !Platform.isAndroid) return; + + try { + await player!.clearVideoFrameRate(); + await player!.setProperty('video-sync', 'audio'); + appLogger.d('Frame rate matching: Cleared, restored default display mode'); + } catch (e) { + appLogger.d('Failed to clear frame rate matching', error: e); + } + } + /// Add external subtitle tracks to the player Future _addExternalSubtitles(List externalSubtitles) async { if (player == null || externalSubtitles.isEmpty) return; @@ -988,6 +1031,11 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Clear Discord Rich Presence DiscordRPCService.instance.stopPlayback(); + // Clear frame rate matching before disposing player (Android only) + if (Platform.isAndroid && player != null) { + player!.clearVideoFrameRate(); + } + // Disable wakelock when leaving the video player WakelockPlus.disable(); appLogger.d('Wakelock disabled'); @@ -1433,6 +1481,8 @@ class VideoPlayerScreenState extends State with WidgetsBindin _detachFromWatchTogetherSession(); _progressTracker?.sendProgress('stopped'); _progressTracker?.stopTracking(); + // Clear frame rate matching before disposing (Android only) + await _clearFrameRateMatching(); await player?.dispose(); } catch (e) { appLogger.d('Error disposing player before navigation', error: e); diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index e8df9750..201ea646 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -59,6 +59,7 @@ class SettingsService extends BaseSharedPreferencesService { static const String _keyMpvConfigPresets = 'mpv_config_presets'; static const String _keyMaxVolume = 'max_volume'; static const String _keyEnableDiscordRPC = 'enable_discord_rpc'; + static const String _keyMatchContentFrameRate = 'match_content_frame_rate'; SettingsService._(); @@ -927,6 +928,15 @@ class SettingsService extends BaseSharedPreferencesService { return prefs.getBool(_keyEnableDiscordRPC) ?? false; // Default disabled } + // Match Content Frame Rate (Android only) + Future setMatchContentFrameRate(bool enabled) async { + await prefs.setBool(_keyMatchContentFrameRate, enabled); + } + + bool getMatchContentFrameRate() { + return prefs.getBool(_keyMatchContentFrameRate) ?? false; // Default disabled + } + // Reset all settings to defaults Future resetAllSettings() async { await Future.wait([ @@ -968,6 +978,7 @@ class SettingsService extends BaseSharedPreferencesService { prefs.remove(_keyMpvConfigEntries), prefs.remove(_keyMpvConfigPresets), prefs.remove(_keyEnableDiscordRPC), + prefs.remove(_keyMatchContentFrameRate), ]); }