Merge branch 'feat/frame-rate-matching'
This commit is contained in:
@@ -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
@@ -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 '搜索媒体';
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -156,7 +156,9 @@
|
||||
"maxVolumePercent": "${percent}%",
|
||||
"maxVolumeHint": "최대 볼륨 입력 (100-300)",
|
||||
"discordRichPresence": "Discord Rich Presence",
|
||||
"discordRichPresenceDescription": "Discord에서 시청 중인 콘텐츠 표시"
|
||||
"discordRichPresenceDescription": "Discord에서 시청 중인 콘텐츠 표시",
|
||||
"matchContentFrameRate": "콘텐츠 프레임 레이트 맞춤",
|
||||
"matchContentFrameRateDescription": "비디오 콘텐츠에 맞게 디스플레이 주사율을 조정하여 떨림을 줄이고 배터리를 절약합니다"
|
||||
},
|
||||
"search": {
|
||||
"hint": "영화, 시리즈, 음악 등을 검색하세요...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -156,7 +156,9 @@
|
||||
"maxVolumePercent": "${percent}%",
|
||||
"maxVolumeHint": "输入最大音量 (100-300)",
|
||||
"discordRichPresence": "Discord 动态状态",
|
||||
"discordRichPresenceDescription": "在 Discord 上显示您正在观看的内容"
|
||||
"discordRichPresenceDescription": "在 Discord 上显示您正在观看的内容",
|
||||
"matchContentFrameRate": "匹配内容帧率",
|
||||
"matchContentFrameRateDescription": "调整显示刷新率以匹配视频内容,减少画面抖动并节省电量"
|
||||
},
|
||||
"search": {
|
||||
"hint": "搜索电影、系列、音乐...",
|
||||
|
||||
@@ -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
|
||||
// ============================================
|
||||
|
||||
@@ -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
|
||||
// ============================================
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user