From f1867ddeb766e15ad3b0fefddceaa709b131e8bd Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 15 Apr 2026 23:13:45 +0200 Subject: [PATCH] fix: preserve PiP during episode auto-play close #865 --- lib/screens/video_player_screen.dart | 233 +++++++++++++++++++++++---- 1 file changed, 202 insertions(+), 31 deletions(-) diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 1137300c..c334f4f5 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -145,9 +145,11 @@ class VideoPlayerScreenState extends State with WidgetsBindin Player? player; bool _isPlayerInitialized = false; + late PlexMetadata _currentMetadata; PlexMetadata? _nextEpisode; PlexMetadata? _previousEpisode; bool _isLoadingNext = false; + bool _isSwappingEpisode = false; bool _showPlayNextDialog = false; bool _isPhone = false; List _availableVersions = []; @@ -256,7 +258,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin /// Get the correct PlexClient for this metadata's server PlexClient _getClientForMetadata(BuildContext context) { - return context.getClientForServer(widget.metadata.serverId!); + return context.getClientForServer(_currentMetadata.serverId!); } Uint8List? _getThumbnailData(Duration time) => _bifService?.getThumbnail(time); @@ -272,6 +274,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin void initState() { super.initState(); + _currentMetadata = widget.metadata; _activeRatingKey = widget.metadata.ratingKey; _activeMediaIndex = widget.selectedMediaIndex; @@ -949,7 +952,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin final offlineWatchService = context.read(); _progressTracker = PlaybackProgressTracker( client: null, - metadata: widget.metadata, + metadata: _currentMetadata, player: player!, isOffline: true, offlineWatchService: offlineWatchService, @@ -957,7 +960,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin _progressTracker!.startTracking(); } else if (client != null) { // Online mode: send progress to server - _progressTracker = PlaybackProgressTracker(client: client, metadata: widget.metadata, player: player!); + _progressTracker = PlaybackProgressTracker(client: client, metadata: _currentMetadata, player: player!); _progressTracker!.startTracking(); } @@ -1003,9 +1006,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Update media metadata (client can be null in offline mode - artwork won't be shown) await _mediaControlsManager!.updateMetadata( - metadata: widget.metadata, + metadata: _currentMetadata, client: client, - duration: widget.metadata.duration != null ? Duration(milliseconds: widget.metadata.duration!) : null, + duration: _currentMetadata.duration != null ? Duration(milliseconds: _currentMetadata.duration!) : null, ); if (!mounted) return; @@ -1038,7 +1041,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Start Discord Rich Presence for current media if (client != null) { - DiscordRPCService.instance.startPlayback(widget.metadata, client); + DiscordRPCService.instance.startPlayback(_currentMetadata, client); } } @@ -1053,7 +1056,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (widget.isLive) return; // Only create play queues for episodes - if (!widget.metadata.isEpisode) { + if (!_currentMetadata.isEpisode) { return; } @@ -1064,7 +1067,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Determine the show's rating key // For episodes, grandparentRatingKey points to the show - final showRatingKey = widget.metadata.grandparentRatingKey; + final showRatingKey = _currentMetadata.grandparentRatingKey; if (showRatingKey == null) { appLogger.d('Episode missing grandparentRatingKey, skipping play queue creation'); return; @@ -1077,7 +1080,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (isQueueActive) { // A queue already exists (could be shuffle, playlist, or sequential) // Just update the current item, don't create a new queue - playbackState.setCurrentItem(widget.metadata); + playbackState.setCurrentItem(_currentMetadata); appLogger.d('Using existing play queue (context: $existingContextKey)'); return; } @@ -1087,7 +1090,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin final playQueue = await client.createShowPlayQueue( showRatingKey: showRatingKey, shuffle: 0, // Sequential order - startingEpisodeKey: widget.metadata.ratingKey, + startingEpisodeKey: _currentMetadata.ratingKey, ); if (playQueue != null && playQueue.items != null && playQueue.items!.isNotEmpty) { @@ -1118,7 +1121,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Load adjacent episodes using the service final adjacentEpisodes = await _episodeNavigation.loadAdjacentEpisodes( context: context, - metadata: widget.metadata, + metadata: _currentMetadata, ); if (mounted) { @@ -1135,9 +1138,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin /// Load next/previous episodes from locally downloaded content void _loadAdjacentEpisodesOffline() { - if (!widget.metadata.isEpisode) return; + if (!_currentMetadata.isEpisode) return; - final showKey = widget.metadata.grandparentRatingKey; + final showKey = _currentMetadata.grandparentRatingKey; if (showKey == null) return; try { @@ -1162,7 +1165,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin }); // Find current episode in the sorted list - final currentIdx = sorted.indexWhere((ep) => ep.ratingKey == widget.metadata.ratingKey); + final currentIdx = sorted.indexWhere((ep) => ep.ratingKey == _currentMetadata.ratingKey); if (currentIdx == -1) return; @@ -1299,7 +1302,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin plexHeaders = client.config.headers; final playbackService = PlaybackInitializationService(client: client, database: PlexApiCache.instance.database); result = await playbackService.getPlaybackData( - metadata: widget.metadata, + metadata: _currentMetadata, selectedMediaIndex: widget.selectedMediaIndex, preferOffline: true, // Use downloaded file if available playbackData: widget.playbackData, @@ -1321,15 +1324,15 @@ class VideoPlayerScreenState extends State with WidgetsBindin // since the user may have watched further since downloading. Duration? resumePosition; if (widget.isOffline) { - final globalKey = widget.metadata.globalKey; + final globalKey = _currentMetadata.globalKey; final localOffset = await offlineWatchService!.getLocalViewOffset(globalKey); if (localOffset != null && localOffset > 0) { resumePosition = Duration(milliseconds: localOffset); appLogger.d('Resuming offline playback from local progress: ${localOffset}ms'); } } - resumePosition ??= widget.metadata.viewOffset != null - ? Duration(milliseconds: widget.metadata.viewOffset!) + resumePosition ??= _currentMetadata.viewOffset != null + ? Duration(milliseconds: _currentMetadata.viewOffset!) : null; // Enable FFmpeg auto-reconnect for VOD streams (covers network drops up to 10 min) @@ -1432,7 +1435,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin getClient: () => _getClientForMetadata(context), getProfileSettings: () => context.read().profileSettings, waitForProfileSettings: _waitForProfileSettingsIfNeeded, - metadata: widget.metadata, + metadata: _currentMetadata, mediaInfo: _currentMediaInfo, preferredAudioTrack: widget.preferredAudioTrack, preferredSubtitleTrack: widget.preferredSubtitleTrack, @@ -1480,9 +1483,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin final downloadProvider = context.read(); // Debug: log metadata info - appLogger.d('Offline playback - serverId: ${widget.metadata.serverId}, ratingKey: ${widget.metadata.ratingKey}'); + appLogger.d('Offline playback - serverId: ${_currentMetadata.serverId}, ratingKey: ${_currentMetadata.ratingKey}'); - final globalKey = widget.metadata.globalKey; + final globalKey = _currentMetadata.globalKey; appLogger.d('Looking up video with globalKey: $globalKey'); final videoPath = await downloadProvider.getVideoFilePath(globalKey); @@ -1496,9 +1499,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Load cached media info so track selection (audio language) works offline PlexMediaInfo? mediaInfo; try { - final serverId = widget.metadata.serverId; + final serverId = _currentMetadata.serverId; if (serverId != null) { - final cached = await PlexApiCache.instance.get(serverId, '/library/metadata/${widget.metadata.ratingKey}'); + final cached = await PlexApiCache.instance.get(serverId, '/library/metadata/${_currentMetadata.ratingKey}'); final metadataJson = PlexCacheParser.extractFirstMetadata(cached); if (metadataJson != null) { mediaInfo = PlexMediaInfo.fromMetadataJson(metadataJson); @@ -1771,9 +1774,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin } /// Notify watch together session of current media change (host only) - /// If [metadata] is provided, uses that instead of widget.metadata (for episode navigation) + /// If [metadata] is provided, uses that instead of _currentMetadata (for episode navigation) void _notifyWatchTogetherMediaChange({PlexMetadata? metadata}) { - final targetMetadata = metadata ?? widget.metadata; + final targetMetadata = metadata ?? _currentMetadata; try { final watchTogether = context.read(); if (watchTogether.isHost && watchTogether.isInSession) { @@ -2110,7 +2113,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin Sentry.addBreadcrumb(Breadcrumb(message: 'Player dispose', category: 'player')); player?.dispose(); - if (_activeRatingKey == widget.metadata.ratingKey) { + if (_activeRatingKey == _currentMetadata.ratingKey) { _activeRatingKey = null; _activeMediaIndex = null; } @@ -2168,6 +2171,8 @@ class VideoPlayerScreenState extends State with WidgetsBindin // inter-segment gaps in the chunked MKV transcode stream. if (widget.isLive) return; if (!completed) return; + // Ignore spurious EOF from the old file during in-place episode swap + if (_isSwappingEpisode) return; // mpv does not flip the `pause` property on EOF, so _onPlayingStateChanged // never fires false. Normalize all playback-dependent state. @@ -2182,6 +2187,12 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (_nextEpisode != null && !_showPlayNextDialog && !_showStillWatchingPrompt && !_completionTriggered) { _completionTriggered = true; + // PiP: skip dialog (user can't interact), auto-play immediately + if (PipService().isPipActive.value) { + _playNext(); + return; + } + // Capture keyboard mode before async gap final isKeyboardMode = PlatformDetector.isTV() && InputModeTracker.isKeyboardMode(context); @@ -2257,7 +2268,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (!mounted || manager == null || currentPlayer == null) return; final playbackState = context.read(); - final canNavigateEpisodes = widget.metadata.isEpisode || playbackState.isPlaylistActive; + final canNavigateEpisodes = _currentMetadata.isEpisode || playbackState.isPlaylistActive; final canSeek = !widget.isLive && currentPlayer.state.seekable; if (!mounted || currentPlayer != player || manager != _mediaControlsManager) return; @@ -2285,9 +2296,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (manager != null && currentPlayer != null) { final client = widget.isOffline ? null : _getClientForMetadata(context); await manager.updateMetadata( - metadata: widget.metadata, + metadata: _currentMetadata, client: client, - duration: widget.metadata.duration != null ? Duration(milliseconds: widget.metadata.duration!) : null, + duration: _currentMetadata.duration != null ? Duration(milliseconds: _currentMetadata.duration!) : null, ); await _syncMediaControlsAvailability(); } @@ -2771,8 +2782,15 @@ class VideoPlayerScreenState extends State with WidgetsBindin _isReplacingWithVideo = true; } - /// Navigates to a new episode, preserving playback state and track selections + /// Navigates to a new episode, preserving playback state and track selections. + /// When PiP is active, swaps the media source in-place to keep the PiP window alive. Future _navigateToEpisode(PlexMetadata episodeMetadata) async { + // PiP active: swap media in-place to keep PiP window alive + if (PipService().isPipActive.value && player != null) { + await _swapEpisodeInPip(episodeMetadata); + return; + } + // Set flag to skip orientation restoration in dispose() _isReplacingWithVideo = true; @@ -2833,6 +2851,159 @@ class VideoPlayerScreenState extends State with WidgetsBindin } } + /// Swap to a new episode while keeping the player alive for PiP continuity. + /// Reuses the existing mpv instance (and its Metal layer in PiP) and only + /// reloads the media source + resets Dart-side services. + Future _swapEpisodeInPip(PlexMetadata episodeMetadata) async { + _isSwappingEpisode = true; + final currentPlayer = player!; + final previousMetadata = _currentMetadata; + + final currentAudioTrack = currentPlayer.state.track.audio; + final currentSubtitleTrack = currentPlayer.state.track.subtitle; + final currentSecondarySubtitleTrack = currentPlayer.state.track.secondarySubtitle; + + // Capture context-dependent values before async gaps + final client = widget.isOffline ? null : _getClientForMetadata(context); + final plexHeaders = client?.config.headers; + final offlineWatchService = widget.isOffline ? context.read() : null; + final userProfileProvider = context.read(); + final playbackState = context.read(); + + await _progressTracker?.sendProgress('stopped'); + _progressTracker?.stopTracking(); + _progressTracker?.dispose(); + _progressTracker = null; + DiscordRPCService.instance.stopPlayback(); + + _currentMetadata = episodeMetadata; + _activeRatingKey = episodeMetadata.ratingKey; + _showPlayNextDialog = false; + _autoPlayTimer?.cancel(); + _hasFirstFrame.value = false; + + try { + PlaybackInitializationResult result; + + if (widget.isOffline) { + result = await _startOfflinePlayback(); + } else { + final playbackService = PlaybackInitializationService(client: client!, database: PlexApiCache.instance.database); + result = await playbackService.getPlaybackData( + metadata: episodeMetadata, + selectedMediaIndex: widget.selectedMediaIndex, + preferOffline: true, + ); + } + + if (result.videoUrl == null) { + throw PlaybackException('No video URL available'); + } + + Duration? resumePosition; + if (widget.isOffline) { + final localOffset = await offlineWatchService!.getLocalViewOffset(episodeMetadata.globalKey); + if (localOffset != null && localOffset > 0) { + resumePosition = Duration(milliseconds: localOffset); + } + } + resumePosition ??= episodeMetadata.viewOffset != null + ? Duration(milliseconds: episodeMetadata.viewOffset!) + : null; + + final hasExternalSubs = result.externalSubtitles.isNotEmpty; + final isExoPlayer = player is PlayerAndroid; + await currentPlayer.open( + Media(result.videoUrl!, start: resumePosition, headers: plexHeaders), + play: isExoPlayer || !hasExternalSubs, + externalSubtitles: isExoPlayer && hasExternalSubs ? result.externalSubtitles : null, + ); + + _completionTriggered = false; + _isSwappingEpisode = false; + + if (!mounted) return; + + _bifService?.dispose(); + setState(() { + _availableVersions = result.availableVersions.cast(); + _currentMediaInfo = result.mediaInfo; + _bifService = null; + _isLoadingNext = false; + }); + + _trackManager?.dispose(); + _trackManager = TrackManager( + player: currentPlayer, + isActive: () => mounted && player != null, + getClient: () => client!, + getProfileSettings: () => userProfileProvider.profileSettings, + waitForProfileSettings: _waitForProfileSettingsIfNeeded, + metadata: episodeMetadata, + mediaInfo: _currentMediaInfo, + preferredAudioTrack: currentAudioTrack, + preferredSubtitleTrack: currentSubtitleTrack, + preferredSecondarySubtitleTrack: currentSecondarySubtitleTrack, + showMessage: (message, {duration}) { + if (mounted) showAppSnackBar(context, message, duration: duration); + }, + ); + _trackManager!.cacheExternalSubtitles(result.externalSubtitles); + + if (player is! PlayerAndroid && hasExternalSubs) { + _trackManager!.waitingForExternalSubsTrackSelection = true; + try { + await _trackManager!.addExternalSubtitles(result.externalSubtitles); + } finally { + await _trackManager!.resumeAfterSubtitleLoad(); + } + } else { + _trackManager!.applyTrackSelectionWhenReady(); + } + + if (widget.isOffline) { + _progressTracker = PlaybackProgressTracker( + client: null, + metadata: episodeMetadata, + player: currentPlayer, + isOffline: true, + offlineWatchService: offlineWatchService, + ); + } else { + _progressTracker = PlaybackProgressTracker(client: client, metadata: episodeMetadata, player: currentPlayer); + } + _progressTracker!.startTracking(); + + if (_mediaControlsManager != null) { + await _mediaControlsManager!.updateMetadata( + metadata: episodeMetadata, + client: client, + duration: episodeMetadata.duration != null ? Duration(milliseconds: episodeMetadata.duration!) : null, + ); + } + + if (client != null) { + DiscordRPCService.instance.startPlayback(episodeMetadata, client); + } + + try { + playbackState.setCurrentItem(episodeMetadata); + } catch (_) {} + + await _loadAdjacentEpisodes(); + + if (_autoPipEnabled) { + _videoPIPManager?.updateAutoPipState(isPlaying: currentPlayer.state.playing); + } + } catch (e) { + _isSwappingEpisode = false; + _completionTriggered = false; + _currentMetadata = previousMetadata; + _activeRatingKey = previousMetadata.ratingKey; + appLogger.e('Failed to swap episode in PiP', error: e); + } + } + /// Dispose the player before replacing the video to avoid race conditions Future disposePlayerForNavigation() async { if (_isDisposingForNavigation) return; @@ -3040,7 +3211,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin player: player!, controls: (context) => plexVideoControlsBuilder( player!, - widget.metadata, + _currentMetadata, onNext: onNext, onPrevious: onPrevious, availableVersions: _availableVersions,