Merge branch 'feat/frame-rate-matching'

This commit is contained in:
edde746
2026-01-11 13:04:01 +01:00
15 changed files with 397 additions and 10 deletions
@@ -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<ViewGroup>(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)
@@ -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<Double>("fps")?.toFloat() ?: 0f
val duration = call.argument<Number>("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?) {
+30 -2
View File
@@ -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 '搜索媒体';
+3 -1
View File
@@ -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...",
+3 -1
View File
@@ -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...",
+3 -1
View File
@@ -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...",
+3 -1
View File
@@ -156,7 +156,9 @@
"maxVolumePercent": "${percent}%",
"maxVolumeHint": "최대 볼륨 입력 (100-300)",
"discordRichPresence": "Discord Rich Presence",
"discordRichPresenceDescription": "Discord에서 시청 중인 콘텐츠 표시"
"discordRichPresenceDescription": "Discord에서 시청 중인 콘텐츠 표시",
"matchContentFrameRate": "콘텐츠 프레임 레이트 맞춤",
"matchContentFrameRateDescription": "비디오 콘텐츠에 맞게 디스플레이 주사율을 조정하여 떨림을 줄이고 배터리를 절약합니다"
},
"search": {
"hint": "영화, 시리즈, 음악 등을 검색하세요...",
+3 -1
View File
@@ -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...",
+3 -1
View File
@@ -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...",
+3 -1
View File
@@ -156,7 +156,9 @@
"maxVolumePercent": "${percent}%",
"maxVolumeHint": "输入最大音量 (100-300)",
"discordRichPresence": "Discord 动态状态",
"discordRichPresenceDescription": "在 Discord 上显示您正在观看的内容"
"discordRichPresenceDescription": "在 Discord 上显示您正在观看的内容",
"matchContentFrameRate": "匹配内容帧率",
"matchContentFrameRateDescription": "调整显示刷新率以匹配视频内容,减少画面抖动并节省电量"
},
"search": {
"hint": "搜索电影、系列、音乐...",
+22
View File
@@ -170,6 +170,28 @@ abstract class Player {
/// On other platforms, this is a no-op.
Future<void> 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<void> 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<void> clearVideoFrameRate();
// ============================================
// Lifecycle
// ============================================
+22
View File
@@ -580,6 +580,28 @@ class PlayerNative implements Player {
}
}
// ============================================
// Frame Rate Matching (Android)
// ============================================
@override
Future<void> setVideoFrameRate(double fps, int durationMs) async {
_checkDisposed();
if (!Platform.isAndroid) return;
if (!_initialized) return;
await _methodChannel.invokeMethod('setVideoFrameRate', {'fps': fps, 'duration': durationMs});
}
@override
Future<void> clearVideoFrameRate() async {
_checkDisposed();
if (!Platform.isAndroid) return;
if (!_initialized) return;
await _methodChannel.invokeMethod('clearVideoFrameRate');
}
// ============================================
// Lifecycle
// ============================================
+15
View File
@@ -63,6 +63,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
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<SettingsScreen> {
_videoPlayerNavigationEnabled = _settingsService.getVideoPlayerNavigationEnabled();
_maxVolume = _settingsService.getMaxVolume();
_enableDiscordRPC = _settingsService.getEnableDiscordRPC();
_matchContentFrameRate = _settingsService.getMatchContentFrameRate();
_isLoading = false;
});
}
@@ -253,6 +255,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
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),
+51 -1
View File
@@ -398,9 +398,14 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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<VideoPlayerScreen> with WidgetsBindin
}
}
/// Apply frame rate matching on Android by setting the display refresh rate
/// to match the video content's frame rate.
Future<void> _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<void> _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<void> _addExternalSubtitles(List<SubtitleTrack> externalSubtitles) async {
if (player == null || externalSubtitles.isEmpty) return;
@@ -988,6 +1031,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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<VideoPlayerScreen> 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);
+11
View File
@@ -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<void> setMatchContentFrameRate(bool enabled) async {
await prefs.setBool(_keyMatchContentFrameRate, enabled);
}
bool getMatchContentFrameRate() {
return prefs.getBool(_keyMatchContentFrameRate) ?? false; // Default disabled
}
// Reset all settings to defaults
Future<void> 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),
]);
}