diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index 0a78714e..64120ac5 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -424,10 +424,14 @@ class PlayerNative implements Player { await setProperty('start', 'none'); } - await command(['loadfile', media.uri, 'replace']); + // Set pause BEFORE loadfile to prevent decoder from starting immediately. + // This is important for adding external subtitles before playback begins, + // avoiding a race condition that can freeze the video decoder on Android (issue #226). if (!play) { await setProperty('pause', 'yes'); } + + await command(['loadfile', media.uri, 'replace']); } @override diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 06d9507b..2c81a0b8 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -93,6 +93,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin StreamSubscription? _playbackRestartSubscription; bool _isReplacingWithVideo = false; // Flag to skip orientation restoration during video-to-video navigation bool _isDisposingForNavigation = false; + bool _waitingForExternalSubsTrackSelection = false; // Auto-play next episode Timer? _autoPlayTimer; @@ -407,6 +408,10 @@ class VideoPlayerScreenState extends State with WidgetsBindin await _applyFrameRateMatching(); } } + if (_waitingForExternalSubsTrackSelection) { + _waitingForExternalSubsTrackSelection = false; + _applyTrackSelection(); + } }); // Listen to position for completion detection (fallback for unreliable MPV events) @@ -483,9 +488,6 @@ class VideoPlayerScreenState extends State with WidgetsBindin appLogger.d('Adding ${externalSubtitles.length} external subtitle(s) to player'); - // Wait for media to be ready - await _waitForMediaReady(); - for (final subtitleTrack in externalSubtitles) { if (subtitleTrack.uri == null) continue; @@ -503,21 +505,6 @@ class VideoPlayerScreenState extends State with WidgetsBindin } } - /// Wait for media to be ready (duration > 0) - Future _waitForMediaReady() async { - if (player == null) return; - - int attempts = 0; - while (player!.state.duration.inMilliseconds == 0 && attempts < 100) { - await Future.delayed(const Duration(milliseconds: 100)); - attempts++; - } - - if (attempts >= 100) { - appLogger.w('Media ready timeout - proceeding anyway'); - } - } - /// Initialize the service layer Future _initializeServices() async { if (!mounted || player == null) return; @@ -744,7 +731,15 @@ class VideoPlayerScreenState extends State with WidgetsBindin final resumePosition = widget.metadata.viewOffset != null ? Duration(milliseconds: widget.metadata.viewOffset!) : null; - await player!.open(Media(result.videoUrl!, start: resumePosition, headers: plexHeaders)); + + // If we have external subtitles, open paused to add them before playback starts. + // This prevents a race condition on Android where adding subtitle tracks + // during active playback can freeze the video decoder (issue #226). + final hasExternalSubs = result.externalSubtitles.isNotEmpty; + await player!.open( + Media(result.videoUrl!, start: resumePosition, headers: plexHeaders), + play: !hasExternalSubs, + ); // Attach player to Watch Together session for sync (if in session) if (mounted && !widget.isOffline) { @@ -774,25 +769,39 @@ class VideoPlayerScreenState extends State with WidgetsBindin _videoPIPManager = VideoPIPManager(player: player!); } - // Add external subtitles to the player + // Add external subtitles while paused, then start playback if (result.externalSubtitles.isNotEmpty) { - await _addExternalSubtitles(result.externalSubtitles); + _hasFirstFrame.value = false; + _waitingForExternalSubsTrackSelection = true; + + try { + await _addExternalSubtitles(result.externalSubtitles); + } finally { + if (player != null && mounted) { + await player!.play(); + final pos = player!.state.position; + await player!.seek(pos.inMilliseconds > 0 ? pos : Duration.zero); + + // Fallback if playbackRestart doesn't fire + Future.delayed(const Duration(seconds: 3), () { + if (_waitingForExternalSubsTrackSelection && mounted) { + _waitingForExternalSubsTrackSelection = false; + _applyTrackSelection(); + } + }); + } + } + } else { + _trackLoadingSubscription?.cancel(); + _trackLoadingSubscription = player!.streams.tracks.listen((tracks) { + if (tracks.audio.isEmpty && tracks.subtitle.isEmpty) return; + + _trackLoadingSubscription?.cancel(); + _trackLoadingSubscription = null; + _applyTrackSelection(); + }); } } - - // Set up track loading subscription to apply track selection when tracks are loaded - _trackLoadingSubscription?.cancel(); - _trackLoadingSubscription = player!.streams.tracks.listen((tracks) { - // Only process when we have actual tracks loaded - if (tracks.audio.isEmpty && tracks.subtitle.isEmpty) return; - - // Cancel subscription after first load to avoid re-applying on every track change - _trackLoadingSubscription?.cancel(); - _trackLoadingSubscription = null; - - // Apply track selection using the service - _applyTrackSelection(); - }); } on PlaybackException catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message))); @@ -1736,19 +1745,22 @@ class VideoPlayerScreenState extends State with WidgetsBindin return ValueListenableBuilder( valueListenable: _hasFirstFrame, builder: (context, hasFrame, child) { - // Don't show spinner when exiting (just black overlay) - // Show spinner when (buffering OR loading) AND NOT exiting if ((!isBuffering && hasFrame) || _isExiting.value) return const SizedBox.shrink(); return Positioned.fill( - child: Center( - child: Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.5), - shape: BoxShape.circle, + child: Stack( + children: [ + if (!hasFrame) Container(color: Colors.black), + Center( + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + shape: BoxShape.circle, + ), + child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 3), + ), ), - child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 3), - ), + ], ), ); },