fix(android): preserve mpv frame-rate startup position

This commit is contained in:
edde746
2026-05-21 09:58:13 +02:00
parent 11b97e626e
commit 2b45557743
4 changed files with 69 additions and 18 deletions
@@ -142,7 +142,7 @@ class FrameRateManager(
cb(switched)
}
private fun registerDisplayListener(extraDelayMs: Long, onComplete: (switched: Boolean) -> Unit) {
private fun registerDisplayListener(fps: Float, extraDelayMs: Long, onComplete: (switched: Boolean) -> Unit) {
// Resolve any previous pending op before starting a new one.
firePendingCompletion("superseded", switched = false)
pendingCompletion = onComplete
@@ -157,7 +157,7 @@ class FrameRateManager(
getDisplayManager().unregisterDisplayListener(this)
displayListener = null
val settle = Runnable { firePendingCompletion("display settled", switched = true) }
val settle = Runnable { firePendingCompletion("display settled", switched = currentRateMatch(fps) != null) }
pendingSettleRunnable = settle
handler.postDelayed(settle, DISPLAY_SETTLE_MS + extraDelayMs)
}
@@ -167,7 +167,7 @@ class FrameRateManager(
// 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) }
val watchdog = Runnable { firePendingCompletion("watchdog", switched = currentRateMatch(fps) != null) }
watchdogRunnable = watchdog
handler.postDelayed(watchdog, DISPLAY_SETTLE_MS + extraDelayMs + WATCHDOG_MARGIN_MS)
}
@@ -295,7 +295,7 @@ class FrameRateManager(
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
Surface.CHANGE_FRAME_RATE_ALWAYS
)
registerDisplayListener(extraDelayMs, onComplete)
registerDisplayListener(fps, extraDelayMs, onComplete)
} else {
val userPreference = getDisplayManager().matchContentFrameRateUserPreference
if (userPreference == DisplayManager.MATCH_CONTENT_FRAMERATE_ALWAYS) {
@@ -305,7 +305,7 @@ class FrameRateManager(
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
Surface.CHANGE_FRAME_RATE_ALWAYS
)
registerDisplayListener(extraDelayMs, onComplete)
registerDisplayListener(fps, extraDelayMs, onComplete)
} else {
log("non-seamless switch not allowed (preference=$userPreference), using seamless-only")
surface.setFrameRate(
@@ -361,6 +361,6 @@ class FrameRateManager(
attrs.preferredDisplayModeId = modeToUse.modeId
activity.window?.attributes = attrs
}
registerDisplayListener(extraDelayMs, onComplete)
registerDisplayListener(fps, extraDelayMs, onComplete)
}
}
@@ -73,7 +73,10 @@ extension _VideoPlayerDisplayMatchingMethods on VideoPlayerScreenState {
final p = player;
if (!mounted || !Platform.isAndroid || p == null || p is PlayerAndroid) return;
// Subscribe before flushing so the broadcast event isn't dropped when
final isLive = widget.isLive;
final targetPosition = p.state.position;
// Subscribe before refreshing so the broadcast event isn't dropped when
// the restart fires synchronously fast.
var timedOut = false;
final restartFuture = p.streams.playbackRestart.first.timeout(
@@ -84,16 +87,24 @@ extension _VideoPlayerDisplayMatchingMethods on VideoPlayerScreenState {
);
final sw = Stopwatch()..start();
try {
appLogger.d('Frame rate matching: flushing Android MPV buffers ($reason, command=drop-buffers)');
await p.command(['drop-buffers']);
if (isLive) {
appLogger.d('Frame rate matching: flushing Android MPV live buffers ($reason, command=drop-buffers)');
await p.command(['drop-buffers']);
} else {
appLogger.d(
'Frame rate matching: refreshing Android MPV decoder '
'($reason, target=${targetPosition.inMilliseconds}ms)',
);
await p.seek(targetPosition);
}
await restartFuture;
appLogger.d(
'Frame rate matching: flushed Android MPV buffers '
'($reason, waited=${sw.elapsedMilliseconds}ms, '
'Frame rate matching: refreshed Android MPV decoder '
'($reason, target=${isLive ? 'live' : '${targetPosition.inMilliseconds}ms'}, waited=${sw.elapsedMilliseconds}ms, '
'gate=${timedOut ? 'timeout' : 'playback-restart'})',
);
} catch (e) {
appLogger.w('Failed to flush Android MPV buffers after frame rate switch ($reason)', error: e);
appLogger.w('Failed to refresh Android MPV decoder after frame rate switch ($reason)', error: e);
}
}
@@ -292,13 +292,13 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
final shouldAutoPlay = !shouldHoldPlaybackStart && (isExoPlayer || !hasExternalSubs);
if (needsAndroidMpvStartupRefresh) {
appLogger.d('Frame rate matching: opening Android MPV paused for startup buffer flush');
appLogger.d('Frame rate matching: opening Android MPV paused for startup decoder refresh');
androidMpvStartupReady = currentPlayer.streams.playbackRestart.first
.then((_) => true)
.timeout(
const Duration(seconds: 15),
onTimeout: () {
appLogger.w('Timed out waiting for Android MPV startup frame before buffer flush');
appLogger.w('Timed out waiting for Android MPV startup frame before decoder refresh');
return false;
},
);
@@ -503,15 +503,15 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
),
);
} else if (needsAndroidMpvStartupRefresh && mounted && player == currentPlayer) {
appLogger.d('Frame rate matching: waiting for Android MPV startup frame before buffer flush');
appLogger.d('Frame rate matching: waiting for Android MPV startup frame before decoder refresh');
final startupReady = androidMpvStartupReady == null ? false : await androidMpvStartupReady;
if (mounted && player == currentPlayer) {
if (startupReady) {
await Future<void>.delayed(const Duration(milliseconds: 100));
await _refreshAndroidMpvDecoderAfterFrameRateSwitch(reason: 'pre-load frame rate startup');
await resumeAfterStartupGate('startup buffer flush');
await resumeAfterStartupGate('startup decoder refresh');
} else {
appLogger.w('Frame rate matching: skipping Android MPV buffer flush because startup frame timed out');
appLogger.w('Frame rate matching: skipping Android MPV decoder refresh because startup frame timed out');
await resumeAfterStartupGate('startup frame timeout');
}
}
@@ -519,7 +519,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
unawaited(
Sentry.addBreadcrumb(
Breadcrumb(
message: 'Android MPV startup buffer flush after pre-load frame-rate switch',
message: 'Android MPV startup decoder refresh after pre-load frame-rate switch',
category: 'player',
),
),
+40
View File
@@ -147,6 +147,46 @@ void main() {
},
);
});
test('MPV refresh seek preserves timeline offset position', () async {
final calls = <MethodCall>[];
await _withMockChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) {
calls.add(call);
switch (call.method) {
case 'initialize':
return Future.value(true);
default:
return Future.value(null);
}
},
testBody: () async {
final player = PlayerNative();
try {
const timelineStart = Duration(milliseconds: 143894);
await player.open(
Media('https://example.test/transcode.mkv'),
timelineOffset: timelineStart,
timelineDuration: const Duration(seconds: 1502),
);
expect(player.state.position, timelineStart);
await player.seek(timelineStart);
final seekCall = calls.lastWhere((call) => call.method == 'command');
final args = Map<Object?, Object?>.from(seekCall.arguments as Map)['args'] as List;
expect(args, ['seek', '0.0', 'absolute']);
expect(player.state.position, timelineStart);
} finally {
await player.dispose();
}
},
);
});
});
}