feat(android): display switch delay

close #894
This commit is contained in:
edde746
2026-04-20 17:34:54 +02:00
parent 6d265e7bae
commit 1fd415ae31
14 changed files with 335 additions and 91 deletions
@@ -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)
}
}
+13 -2
View File
@@ -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
View File
@@ -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)';
+8 -3
View File
@@ -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
+10 -2
View File
@@ -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.
///
+1 -2
View File
@@ -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
+8 -3
View File
@@ -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(),
+95 -10
View File
@@ -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) {
+12 -5
View File
@@ -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,
);
}
}