fix(android): preserve mpv frame-rate startup position
This commit is contained in:
@@ -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',
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user