fix: android video surface lifecycle management

close #771
This commit is contained in:
edde746
2026-03-29 18:07:50 +02:00
parent 6a7644935f
commit ad8faab9ea
7 changed files with 534 additions and 96 deletions
@@ -387,6 +387,12 @@ class PlayerAndroid extends PlayerBase {
await invoke('clearVideoFrameRate');
}
@override
Future<void> updateFrame() async {
if (disposed || !initialized) return;
await invoke('updateFrame');
}
// ============================================
// Audio Focus
// ============================================
+1 -1
View File
@@ -266,7 +266,7 @@ class PlayerNative extends PlayerBase {
@override
Future<void> updateFrame() async {
if (disposed || !initialized) return;
if (Platform.isIOS || Platform.isMacOS || Platform.isLinux) {
if (Platform.isAndroid || Platform.isIOS || Platform.isMacOS || Platform.isLinux) {
await invoke('updateFrame');
}
}
+94 -43
View File
@@ -203,6 +203,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
bool _hiddenForBackground = false;
bool _autoPipEnabled = false;
int _rewindOnResume = 0;
Future<void> _lifecycleTransition = Future<void>.value();
/// Whether to skip lifecycle actions because PiP is active or about to start.
/// iOS auto-PiP is system-initiated during the background transition, so
@@ -342,24 +343,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Don't pause - user may still be watching
break;
case AppLifecycleState.hidden:
if (_shouldSkipForPip) break;
// Pause video on mobile (we don't support background playback)
if (PlatformDetector.isMobile(context)) {
if (player != null && _isPlayerInitialized) {
_wasPlayingBeforeInactive = player!.state.playing;
if (_wasPlayingBeforeInactive) {
player!.pause();
appLogger.d('Video paused due to app being hidden (mobile)');
}
}
}
// Hide render layer to stop Vulkan present loop and gate native events
if (player != null && _isPlayerInitialized) {
player!.setVisible(false);
_hiddenForBackground = true;
_liveTimelineTimer?.cancel();
appLogger.d('Render layer hidden due to app being hidden');
}
_enqueueLifecycleTransition('hidden', _handleAppHidden);
break;
case AppLifecycleState.paused:
if (_shouldSkipForPip) break;
@@ -371,19 +355,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
appLogger.d('Media controls cleared and wakelock disabled due to app being paused/backgrounded');
break;
case AppLifecycleState.resumed:
// Restore render layer if it was hidden for background
if (_hiddenForBackground && player != null && _isPlayerInitialized) {
player!.setVisible(true);
_hiddenForBackground = false;
if (_liveSessionIdentifier != null) {
_startLiveTimelineUpdates();
}
appLogger.d('Render layer restored after app resumed');
}
// Restore media controls and wakelock when app is resumed
if (_isPlayerInitialized && mounted) {
unawaited(_restoreMediaControlsAfterResume());
}
_enqueueLifecycleTransition('resumed', _handleAppResumed);
break;
case AppLifecycleState.detached:
// No action needed for this state
@@ -391,6 +363,73 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
}
void _enqueueLifecycleTransition(String label, Future<void> Function() transition) {
_lifecycleTransition = _lifecycleTransition
.catchError((Object error, StackTrace stackTrace) {
appLogger.w('Previous lifecycle transition failed', error: error, stackTrace: stackTrace);
})
.then((_) async {
if (!mounted) return;
try {
await transition();
} catch (e, stackTrace) {
appLogger.w('Lifecycle transition failed during $label', error: e, stackTrace: stackTrace);
}
});
}
Future<void> _handleAppHidden() async {
if (_shouldSkipForPip) return;
final currentPlayer = player;
if (currentPlayer == null || !_isPlayerInitialized) return;
// Pause first so Android MPV does not keep decoding against a transient
// background surface while the app is locking or hiding.
if (PlatformDetector.isMobile(context)) {
_wasPlayingBeforeInactive = currentPlayer.state.playing;
if (_wasPlayingBeforeInactive) {
try {
await currentPlayer.pause();
appLogger.d('Video paused due to app being hidden (mobile)');
} catch (e) {
appLogger.w('Failed to pause video before hiding render layer', error: e);
}
}
}
if (!mounted || currentPlayer != player) return;
_hiddenForBackground = true;
_liveTimelineTimer?.cancel();
await currentPlayer.setVisible(false);
appLogger.d('Render layer hidden due to app being hidden');
}
Future<void> _handleAppResumed() async {
final currentPlayer = player;
// Restore render layer if it was hidden for background, then force a
// video-output refresh before any auto-resume logic runs.
if (_hiddenForBackground && currentPlayer != null && _isPlayerInitialized) {
await currentPlayer.setVisible(true);
await currentPlayer.updateFrame();
if (!mounted || currentPlayer != player) return;
_hiddenForBackground = false;
if (_liveSessionIdentifier != null) {
_startLiveTimelineUpdates();
}
appLogger.d('Render layer restored after app resumed');
}
// Restore media controls and wakelock when app is resumed.
if (_isPlayerInitialized && mounted) {
await _restoreMediaControlsAfterResume();
}
}
Future<void> _initializePlayer() async {
try {
// Load buffer size from settings
@@ -597,6 +636,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
if (!widget.isOffline && !widget.isLive) {
final serverId = widget.metadata.serverId;
if (serverId != null) {
if (!mounted) return;
final serverManager = context.read<MultiServerProvider>().serverManager;
bool wasOffline = false;
_serverStatusSubscription = serverManager.statusStream.listen((statusMap) {
@@ -1012,7 +1052,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
});
_trackManager?.mediaInfo = null;
}
} catch (e) {
appLogger.e('Failed to start live TV playback', error: e);
_sendLiveTimeline('stopped');
@@ -1075,8 +1114,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Enable FFmpeg auto-reconnect for VOD streams (covers network drops up to 10 min)
if (!widget.isOffline && !widget.isLive) {
await player!.setProperty('stream-lavf-o',
'reconnect=1,reconnect_on_network_error=1,reconnect_streamed=1,reconnect_delay_max=600');
await player!.setProperty(
'stream-lavf-o',
'reconnect=1,reconnect_on_network_error=1,reconnect_streamed=1,reconnect_delay_max=600',
);
}
final hasExternalSubs = result.externalSubtitles.isNotEmpty;
@@ -1290,11 +1331,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
? mediaInfo?.subtitleTracks.where((t) => t.id == trackId).firstOrNull
: null;
offlineSubtitles.add(SubtitleTrack.uri(
'file://${entity.path}',
title: plexTrack?.displayTitle ?? plexTrack?.language ?? 'Subtitle $fileName',
language: plexTrack?.languageCode,
));
offlineSubtitles.add(
SubtitleTrack.uri(
'file://${entity.path}',
title: plexTrack?.displayTitle ?? plexTrack?.language ?? 'Subtitle $fileName',
language: plexTrack?.languageCode,
),
);
}
}
}
@@ -1562,13 +1605,19 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
receiver.onSeekForward = () async {
if (player == null) return;
final settings = await SettingsService.getInstance();
final target = clampSeekPosition(player!, player!.state.position + Duration(seconds: settings.getSeekTimeSmall()));
final target = clampSeekPosition(
player!,
player!.state.position + Duration(seconds: settings.getSeekTimeSmall()),
);
await player!.seek(target);
};
receiver.onSeekBackward = () async {
if (player == null) return;
final settings = await SettingsService.getInstance();
final target = clampSeekPosition(player!, player!.state.position - Duration(seconds: settings.getSeekTimeSmall()));
final target = clampSeekPosition(
player!,
player!.state.position - Duration(seconds: settings.getSeekTimeSmall()),
);
await player!.seek(target);
};
receiver.onVolumeUp = () async {
@@ -2124,8 +2173,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
Future<void> _setLiveStreamOptions() async {
final p = player!;
// FFmpeg HTTP protocol reconnection
await p.setProperty('stream-lavf-o',
'reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1,reconnect_delay_max=30');
await p.setProperty(
'stream-lavf-o',
'reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1,reconnect_delay_max=30',
);
// Demuxer: retry up to 1000 times on stream reload failures
await p.setProperty('demuxer-lavf-o', 'max_reload=1000');
await p.setProperty('force-seekable', 'no');