@@ -265,12 +265,6 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
frameRateManager = FrameRateManager(
|
||||
activity = activity,
|
||||
handler = handler,
|
||||
onDisplayChanged = {
|
||||
if (exoPlayer?.isPlaying == false) {
|
||||
Log.d(TAG, "Display changed after frame rate switch, resuming playback")
|
||||
exoPlayer?.play()
|
||||
}
|
||||
},
|
||||
log = { emitLog("info", "framerate", it) }
|
||||
)
|
||||
|
||||
@@ -1616,8 +1610,18 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
|
||||
// Frame Rate Matching
|
||||
|
||||
fun setVideoFrameRate(fps: Float, videoDurationMs: Long) {
|
||||
frameRateManager?.setVideoFrameRate(fps, videoDurationMs, surfaceView?.holder?.surface)
|
||||
fun setVideoFrameRate(
|
||||
fps: Float,
|
||||
videoDurationMs: Long,
|
||||
extraDelayMs: Long,
|
||||
onComplete: (switched: Boolean) -> Unit,
|
||||
) {
|
||||
val mgr = frameRateManager
|
||||
if (mgr == null) {
|
||||
onComplete(false)
|
||||
return
|
||||
}
|
||||
mgr.setVideoFrameRate(fps, videoDurationMs, surfaceView?.holder?.surface, extraDelayMs, onComplete)
|
||||
}
|
||||
|
||||
fun clearVideoFrameRate() {
|
||||
|
||||
@@ -433,14 +433,19 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||
private fun handleSetVideoFrameRate(call: MethodCall, result: MethodChannel.Result) {
|
||||
val fps = call.argument<Double>("fps")?.toFloat() ?: 0f
|
||||
val duration = call.argument<Number>("duration")?.toLong() ?: 0L
|
||||
val extraDelayMs = call.argument<Number>("extraDelayMs")?.toLong() ?: 0L
|
||||
|
||||
Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration")
|
||||
Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs")
|
||||
val onComplete: (Boolean) -> Unit = { switched -> result.success(switched) }
|
||||
if (usingMpvFallback) {
|
||||
mpvCore?.setVideoFrameRate(fps, duration)
|
||||
val core = mpvCore
|
||||
if (core == null) result.success(false)
|
||||
else core.setVideoFrameRate(fps, duration, extraDelayMs, onComplete)
|
||||
} else {
|
||||
playerCore?.setVideoFrameRate(fps, duration)
|
||||
val core = playerCore
|
||||
if (core == null) result.success(false)
|
||||
else core.setVideoFrameRate(fps, duration, extraDelayMs, onComplete)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
private fun handleClearVideoFrameRate(result: MethodChannel.Result) {
|
||||
|
||||
@@ -134,9 +134,6 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
|
||||
frameRateManager = FrameRateManager(
|
||||
activity = activity,
|
||||
handler = handler,
|
||||
onDisplayChanged = {
|
||||
requestAutoResume("display change")
|
||||
}
|
||||
)
|
||||
|
||||
// Create FrameLayout container for video
|
||||
@@ -707,8 +704,18 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
|
||||
|
||||
// Frame Rate Matching
|
||||
|
||||
fun setVideoFrameRate(fps: Float, videoDurationMs: Long) {
|
||||
frameRateManager?.setVideoFrameRate(fps, videoDurationMs, surfaceView?.holder?.surface)
|
||||
fun setVideoFrameRate(
|
||||
fps: Float,
|
||||
videoDurationMs: Long,
|
||||
extraDelayMs: Long,
|
||||
onComplete: (switched: Boolean) -> Unit,
|
||||
) {
|
||||
val mgr = frameRateManager
|
||||
if (mgr == null) {
|
||||
onComplete(false)
|
||||
return
|
||||
}
|
||||
mgr.setVideoFrameRate(fps, videoDurationMs, surfaceView?.holder?.surface, extraDelayMs, onComplete)
|
||||
}
|
||||
|
||||
fun clearVideoFrameRate() {
|
||||
|
||||
@@ -239,10 +239,17 @@ class MpvPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||
private fun handleSetVideoFrameRate(call: MethodCall, result: MethodChannel.Result) {
|
||||
val fps = call.argument<Double>("fps")?.toFloat() ?: 0f
|
||||
val duration = call.argument<Number>("duration")?.toLong() ?: 0L
|
||||
val extraDelayMs = call.argument<Number>("extraDelayMs")?.toLong() ?: 0L
|
||||
|
||||
Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration")
|
||||
playerCore?.setVideoFrameRate(fps, duration)
|
||||
result.success(null)
|
||||
Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs")
|
||||
val core = playerCore
|
||||
if (core == null) {
|
||||
result.success(false)
|
||||
return
|
||||
}
|
||||
core.setVideoFrameRate(fps, duration, extraDelayMs) { switched ->
|
||||
result.success(switched)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleClearVideoFrameRate(result: MethodChannel.Result) {
|
||||
|
||||
@@ -15,52 +15,74 @@ import java.math.RoundingMode
|
||||
class FrameRateManager(
|
||||
private val activity: Activity,
|
||||
private val handler: Handler,
|
||||
private val onDisplayChanged: () -> Unit,
|
||||
private val log: (String) -> Unit = { Log.d(TAG, it) }
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "FrameRateManager"
|
||||
private const val SHORT_VIDEO_LENGTH_MS = 300000L // 5 minutes
|
||||
private const val DISPLAY_SETTLE_MS = 2000L
|
||||
private const val WATCHDOG_MARGIN_MS = 3000L
|
||||
}
|
||||
|
||||
private var currentVideoFps: Float = 0f
|
||||
private var displayListener: DisplayManager.DisplayListener? = null
|
||||
private var pendingSettleRunnable: Runnable? = null
|
||||
private var watchdogRunnable: Runnable? = null
|
||||
private var pendingCompletion: ((switched: Boolean) -> Unit)? = null
|
||||
|
||||
private fun getDisplayManager(): DisplayManager {
|
||||
return activity.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
|
||||
}
|
||||
|
||||
fun setVideoFrameRate(fps: Float, videoDurationMs: Long, surface: Surface?) {
|
||||
/// Request a display frame-rate switch. Invokes [onComplete] once, either:
|
||||
/// - immediately with `switched=false` when no switch is needed (invalid
|
||||
/// fps, no matching mode, seamless fallback); or
|
||||
/// - after the real DisplayListener event + [DISPLAY_SETTLE_MS] + the
|
||||
/// caller's [extraDelayMs], with `switched=true`; or
|
||||
/// - via a watchdog with `switched=true` if the real event never arrives,
|
||||
/// so the caller doesn't hang.
|
||||
///
|
||||
/// The caller is responsible for pausing playback before calling and
|
||||
/// resuming it after [onComplete] fires.
|
||||
fun setVideoFrameRate(
|
||||
fps: Float,
|
||||
videoDurationMs: Long,
|
||||
surface: Surface?,
|
||||
extraDelayMs: Long,
|
||||
onComplete: (switched: Boolean) -> Unit,
|
||||
) {
|
||||
currentVideoFps = fps
|
||||
if (fps <= 0f) {
|
||||
Log.d(TAG, "setVideoFrameRate: Invalid fps ($fps), skipping")
|
||||
onComplete(false)
|
||||
return
|
||||
}
|
||||
|
||||
log("fps=$fps, duration=${videoDurationMs}ms, API=${Build.VERSION.SDK_INT}")
|
||||
log("fps=$fps, duration=${videoDurationMs}ms, extraDelayMs=${extraDelayMs}, API=${Build.VERSION.SDK_INT}")
|
||||
|
||||
when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
if (surface == null) {
|
||||
Log.d(TAG, "setVideoFrameRate: Surface not available")
|
||||
onComplete(false)
|
||||
return
|
||||
}
|
||||
setFrameRateS(fps, surface, videoDurationMs)
|
||||
setFrameRateS(fps, surface, videoDurationMs, extraDelayMs, onComplete)
|
||||
}
|
||||
// API R's Surface.setFrameRate() only supports seamless switching (no
|
||||
// CHANGE_FRAME_RATE_ALWAYS), so 60→24Hz won't switch. Fall through to
|
||||
// preferredDisplayModeId which directly sets the display mode.
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> setFrameRateM(fps)
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> setFrameRateM(fps, extraDelayMs, onComplete)
|
||||
else -> onComplete(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearVideoFrameRate() {
|
||||
Log.d(TAG, "clearVideoFrameRate")
|
||||
currentVideoFps = 0f
|
||||
displayListener?.let {
|
||||
getDisplayManager().unregisterDisplayListener(it)
|
||||
displayListener = null
|
||||
}
|
||||
// Resolve any pending setVideoFrameRate future as "not switched" so
|
||||
// the Dart caller's await doesn't hang on player dispose.
|
||||
firePendingCompletion("clear", switched = false)
|
||||
// Restore default display mode on API M (preferredDisplayModeId persists)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
activity.window?.attributes?.let { attrs ->
|
||||
@@ -70,29 +92,90 @@ class FrameRateManager(
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerDisplayListener() {
|
||||
private fun cancelPendingCallbacks() {
|
||||
pendingSettleRunnable?.let { handler.removeCallbacks(it) }
|
||||
watchdogRunnable?.let { handler.removeCallbacks(it) }
|
||||
pendingSettleRunnable = null
|
||||
watchdogRunnable = null
|
||||
}
|
||||
|
||||
private fun firePendingCompletion(reason: String, switched: Boolean) {
|
||||
cancelPendingCallbacks()
|
||||
displayListener?.let {
|
||||
getDisplayManager().unregisterDisplayListener(it)
|
||||
displayListener = null
|
||||
}
|
||||
val cb = pendingCompletion ?: return
|
||||
pendingCompletion = null
|
||||
Log.d(TAG, "FrameRateManager complete ($reason, switched=$switched)")
|
||||
cb(switched)
|
||||
}
|
||||
|
||||
private fun registerDisplayListener(extraDelayMs: Long, onComplete: (switched: Boolean) -> Unit) {
|
||||
// Resolve any previous pending op before starting a new one.
|
||||
firePendingCompletion("superseded", switched = false)
|
||||
pendingCompletion = onComplete
|
||||
|
||||
displayListener = object : DisplayManager.DisplayListener {
|
||||
override fun onDisplayAdded(displayId: Int) = Unit
|
||||
override fun onDisplayRemoved(displayId: Int) = Unit
|
||||
override fun onDisplayChanged(displayId: Int) {
|
||||
handler.postDelayed({
|
||||
onDisplayChanged()
|
||||
}, 2000L)
|
||||
// Unregister immediately so a chatty display (e.g. several
|
||||
// onDisplayChanged events during HDMI renegotiation) doesn't
|
||||
// queue multiple settle callbacks.
|
||||
getDisplayManager().unregisterDisplayListener(this)
|
||||
displayListener = null
|
||||
|
||||
val settle = Runnable { firePendingCompletion("display settled", switched = true) }
|
||||
pendingSettleRunnable = settle
|
||||
handler.postDelayed(settle, DISPLAY_SETTLE_MS + extraDelayMs)
|
||||
}
|
||||
}
|
||||
getDisplayManager().registerDisplayListener(displayListener, handler)
|
||||
|
||||
// Watchdog: if the TV never signals a display change (silently ignoring
|
||||
// the mode request), still complete after a bounded wait so the caller
|
||||
// doesn't hang.
|
||||
val watchdog = Runnable { firePendingCompletion("watchdog", switched = true) }
|
||||
watchdogRunnable = watchdog
|
||||
handler.postDelayed(watchdog, DISPLAY_SETTLE_MS + extraDelayMs + WATCHDOG_MARGIN_MS)
|
||||
}
|
||||
|
||||
private fun currentRateMatchesFps(fps: Float): Boolean {
|
||||
val current = activity.display?.mode?.refreshRate ?: return false
|
||||
if (current <= 0f) return false
|
||||
// Treat "equal within a frame" and "clean multiple" as a match —
|
||||
// same tolerance the API M matcher uses below.
|
||||
if (kotlin.math.abs(current - fps) < 0.1f) return true
|
||||
val mod = current % fps
|
||||
return mod < 0.1f || (fps - mod) < 0.1f
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.S)
|
||||
private fun setFrameRateS(fps: Float, surface: Surface, videoDurationMs: Long) {
|
||||
private fun setFrameRateS(
|
||||
fps: Float,
|
||||
surface: Surface,
|
||||
videoDurationMs: Long,
|
||||
extraDelayMs: Long,
|
||||
onComplete: (switched: Boolean) -> Unit,
|
||||
) {
|
||||
Log.d(TAG, "setFrameRateS: fps=$fps, duration=${videoDurationMs}ms")
|
||||
|
||||
// If the current display rate already satisfies the video fps, issue
|
||||
// the hint for book-keeping but skip the listener — otherwise we'd
|
||||
// wait for an onDisplayChanged event that never fires and end up
|
||||
// burning the watchdog timeout for no reason.
|
||||
if (currentRateMatchesFps(fps)) {
|
||||
Log.d(TAG, "Current display rate already matches ${fps}fps, no switch needed")
|
||||
surface.setFrameRate(
|
||||
fps,
|
||||
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
|
||||
Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS
|
||||
)
|
||||
onComplete(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (videoDurationMs < SHORT_VIDEO_LENGTH_MS) {
|
||||
Log.d(TAG, "Short video, using seamless-only switching")
|
||||
surface.setFrameRate(
|
||||
@@ -100,6 +183,7 @@ class FrameRateManager(
|
||||
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
|
||||
Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS
|
||||
)
|
||||
onComplete(false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -122,7 +206,7 @@ class FrameRateManager(
|
||||
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
|
||||
Surface.CHANGE_FRAME_RATE_ALWAYS
|
||||
)
|
||||
registerDisplayListener()
|
||||
registerDisplayListener(extraDelayMs, onComplete)
|
||||
} else {
|
||||
val userPreference = getDisplayManager().matchContentFrameRateUserPreference
|
||||
if (userPreference == DisplayManager.MATCH_CONTENT_FRAMERATE_ALWAYS) {
|
||||
@@ -132,7 +216,7 @@ class FrameRateManager(
|
||||
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
|
||||
Surface.CHANGE_FRAME_RATE_ALWAYS
|
||||
)
|
||||
registerDisplayListener()
|
||||
registerDisplayListener(extraDelayMs, onComplete)
|
||||
} else {
|
||||
Log.d(TAG, "Non-seamless switch not allowed, using seamless-only")
|
||||
surface.setFrameRate(
|
||||
@@ -140,45 +224,56 @@ class FrameRateManager(
|
||||
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
|
||||
Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS
|
||||
)
|
||||
onComplete(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun setFrameRateM(fps: Float) {
|
||||
private fun setFrameRateM(fps: Float, extraDelayMs: Long, onComplete: (switched: Boolean) -> Unit) {
|
||||
Log.d(TAG, "setFrameRateM: fps=$fps")
|
||||
val wm = activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
@Suppress("DEPRECATION")
|
||||
val display = wm.defaultDisplay ?: return
|
||||
val display = wm.defaultDisplay
|
||||
if (display == null) {
|
||||
onComplete(false)
|
||||
return
|
||||
}
|
||||
|
||||
display.supportedModes?.let { supportedModes ->
|
||||
val currentMode = display.mode
|
||||
var modeToUse = currentMode
|
||||
val supportedModes = display.supportedModes
|
||||
if (supportedModes == null) {
|
||||
onComplete(false)
|
||||
return
|
||||
}
|
||||
val currentMode = display.mode
|
||||
var modeToUse = currentMode
|
||||
|
||||
for (mode in supportedModes) {
|
||||
if (mode.physicalHeight != currentMode.physicalHeight ||
|
||||
mode.physicalWidth != currentMode.physicalWidth) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (BigDecimal(fps.toString()).setScale(1, RoundingMode.FLOOR) ==
|
||||
BigDecimal(mode.refreshRate.toString()).setScale(1, RoundingMode.FLOOR)) {
|
||||
modeToUse = mode
|
||||
break
|
||||
} else if ((mode.refreshRate % fps).let { it < 0.1f || (fps - it) < 0.1f }) {
|
||||
modeToUse = mode
|
||||
break
|
||||
}
|
||||
for (mode in supportedModes) {
|
||||
if (mode.physicalHeight != currentMode.physicalHeight ||
|
||||
mode.physicalWidth != currentMode.physicalWidth) {
|
||||
continue
|
||||
}
|
||||
|
||||
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()
|
||||
if (BigDecimal(fps.toString()).setScale(1, RoundingMode.FLOOR) ==
|
||||
BigDecimal(mode.refreshRate.toString()).setScale(1, RoundingMode.FLOOR)) {
|
||||
modeToUse = mode
|
||||
break
|
||||
} else if ((mode.refreshRate % fps).let { it < 0.1f || (fps - it) < 0.1f }) {
|
||||
modeToUse = mode
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (modeToUse == currentMode) {
|
||||
onComplete(false)
|
||||
return
|
||||
}
|
||||
|
||||
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(extraDelayMs, onComplete)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ class PlexMediaInfo {
|
||||
final List<PlexSubtitleTrack> subtitleTracks;
|
||||
final List<PlexChapter> chapters;
|
||||
final int? partId;
|
||||
final double? frameRate;
|
||||
|
||||
PlexMediaInfo({
|
||||
required this.videoUrl,
|
||||
@@ -16,6 +17,7 @@ class PlexMediaInfo {
|
||||
required this.subtitleTracks,
|
||||
required this.chapters,
|
||||
this.partId,
|
||||
this.frameRate,
|
||||
});
|
||||
int? getPartId() => partId;
|
||||
|
||||
@@ -31,12 +33,15 @@ class PlexMediaInfo {
|
||||
|
||||
final audioTracks = <PlexAudioTrack>[];
|
||||
final subtitleTracks = <PlexSubtitleTrack>[];
|
||||
double? frameRate;
|
||||
|
||||
if (streams != null) {
|
||||
for (final s in streams) {
|
||||
try {
|
||||
final streamType = s['streamType'] as int?;
|
||||
if (streamType == 2) {
|
||||
if (streamType == 1) {
|
||||
frameRate ??= (s['frameRate'] as num?)?.toDouble();
|
||||
} else if (streamType == 2) {
|
||||
audioTracks.add(
|
||||
PlexAudioTrack(
|
||||
id: s['id'] as int,
|
||||
@@ -72,7 +77,13 @@ class PlexMediaInfo {
|
||||
}
|
||||
}
|
||||
|
||||
return PlexMediaInfo(videoUrl: '', audioTracks: audioTracks, subtitleTracks: subtitleTracks, chapters: const []);
|
||||
return PlexMediaInfo(
|
||||
videoUrl: '',
|
||||
audioTracks: audioTracks,
|
||||
subtitleTracks: subtitleTracks,
|
||||
chapters: const [],
|
||||
frameRate: frameRate,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -288,7 +288,11 @@ class Media {
|
||||
/// Optional start position for playback.
|
||||
final Duration? start;
|
||||
|
||||
const Media(this.uri, {this.headers, this.start});
|
||||
/// Optional pre-known video frame rate (from server metadata), used to drive
|
||||
/// display refresh-rate matching before the first frame renders.
|
||||
final double? fps;
|
||||
|
||||
const Media(this.uri, {this.headers, this.start, this.fps});
|
||||
|
||||
@override
|
||||
String toString() => 'Media($uri)';
|
||||
|
||||
@@ -385,9 +385,14 @@ class PlayerAndroid extends PlayerBase {
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<void> setVideoFrameRate(double fps, int durationMs) async {
|
||||
if (disposed || !initialized) return;
|
||||
await invoke('setVideoFrameRate', {'fps': fps, 'duration': durationMs});
|
||||
Future<bool> setVideoFrameRate(double fps, int durationMs, {int extraDelayMs = 0}) async {
|
||||
if (disposed || !initialized) return false;
|
||||
final result = await invoke<bool>('setVideoFrameRate', {
|
||||
'fps': fps,
|
||||
'duration': durationMs,
|
||||
'extraDelayMs': extraDelayMs,
|
||||
});
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -213,9 +213,17 @@ abstract class Player {
|
||||
///
|
||||
/// [fps] - The video frame rate (e.g., 23.976, 24, 30, 60).
|
||||
/// [durationMs] - The video duration in milliseconds.
|
||||
/// [extraDelayMs] - Extra settle time (ms) added to the native display-change
|
||||
/// wait before playback is auto-resumed. Used to absorb the
|
||||
/// user-configured "display switch delay" on Android TV.
|
||||
///
|
||||
/// On other platforms, this is a no-op.
|
||||
Future<void> setVideoFrameRate(double fps, int durationMs);
|
||||
/// Returns `true` if a display mode switch was initiated and the platform
|
||||
/// will resume playback once the display settles; `false` if no switch was
|
||||
/// needed (seamless fallback, invalid fps, no matching mode), in which case
|
||||
/// the caller is responsible for starting playback itself.
|
||||
///
|
||||
/// On other platforms, this is a no-op that returns `false`.
|
||||
Future<bool> setVideoFrameRate(double fps, int durationMs, {int extraDelayMs = 0});
|
||||
|
||||
/// Clear the video frame rate hint and restore default display mode.
|
||||
///
|
||||
|
||||
@@ -544,8 +544,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
Future<void> updateFrame() async {}
|
||||
|
||||
@override
|
||||
// ignore: no-empty-block - base no-op, overridden by platform subclasses
|
||||
Future<void> setVideoFrameRate(double fps, int durationMs) async {}
|
||||
Future<bool> setVideoFrameRate(double fps, int durationMs, {int extraDelayMs = 0}) async => false;
|
||||
|
||||
@override
|
||||
// ignore: no-empty-block - base no-op, overridden by platform subclasses
|
||||
|
||||
@@ -277,9 +277,14 @@ class PlayerNative extends PlayerBase {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setVideoFrameRate(double fps, int durationMs) async {
|
||||
if (!Platform.isAndroid || disposed || !initialized) return;
|
||||
await invoke('setVideoFrameRate', {'fps': fps, 'duration': durationMs});
|
||||
Future<bool> setVideoFrameRate(double fps, int durationMs, {int extraDelayMs = 0}) async {
|
||||
if (!Platform.isAndroid || disposed || !initialized) return false;
|
||||
final result = await invoke<bool>('setVideoFrameRate', {
|
||||
'fps': fps,
|
||||
'duration': durationMs,
|
||||
'extraDelayMs': extraDelayMs,
|
||||
});
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -124,7 +124,9 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
||||
if (Platform.isAndroid) _buildMatchContentFrameRate(),
|
||||
if (Platform.isWindows) _buildMatchRefreshRate(),
|
||||
if (Platform.isWindows) _buildMatchDynamicRange(),
|
||||
if (Platform.isWindows && (_matchRefreshRate || _matchDynamicRange)) _buildDisplaySwitchDelay(),
|
||||
if ((Platform.isWindows && (_matchRefreshRate || _matchDynamicRange)) ||
|
||||
(Platform.isAndroid && _matchContentFrameRate))
|
||||
_buildDisplaySwitchDelay(),
|
||||
if (Platform.isAndroid && _useExoPlayer) _buildTunneledPlayback(),
|
||||
_buildBufferSizeSelector(),
|
||||
|
||||
|
||||
@@ -862,8 +862,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
/// to match the video content's frame rate.
|
||||
int _frameRateRetries = 0;
|
||||
bool _suppressMediaPauseDuringFrameRateSwitch = false;
|
||||
// True once a frame-rate switch has been requested for the current playback
|
||||
// session — either via the pre-playback primary path (Plex metadata fps) or
|
||||
// via the post-`playbackRestart` fallback. Prevents double-switching.
|
||||
bool _frameRateMatchingApplied = false;
|
||||
Future<void> _applyFrameRateMatching() async {
|
||||
if (player == null || !Platform.isAndroid) return;
|
||||
if (_frameRateMatchingApplied) return;
|
||||
|
||||
try {
|
||||
final fpsStr = await player!.getProperty('container-fps');
|
||||
@@ -883,22 +888,47 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
}
|
||||
|
||||
_frameRateRetries = 0;
|
||||
_frameRateMatchingApplied = true;
|
||||
final durationMs = player!.state.duration.inMilliseconds;
|
||||
final settingsService = await SettingsService.getInstance();
|
||||
final delaySec = settingsService.getDisplaySwitchDelay();
|
||||
|
||||
// Suppress spurious PauseEvent from MediaSession during HDMI renegotiation.
|
||||
// Fire Stick (and similar Android TV devices) send onPause() through the
|
||||
// MediaSession callback when the display mode changes for frame rate matching.
|
||||
_suppressMediaPauseDuringFrameRateSwitch = true;
|
||||
await player!.setVideoFrameRate(fps, durationMs);
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
Future.delayed(Duration(seconds: 2 + delaySec + 1), () {
|
||||
_suppressMediaPauseDuringFrameRateSwitch = false;
|
||||
});
|
||||
|
||||
// Set MPV video-sync mode for smoother playback when display is synced
|
||||
await player!.setProperty('video-sync', 'display-tempo');
|
||||
// Pause so the playback clock doesn't advance while the TV renegotiates
|
||||
// HDMI. The native setVideoFrameRate call below awaits the real display
|
||||
// change event (+ settle + user delay) before returning, and then we
|
||||
// resume — same shape as the primary pre-playback path, just later.
|
||||
try {
|
||||
await player!.pause();
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to pause before frame rate switch', error: e);
|
||||
}
|
||||
|
||||
Sentry.addBreadcrumb(Breadcrumb(message: 'Frame rate matching: ${fps}fps', category: 'player'));
|
||||
appLogger.d('Frame rate matching: Set display to ${fps}fps (duration: ${durationMs}ms)');
|
||||
final didSwitch = await player!.setVideoFrameRate(fps, durationMs, extraDelayMs: delaySec * 1000);
|
||||
|
||||
// Set MPV video-sync mode for smoother playback when display is synced
|
||||
try {
|
||||
await player!.setProperty('video-sync', 'display-tempo');
|
||||
} catch (_) {}
|
||||
|
||||
if (mounted && player != null) {
|
||||
await player!.play();
|
||||
}
|
||||
|
||||
Sentry.addBreadcrumb(
|
||||
Breadcrumb(
|
||||
message: 'Frame rate matching: ${fps}fps, switched=$didSwitch, delay=${delaySec}s',
|
||||
category: 'player',
|
||||
),
|
||||
);
|
||||
appLogger.d('Frame rate matching: Set display to ${fps}fps (duration: ${durationMs}ms, switched=$didSwitch)');
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to apply frame rate matching', error: e);
|
||||
}
|
||||
@@ -1357,11 +1387,20 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
);
|
||||
}
|
||||
|
||||
// Primary refresh-rate path: when Plex metadata provides an fps and the
|
||||
// user has frame-rate matching on, open the player paused so the HDMI
|
||||
// refresh-rate switch can complete before any frame renders.
|
||||
final settingsService = await SettingsService.getInstance();
|
||||
final preKnownFps = result.mediaInfo?.frameRate;
|
||||
final willAutoSwitch =
|
||||
Platform.isAndroid && settingsService.getMatchContentFrameRate() && preKnownFps != null && preKnownFps > 0;
|
||||
|
||||
// Open video through Player
|
||||
if (result.videoUrl != null) {
|
||||
// Reset first frame flag and frame rate retry counter for new video
|
||||
_hasFirstFrame.value = false;
|
||||
_frameRateRetries = 0;
|
||||
_frameRateMatchingApplied = false;
|
||||
|
||||
// Request audio focus before starting playback (Android)
|
||||
// This causes other media apps (Spotify, podcasts, etc.) to pause
|
||||
@@ -1398,15 +1437,14 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
// them in a single prepare() — no media reload needed for selection.
|
||||
// MPV (all platforms including Android): external subs added after open via sub-add.
|
||||
await player!.open(
|
||||
Media(result.videoUrl!, start: resumePosition, headers: plexHeaders),
|
||||
play: isExoPlayer || !hasExternalSubs,
|
||||
Media(result.videoUrl!, start: resumePosition, headers: plexHeaders, fps: preKnownFps),
|
||||
play: !willAutoSwitch && (isExoPlayer || !hasExternalSubs),
|
||||
externalSubtitles: isExoPlayer && hasExternalSubs ? result.externalSubtitles : null,
|
||||
);
|
||||
|
||||
// Apply subtitle styling to ExoPlayer native layer (CaptionStyleCompat + libass font scale)
|
||||
// Must be called after open() since that's when ExoPlayer initializes
|
||||
if (player is PlayerAndroid) {
|
||||
final settingsService = await SettingsService.getInstance();
|
||||
await (player as PlayerAndroid).setSubtitleStyle(
|
||||
fontSize: settingsService.getSubtitleFontSize().toDouble(),
|
||||
textColor: settingsService.getSubtitleTextColor(),
|
||||
@@ -1507,13 +1545,60 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
try {
|
||||
await _trackManager!.addExternalSubtitles(result.externalSubtitles);
|
||||
} finally {
|
||||
await _trackManager!.resumeAfterSubtitleLoad();
|
||||
// When willAutoSwitch the pre-playback refresh-rate block below
|
||||
// owns the resume, so skip this one to avoid a double-play.
|
||||
if (!willAutoSwitch) {
|
||||
await _trackManager!.resumeAfterSubtitleLoad();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Android (subs attached at open time) or no external subs:
|
||||
// apply once tracks are available
|
||||
_trackManager!.applyTrackSelectionWhenReady();
|
||||
}
|
||||
|
||||
// Initiate the HDMI refresh-rate switch BEFORE any frame renders.
|
||||
// The player was opened paused; setVideoFrameRate awaits the real
|
||||
// display-change event (+ settle + user delay) before returning, and
|
||||
// then we start playback — so the first frame the user sees is after
|
||||
// the switch has settled.
|
||||
if (willAutoSwitch && mounted && player != null) {
|
||||
_frameRateMatchingApplied = true;
|
||||
final delaySec = settingsService.getDisplaySwitchDelay();
|
||||
final durationMs = _currentMetadata.duration ?? player!.state.duration.inMilliseconds;
|
||||
_suppressMediaPauseDuringFrameRateSwitch = true;
|
||||
Future.delayed(Duration(seconds: 2 + delaySec + 1), () {
|
||||
_suppressMediaPauseDuringFrameRateSwitch = false;
|
||||
});
|
||||
bool didSwitch = false;
|
||||
try {
|
||||
didSwitch = await player!.setVideoFrameRate(preKnownFps, durationMs, extraDelayMs: delaySec * 1000);
|
||||
// MPV video-sync tuning (no-op on ExoPlayer).
|
||||
try {
|
||||
await player!.setProperty('video-sync', 'display-tempo');
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to apply pre-playback frame rate matching', error: e);
|
||||
}
|
||||
|
||||
// Always resume — either the switch completed and we want to play,
|
||||
// or no switch was needed and we need to start playback now that the
|
||||
// preparation gate has been cleared.
|
||||
if (mounted && player != null) {
|
||||
if (player is! PlayerAndroid && result.externalSubtitles.isNotEmpty) {
|
||||
await _trackManager!.resumeAfterSubtitleLoad();
|
||||
} else {
|
||||
await player!.play();
|
||||
}
|
||||
}
|
||||
|
||||
Sentry.addBreadcrumb(
|
||||
Breadcrumb(
|
||||
message: 'Pre-playback frame rate: ${preKnownFps}fps, switched=$didSwitch, delay=${delaySec}s',
|
||||
category: 'player',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} on PlaybackException catch (e) {
|
||||
if (mounted) {
|
||||
|
||||
@@ -826,17 +826,23 @@ class PlexClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse audio and subtitle tracks from a stream list
|
||||
({List<PlexAudioTrack> audio, List<PlexSubtitleTrack> subtitles}) _parseStreams(List<dynamic>? streams) {
|
||||
/// Parse audio/subtitle tracks and the video stream's frame rate from a
|
||||
/// raw Part.Stream list in a single pass.
|
||||
({List<PlexAudioTrack> audio, List<PlexSubtitleTrack> subtitles, double? frameRate}) _parseStreams(
|
||||
List<dynamic>? streams,
|
||||
) {
|
||||
final audioTracks = <PlexAudioTrack>[];
|
||||
final subtitleTracks = <PlexSubtitleTrack>[];
|
||||
double? frameRate;
|
||||
|
||||
if (streams == null) return (audio: audioTracks, subtitles: subtitleTracks);
|
||||
if (streams == null) return (audio: audioTracks, subtitles: subtitleTracks, frameRate: frameRate);
|
||||
|
||||
for (var stream in streams) {
|
||||
final streamType = stream['streamType'] as int?;
|
||||
|
||||
if (streamType == PlexStreamType.audio) {
|
||||
if (streamType == PlexStreamType.video) {
|
||||
frameRate ??= (stream['frameRate'] as num?)?.toDouble();
|
||||
} else if (streamType == PlexStreamType.audio) {
|
||||
audioTracks.add(
|
||||
PlexAudioTrack(
|
||||
id: stream['id'] as int,
|
||||
@@ -868,7 +874,7 @@ class PlexClient {
|
||||
}
|
||||
}
|
||||
|
||||
return (audio: audioTracks, subtitles: subtitleTracks);
|
||||
return (audio: audioTracks, subtitles: subtitleTracks, frameRate: frameRate);
|
||||
}
|
||||
|
||||
/// Parse chapters from metadata JSON
|
||||
@@ -1237,6 +1243,7 @@ class PlexClient {
|
||||
subtitleTracks: streams.subtitles,
|
||||
chapters: chapters,
|
||||
partId: part['id'] as int?,
|
||||
frameRate: streams.frameRate,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user