import 'dart:async'; import '../media/ids.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../media/media_item.dart'; import '../media/media_version.dart'; import '../media/media_version_preference.dart'; import '../mpv/mpv.dart'; import '../models/transcode_quality_preset.dart'; import '../providers/download_provider.dart'; import '../providers/multi_server_provider.dart'; import '../providers/watch_state_store.dart'; import '../watch_together/providers/watch_together_provider.dart'; import '../screens/video_player_screen.dart'; import '../services/external_player_service.dart'; import '../services/local_playback_history.dart'; import '../services/offline_watch_sync_service.dart'; import '../services/settings_service.dart'; import 'app_logger.dart'; import 'global_key_utils.dart'; import 'platform_detector.dart'; const String kVideoPlayerRouteName = '/video_player'; /// The route contract shared by VOD and Live TV playback. /// /// The stable route name drives player lifecycle observation, while the /// opaque zero-duration route prevents the underlying detail screen flashing /// during player startup and teardown. PageRouteBuilder buildVideoPlayerRoute({required WidgetBuilder builder}) { return PageRouteBuilder( settings: const RouteSettings(name: kVideoPlayerRouteName), pageBuilder: (context, _, _) => builder(context), transitionDuration: Duration.zero, reverseTransitionDuration: Duration.zero, ); } enum VideoPlayerRouteKind { vod, liveTv } @immutable final class VideoPlayerLaunchIdentity { VideoPlayerLaunchIdentity({ required MediaItem metadata, required this.mediaIndex, required String? selectedMediaSourceId, required this.selectedQualityPreset, required this.isOffline, required this.routeKind, }) : globalKey = metadata.globalKey, mediaSourceId = _normalizeMediaSourceId(selectedMediaSourceId); final String globalKey; final int mediaIndex; final String? mediaSourceId; final TranscodeQualityPreset? selectedQualityPreset; final bool isOffline; final VideoPlayerRouteKind routeKind; static String? _normalizeMediaSourceId(String? mediaSourceId) { if (mediaSourceId == null || mediaSourceId.trim().isEmpty) return null; return mediaSourceId; } @override bool operator ==(Object other) { return identical(this, other) || other is VideoPlayerLaunchIdentity && other.globalKey == globalKey && other.mediaIndex == mediaIndex && other.mediaSourceId == mediaSourceId && other.selectedQualityPreset == selectedQualityPreset && other.isOffline == isOffline && other.routeKind == routeKind; } @override int get hashCode => Object.hash(globalKey, mediaIndex, mediaSourceId, selectedQualityPreset, isOffline, routeKind); } class VideoPlayerNavigationInFlightGuard { final Set _identities = {}; bool tryStart(VideoPlayerLaunchIdentity identity) => _identities.add(identity); void finish(VideoPlayerLaunchIdentity identity) => _identities.remove(identity); } class VideoPlayerActiveRouteGuard { Object? _owner; VideoPlayerLaunchIdentity? _identity; String? get activeGlobalKey => _identity?.globalKey; VideoPlayerLaunchIdentity? identityFor(Object owner) => identical(_owner, owner) ? _identity : null; bool blocks(VideoPlayerLaunchIdentity identity) => _identity == identity; void activate(Object owner, VideoPlayerLaunchIdentity identity) { _owner = owner; _identity = identity; } bool update(Object owner, VideoPlayerLaunchIdentity identity) { if (!identical(_owner, owner)) return false; _identity = identity; return true; } bool clear(Object owner) { if (!identical(_owner, owner)) return false; _owner = null; _identity = null; return true; } } final _videoPlayerNavigationInFlightGuard = VideoPlayerNavigationInFlightGuard(); class WatchTogetherPlaybackNavigationException implements Exception { final String message; const WatchTogetherPlaybackNavigationException(this.message); @override String toString() => message; } /// Series (keyed by grandparent) or standalone-item key under /// [SettingsService.mediaVersionPreferences], scoped by server — raw Plex /// rating keys are small integers that can collide across servers. String _mediaVersionPreferenceKey(MediaItem metadata) { final serverId = serverIdOrNull(metadata.serverId); final id = metadata.grandparentId ?? metadata.id; return serverId != null ? buildGlobalKey(serverId, id) : id; } /// Key entries were stored under before server scoping. Reads fall back to /// it; writes migrate it to the scoped key. String _legacyMediaVersionPreferenceKey(MediaItem metadata) => metadata.grandparentId ?? metadata.id; /// Entry cap for [SettingsService.mediaVersionPreferences]; oldest entries /// (by write time, legacy entries first) are evicted past it. const _maxMediaVersionPreferences = 500; /// Saved media-version preference for [metadata]'s series/movie, or null when /// none is stored. Shared by launch navigation and in-player version /// switching so reads and writes can't drift onto different keys. Future savedMediaVersionPreferenceFor(MediaItem metadata) async { try { final settingsService = await SettingsService.getInstance(); final prefs = settingsService.read(SettingsService.mediaVersionPreferences); return prefs[_mediaVersionPreferenceKey(metadata)] ?? prefs[_legacyMediaVersionPreferenceKey(metadata)]; } catch (_) { return null; } } /// Persist the version at [index] in [versions] as the preferred media /// version for [metadata]'s series/movie. Callers are explicit-selection /// sites only — plain plays and backend fallbacks must not write, so a /// server-side clamp can't silently overwrite the user's choice. Future saveMediaVersionPreferenceFor( MediaItem metadata, { required int index, required List versions, }) async { final settingsService = await SettingsService.getInstance(); final pref = index >= 0 && index < versions.length ? MediaVersionPreference.forVersion(versions[index], index) : MediaVersionPreference(index: index, updatedAt: DateTime.now().millisecondsSinceEpoch); final updated = {...settingsService.read(SettingsService.mediaVersionPreferences)} ..remove(_legacyMediaVersionPreferenceKey(metadata)) ..[_mediaVersionPreferenceKey(metadata)] = pref; await settingsService.write(SettingsService.mediaVersionPreferences, _pruneMediaVersionPreferences(updated)); } Map _pruneMediaVersionPreferences(Map prefs) { if (prefs.length <= _maxMediaVersionPreferences) return prefs; final entries = prefs.entries.toList()..sort((a, b) => (b.value.updatedAt ?? 0).compareTo(a.value.updatedAt ?? 0)); return Map.fromEntries(entries.take(_maxMediaVersionPreferences)); } /// A saved preference resolved for launch: the index to request plus the /// id/signature evidence for re-resolving it against the authoritative /// version list during playback initialization. typedef ResolvedMediaVersionPreference = ({int index, String? sourceId, String? signature}); /// Resolve the saved preference for [metadata] against its version list. /// /// When [MediaItem.mediaVersions] is populated (Plex hub/detail fetches) the /// index is verified and the matched version's real id is returned. When it /// isn't (Jellyfin resume rows omit `MediaSources`), the stored index and /// signature pass through with a null sourceId — an unverified id from a /// sibling episode would be meaningless downstream, while a signature is /// safely re-matched there. Future resolveSavedMediaVersionFor(MediaItem metadata) async { final pref = await savedMediaVersionPreferenceFor(metadata); if (pref == null) return null; final versions = metadata.mediaVersions ?? const []; if (versions.isEmpty) return (index: pref.index, sourceId: null, signature: pref.signature); final index = pref.resolveIndex(versions); if (index == null) return null; final version = versions[index]; return (index: index, sourceId: version.id.isEmpty ? null : version.id, signature: version.signature); } /// Navigates to the VideoPlayerScreen with instant transitions to prevent white flash. /// /// This utility function provides a consistent way to navigate to the video player /// across the app, using PageRouteBuilder with zero-duration transitions to eliminate /// the white flash that occurs with MaterialPageRoute. /// /// Parameters: /// - [context]: The build context for navigation /// - [metadata]: The neutral [MediaItem] for the content to play /// - [preferredAudioTrack]: Optional audio track to select on playback start /// - [preferredSubtitleTrack]: Optional subtitle track to select on playback start /// - [selectedMediaIndex]: Optional media version index to use; if not provided, /// loads the saved preference for the series/movie. Defaults to 0 if no preference exists. /// - [selectedMediaSourceId]: Optional stable backend source id for the chosen version. /// - [usePushReplacement]: If true, replaces current route instead of pushing; /// useful for episode-to-episode navigation. Defaults to false. /// - [isOffline]: If true, plays from downloaded content without requiring server connection. /// - [resolveWatchState]: Resolve [metadata] through [WatchStateStore] so the /// resume offset/watched flag are session-fresh even when the caller holds a /// stale list snapshot. Pass false for explicit intents like play-from-start. /// /// Returns a Future that completes with a boolean indicating whether the content /// was watched, or null if navigation was cancelled. Future navigateToVideoPlayer( BuildContext context, { required MediaItem metadata, AudioTrack? preferredAudioTrack, SubtitleTrack? preferredSubtitleTrack, SubtitleTrack? preferredSecondarySubtitleTrack, int? selectedMediaIndex, String? selectedMediaSourceId, TranscodeQualityPreset? selectedQualityPreset, bool usePushReplacement = false, bool isOffline = false, bool resolveWatchState = true, }) async { if (resolveWatchState) { metadata = context.readFreshWatchState(metadata); } final navigator = Navigator.of(context); final downloadProvider = context.read(); // Use the manager-routed lookup so Jellyfin items don't trip the // Plex-only client. The player branches on the returned type internally. final manager = context.read().serverManager; final offlineWatchService = context.read(); final serverId = serverIdOrNull(metadata.serverId); final mediaClient = serverId != null && (!isOffline || manager.isClientOnline(serverId)) ? manager.getClient(serverId) : null; // Plain Play on a downloaded item must target the version actually on // disk. Only one version can be downloaded per item, and saved version // preferences describe online intent — they may point at a version that // was never downloaded (issue #1440). Explicit caller selections still win. int? downloadedMediaIndex; String? downloadedMediaSourceId; if (isOffline && selectedMediaIndex == null && selectedMediaSourceId == null) { final downloaded = await downloadProvider.getCompletedDownload(metadata.globalKey); if (downloaded != null) { downloadedMediaIndex = downloaded.mediaIndex; downloadedMediaSourceId = downloaded.mediaSourceId; } } // Saved preferences only apply when nothing explicit is in play — an // explicit caller selection or a downloaded version must never be // second-guessed by a remembered choice. ResolvedMediaVersionPreference? savedVersion; if (selectedMediaIndex == null && selectedMediaSourceId == null && downloadedMediaIndex == null && downloadedMediaSourceId == null) { savedVersion = await resolveSavedMediaVersionFor(metadata); } final mediaIndex = selectedMediaIndex ?? downloadedMediaIndex ?? savedVersion?.index ?? 0; final mediaSourceId = selectedMediaSourceId ?? downloadedMediaSourceId ?? savedVersion?.sourceId; final launchIdentity = VideoPlayerLaunchIdentity( metadata: metadata, mediaIndex: mediaIndex, selectedMediaSourceId: mediaSourceId, selectedQualityPreset: selectedQualityPreset, isOffline: isOffline, routeKind: VideoPlayerRouteKind.vod, ); var markedInFlight = false; if (!usePushReplacement) { markedInFlight = _videoPlayerNavigationInFlightGuard.tryStart(launchIdentity); if (!markedInFlight) { appLogger.d( 'Video player navigation already in flight for ${metadata.id} (mediaIndex=$mediaIndex), ' 'skipping duplicate navigation', ); return null; } } try { // Check if external player is enabled try { final settingsService = await SettingsService.getInstance(); if (PlatformDetector.supportsExternalPlayers() && settingsService.read(SettingsService.useExternalPlayer)) { bool launched = false; if (isOffline) { final globalKey = metadata.globalKey; final videoPath = await downloadProvider.getVideoFilePath( globalKey, mediaIndex: mediaIndex, mediaSourceId: mediaSourceId, ); if (videoPath != null && context.mounted) { final videoUrl = videoPath.contains('://') ? videoPath : 'file://$videoPath'; launched = await ExternalPlayerService.launch( context: context, videoUrl: videoUrl, metadata: metadata, client: mediaClient, offlineWatchService: offlineWatchService, mediaIndex: mediaIndex, mediaSourceId: mediaSourceId, ); } } else if (context.mounted) { launched = await ExternalPlayerService.launch( context: context, metadata: metadata, client: mediaClient, offlineWatchService: offlineWatchService, mediaIndex: mediaIndex, mediaSourceId: mediaSourceId, ); } if (launched) { // External playback never reaches the in-player session commit, so // record the local last-played history here. if (!isOffline) unawaited(LocalPlaybackHistory.recordPlayback(metadata)); return null; } } } catch (e) { appLogger.w('External player launch failed, falling back to built-in player', error: e); } // Prevent stacking an identical video player when already active. if (!usePushReplacement && VideoPlayerScreenState.isNavigationActive(launchIdentity)) { appLogger.d( 'Video player already active for ${metadata.globalKey} (mediaIndex=$mediaIndex), skipping duplicate navigation', ); return null; } final route = buildVideoPlayerRoute( builder: (_) => VideoPlayerScreen( metadata: metadata, preferredAudioTrack: preferredAudioTrack, preferredSubtitleTrack: preferredSubtitleTrack, preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack, selectedMediaIndex: mediaIndex, selectedMediaSourceId: mediaSourceId, preferredVersionSignature: savedVersion?.signature, selectedQualityPreset: selectedQualityPreset, isOffline: isOffline, ), ); return usePushReplacement ? navigator.pushReplacement(route) : navigator.push(route); } finally { if (markedInFlight) { _videoPlayerNavigationInFlightGuard.finish(launchIdentity); } } } /// Navigates to the video player and optionally refreshes content when returning. /// /// This helper consolidates the common pattern of: /// 1. Navigating to the video player /// 2. Logging the return /// 3. Calling a refresh callback if not offline /// /// Parameters: /// - [context]: The build context for navigation /// - [metadata]: The neutral [MediaItem] for the content to play /// - [isOffline]: If true, plays from downloaded content /// - [onRefresh]: Optional callback to refresh data when returning from playback /// (only called when not offline) /// - All other parameters are passed through to [navigateToVideoPlayer] Future navigateToVideoPlayerWithRefresh( BuildContext context, { required MediaItem metadata, bool isOffline = false, VoidCallback? onRefresh, AudioTrack? preferredAudioTrack, SubtitleTrack? preferredSubtitleTrack, SubtitleTrack? preferredSecondarySubtitleTrack, int? selectedMediaIndex, String? selectedMediaSourceId, bool usePushReplacement = false, }) async { final result = await navigateToVideoPlayer( context, metadata: metadata, isOffline: isOffline, preferredAudioTrack: preferredAudioTrack, preferredSubtitleTrack: preferredSubtitleTrack, preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack, selectedMediaIndex: selectedMediaIndex, selectedMediaSourceId: selectedMediaSourceId, usePushReplacement: usePushReplacement, ); appLogger.d('Returned from playback, refreshing metadata'); if (!isOffline && onRefresh != null && context.mounted) { onRefresh(); } return result; } /// Resolves the current Watch Together media and opens the video player. /// /// Returns whether navigation was initiated. The fetch can outlive the /// dispatch that requested it (slow server, host switching again, dispatcher /// timeout); navigating then would stack a stale player route on top of the /// live one, so the key is re-validated against the session's current /// playback snapshot before the push. Future navigateToWatchTogetherPlayback( BuildContext context, { required String ratingKey, required ServerId serverId, VoidCallback? onBeforeNavigate, }) async { final multiServer = context.read(); final client = multiServer.getClientForServer(serverId); if (client == null) { throw const WatchTogetherPlaybackNavigationException('Watch Together server is unavailable'); } final metadata = await client.fetchItem(ratingKey); if (metadata == null) { throw const WatchTogetherPlaybackNavigationException('Current Watch Together media is unavailable'); } if (!context.mounted) return false; final watchTogether = context.read(); if (watchTogether.currentMediaRatingKey != ratingKey || watchTogether.currentMediaServerId != serverId) { appLogger.d('WatchTogether: Skipping stale navigation to $ratingKey'); return false; } onBeforeNavigate?.call(); unawaited(navigateToVideoPlayer(context, metadata: metadata)); return true; }