fix: preserve PiP during episode auto-play

close #865
This commit is contained in:
edde746
2026-04-15 23:16:04 +02:00
parent 79f7de1b4c
commit f1867ddeb7
+202 -31
View File
@@ -145,9 +145,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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<PlexMediaVersion> _availableVersions = [];
@@ -256,7 +258,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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<VideoPlayerScreen> with WidgetsBindin
void initState() {
super.initState();
_currentMetadata = widget.metadata;
_activeRatingKey = widget.metadata.ratingKey;
_activeMediaIndex = widget.selectedMediaIndex;
@@ -949,7 +952,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
final offlineWatchService = context.read<OfflineWatchSyncService>();
_progressTracker = PlaybackProgressTracker(
client: null,
metadata: widget.metadata,
metadata: _currentMetadata,
player: player!,
isOffline: true,
offlineWatchService: offlineWatchService,
@@ -957,7 +960,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> with WidgetsBindin
getClient: () => _getClientForMetadata(context),
getProfileSettings: () => context.read<UserProfileProvider>().profileSettings,
waitForProfileSettings: _waitForProfileSettingsIfNeeded,
metadata: widget.metadata,
metadata: _currentMetadata,
mediaInfo: _currentMediaInfo,
preferredAudioTrack: widget.preferredAudioTrack,
preferredSubtitleTrack: widget.preferredSubtitleTrack,
@@ -1480,9 +1483,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
final downloadProvider = context.read<DownloadProvider>();
// 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<WatchTogetherProvider>();
if (watchTogether.isHost && watchTogether.isInSession) {
@@ -2110,7 +2113,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> with WidgetsBindin
if (!mounted || manager == null || currentPlayer == null) return;
final playbackState = context.read<PlaybackStateProvider>();
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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<void> _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<VideoPlayerScreen> 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<void> _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<OfflineWatchSyncService>() : null;
final userProfileProvider = context.read<UserProfileProvider>();
final playbackState = context.read<PlaybackStateProvider>();
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<void> disposePlayerForNavigation() async {
if (_isDisposingForNavigation) return;
@@ -3040,7 +3211,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
player: player!,
controls: (context) => plexVideoControlsBuilder(
player!,
widget.metadata,
_currentMetadata,
onNext: onNext,
onPrevious: onPrevious,
availableVersions: _availableVersions,