diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 9ee691dc..9b31f97e 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -1952,107 +1952,131 @@ class VideoPlayerScreenState extends State with WidgetsBindin ); } - /// Handle audio track changes from the user - save both stream selection and language preference - Future _onAudioTrackChanged(AudioTrack track) async { + /// Rating key used for series/movie level language preferences. + String get _preferenceRatingKey { + return widget.metadata.isEpisode + ? (widget.metadata.grandparentRatingKey ?? widget.metadata.ratingKey) + : widget.metadata.ratingKey; + } + + /// Common guard checks for track change handlers. + /// Returns the part ID if all checks pass, or null if the change should be skipped. + Future _guardTrackChange() async { final settings = await SettingsService.getInstance(); + if (!settings.getRememberTrackSelections()) return null; - // Only save if remember track selections is enabled - if (!settings.getRememberTrackSelections()) { - return; - } if (_currentMediaInfo == null) { appLogger.w('No media info available, cannot save stream selection'); - return; + return null; } + final partId = _currentMediaInfo!.getPartId(); if (partId == null) { appLogger.w('No part ID available, cannot save stream selection'); - return; } + return partId; + } - final languageCode = track.language; - int? streamID; + /// Save language preference and stream selection to the server. + Future _saveTrackPreferences({ + required int partId, + required String trackType, + String? languageCode, + int? streamID, + }) async { + try { + if (!mounted) return; + final client = _getClientForMetadata(context); + final ratingKey = _preferenceRatingKey; - // === Matching by attributes === - PlexAudioTrack? matched; - final normalizedTrackLang = _iso6391ToPlex6392(track.language); + final futures = []; - appLogger.d('Normalized media_kit language: ${track.language} -> $normalizedTrackLang'); + if (languageCode != null && (trackType == 'subtitle' || languageCode.isNotEmpty)) { + futures.add( + trackType == 'audio' + ? client.setMetadataPreferences(ratingKey, audioLanguage: languageCode) + : client.setMetadataPreferences(ratingKey, subtitleLanguage: languageCode), + ); + } + if (streamID != null) { + futures.add( + trackType == 'audio' + ? client.selectStreams(partId, audioStreamID: streamID, allParts: true) + : client.selectStreams(partId, subtitleStreamID: streamID, allParts: true), + ); + } - for (final plexTrack in _currentMediaInfo!.audioTracks) { - final matchLang = plexTrack.languageCode == normalizedTrackLang; - final matchTitle = (track.title == null || track.title!.isEmpty) + await Future.wait(futures); + appLogger.d('Successfully saved $trackType preferences (language + stream)'); + } catch (e) { + appLogger.e('Failed to save $trackType preferences', error: e); + } + } + + /// Match an mpv track against Plex tracks by language and title. + int? _matchTrackByAttributes({ + required String? mpvLanguage, + required String? mpvTitle, + required List plexTracks, + required String? Function(T) getLanguageCode, + required String? Function(T) getDisplayTitle, + required String? Function(T) getTitle, + required int Function(T) getId, + }) { + final normalizedLang = _iso6391ToPlex6392(mpvLanguage); + + for (final plexTrack in plexTracks) { + final matchLang = getLanguageCode(plexTrack) == normalizedLang; + final matchTitle = (mpvTitle == null || mpvTitle.isEmpty) ? true - : (plexTrack.displayTitle == track.title || plexTrack.title == track.title); + : (getDisplayTitle(plexTrack) == mpvTitle || getTitle(plexTrack) == mpvTitle); if (matchLang && matchTitle) { - matched = plexTrack; - appLogger.d('Matched audio by lang/title: streamID ${matched.id}'); - break; + return getId(plexTrack); } } + return null; + } - if (matched != null) { - streamID = matched.id; + /// Handle audio track changes from the user - save both stream selection and language preference + Future _onAudioTrackChanged(AudioTrack track) async { + final partId = await _guardTrackChange(); + if (partId == null) return; + + int? streamID = _matchTrackByAttributes( + mpvLanguage: track.language, + mpvTitle: track.title, + plexTracks: _currentMediaInfo!.audioTracks, + getLanguageCode: (t) => t.languageCode, + getDisplayTitle: (t) => t.displayTitle, + getTitle: (t) => t.title, + getId: (t) => t.id, + ); + + if (streamID != null) { appLogger.d('Matched audio by lang/title: streamID $streamID'); } else { - // Use property-based matching from track_selection_service final matchedPlex = findPlexTrackForMpvAudio(track, _currentMediaInfo!.audioTracks); - - if (matchedPlex != null) { - streamID = matchedPlex.id; + streamID = matchedPlex?.id; + if (streamID != null) { appLogger.d('Matched audio by properties: streamID $streamID'); } else { appLogger.e('Could not match audio track to any Plex track'); } } - final isEpisode = widget.metadata.isEpisode; - final languagePrefRatingKey = isEpisode - ? (widget.metadata.grandparentRatingKey ?? widget.metadata.ratingKey) - : widget.metadata.ratingKey; - - try { - if (!mounted) return; - final client = _getClientForMetadata(context); - - final futures = []; - - // 1. Language preference (series/movie level) - if (languageCode != null && languageCode.isNotEmpty) { - futures.add(client.setMetadataPreferences(languagePrefRatingKey, audioLanguage: languageCode)); - } - // 2. Exact stream selection (part level) - if (streamID != null) { - futures.add(client.selectStreams(partId, audioStreamID: streamID, allParts: true)); - } - - await Future.wait(futures); - appLogger.d('Successfully saved audio preferences (language + stream)'); - } catch (e) { - appLogger.e('Failed to save audio preferences', error: e); - } + await _saveTrackPreferences( + partId: partId, + trackType: 'audio', + languageCode: track.language, + streamID: streamID, + ); } /// Handle subtitle track changes from the user - save both stream selection and language preference Future _onSubtitleTrackChanged(SubtitleTrack track) async { - final settings = await SettingsService.getInstance(); - - // Only save if remember track selections is enabled - if (!settings.getRememberTrackSelections()) { - return; - } - - if (_currentMediaInfo == null) { - appLogger.w('No media info available, cannot save stream selection'); - return; - } - - final partId = _currentMediaInfo!.getPartId(); - if (partId == null) { - appLogger.w('No part ID available, cannot save stream selection'); - return; - } + final partId = await _guardTrackChange(); + if (partId == null) return; String? languageCode; int? streamID; @@ -2064,38 +2088,22 @@ class VideoPlayerScreenState extends State with WidgetsBindin } else { languageCode = track.language; - // === Matching by attributes === - PlexSubtitleTrack? matched; - final normalizedTrackLang = _iso6391ToPlex6392(track.language); + streamID = _matchTrackByAttributes( + mpvLanguage: track.language, + mpvTitle: track.title, + plexTracks: _currentMediaInfo!.subtitleTracks, + getLanguageCode: (t) => t.languageCode, + getDisplayTitle: (t) => t.displayTitle, + getTitle: (t) => t.title, + getId: (t) => t.id, + ); - appLogger.d('Normalized media_kit language: ${track.language} -> $normalizedTrackLang'); - - for (final plexTrack in _currentMediaInfo!.subtitleTracks) { - final matchLang = plexTrack.languageCode == normalizedTrackLang; - final matchTitle = (track.title == null || track.title!.isEmpty) - ? true - : (plexTrack.displayTitle == track.title || plexTrack.title == track.title); - - appLogger.d('Comparing with streamID ${plexTrack.id}:'); - appLogger.d(' matchLang: $matchLang (${plexTrack.languageCode} == $normalizedTrackLang)'); - appLogger.d(' matchTitle: $matchTitle'); - - if (matchLang && matchTitle) { - matched = plexTrack; - appLogger.d(' ✅ MATCHED!'); - break; - } - } - - if (matched != null) { - streamID = matched.id; + if (streamID != null) { appLogger.d('Matched subtitle by lang/title: streamID $streamID'); } else { - // Use property-based matching from track_selection_service final matchedPlex = findPlexTrackForMpvSubtitle(track, _currentMediaInfo!.subtitleTracks); - - if (matchedPlex != null) { - streamID = matchedPlex.id; + streamID = matchedPlex?.id; + if (streamID != null) { appLogger.d('Matched subtitle by properties: streamID $streamID'); } else { appLogger.e('Could not match subtitle track to any Plex track'); @@ -2103,36 +2111,12 @@ class VideoPlayerScreenState extends State with WidgetsBindin } } - // Determine ratingKeys - final isEpisode = widget.metadata.isEpisode; - final languagePrefRatingKey = isEpisode - ? (widget.metadata.grandparentRatingKey ?? widget.metadata.ratingKey) - : widget.metadata.ratingKey; - - appLogger.i( - 'Saving subtitle preference: language=$languageCode (ratingKey: $languagePrefRatingKey), streamID=$streamID (partId: $partId)', + await _saveTrackPreferences( + partId: partId, + trackType: 'subtitle', + languageCode: languageCode, + streamID: streamID, ); - - try { - if (!mounted) return; - final client = _getClientForMetadata(context); - - final futures = []; - - // 1. Save language preference at series/movie level - if (languageCode != null) { - futures.add(client.setMetadataPreferences(languagePrefRatingKey, subtitleLanguage: languageCode)); - } - // 2. Save exact stream selection using part ID - if (streamID != null) { - futures.add(client.selectStreams(partId, subtitleStreamID: streamID, allParts: true)); - } - - await Future.wait(futures); - appLogger.d('Successfully saved subtitle preferences (language + stream)'); - } catch (e) { - appLogger.e('Failed to save subtitle preferences', error: e); - } } /// Set flag to skip orientation restoration when replacing with another video diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index ff478605..3effd15a 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -557,6 +557,27 @@ class PlexClient { } } + /// Fetch data with cache checked first, network only on cache miss. + /// + /// Use this when fresh data is not critical and prior fetches likely + /// already populated the cache (e.g. playback after visiting detail screen). + Future _fetchWithCacheFirst({ + required String cacheKey, + required Future Function() networkCall, + required T? Function(dynamic cachedData) parseCache, + required T? Function(Response response) parseResponse, + bool cacheResponse = true, + }) async { + final cached = await _cache.get(serverId, cacheKey); + if (cached != null) return parseCache(cached); + if (_offlineMode) return null; + final response = await networkCall(); + if (cacheResponse && response.data != null) { + await _cache.put(serverId, cacheKey, response.data); + } + return parseResponse(response); + } + /// Get first metadata JSON from response data Map? _getFirstMetadataJsonFromData(Map? data) => PlexCacheParser.extractFirstMetadata(data); @@ -651,6 +672,23 @@ class PlexClient { }).toList(); } + /// Parse markers from metadata JSON + List _parseMarkers(Map? metadataJson) { + if (metadataJson == null || metadataJson['Marker'] == null) { + return []; + } + + final markerList = metadataJson['Marker'] as List; + return markerList.map((marker) { + return PlexMarker( + id: marker['id'] as int, + type: marker['type'] as String, + startTimeOffset: marker['startTimeOffset'] as int, + endTimeOffset: marker['endTimeOffset'] as int, + ); + }).toList(); + } + /// Set per-media language preferences (audio and subtitle) /// For TV shows, use grandparentRatingKey to set preference for the entire series /// For movies, use the movie's ratingKey @@ -869,145 +907,31 @@ class PlexClient { } } - /// Get chapters for a media item - Future> getChapters(String ratingKey) async { - final response = await _dio.get('/library/metadata/$ratingKey', queryParameters: {'includeChapters': 1}); - - final metadataJson = _getFirstMetadataJson(response); - if (metadataJson != null && metadataJson['Chapter'] != null) { - final chapterList = metadataJson['Chapter'] as List; - return chapterList.map((chapter) { - return PlexChapter( - id: chapter['id'] as int, - index: chapter['index'] as int?, - startTimeOffset: chapter['startTimeOffset'] as int?, - endTimeOffset: chapter['endTimeOffset'] as int?, - title: chapter['tag'] as String?, - thumb: chapter['thumb'] as String?, - ); - }).toList(); - } - - return []; - } - - Future> getMarkers(String ratingKey) async { - final response = await _dio.get('/library/metadata/$ratingKey', queryParameters: {'includeMarkers': 1}); - - final metadataJson = _getFirstMetadataJson(response); - - if (metadataJson != null && metadataJson['Marker'] != null) { - final markerList = metadataJson['Marker'] as List; - return markerList.map((marker) { - return PlexMarker( - id: marker['id'] as int, - type: marker['type'] as String, - startTimeOffset: marker['startTimeOffset'] as int, - endTimeOffset: marker['endTimeOffset'] as int, - ); - }).toList(); - } - - return []; - } - /// Get chapters and markers from cached metadata or fetch if needed /// Uses same cache key as other metadata methods for consistency Future getPlaybackExtras(String ratingKey) async { - // Use same cache key as other metadata methods - final cacheKey = '/library/metadata/$ratingKey'; - - // If offline mode, return from cache only - if (_offlineMode) { - final cached = await _cache.get(serverId, cacheKey); - if (cached != null) { - return _parsePlaybackExtrasFromCachedResponse(cached); - } - return PlaybackExtras(chapters: [], markers: []); - } - - // Online: check cache first (already has chapters/markers from other fetches) - appLogger.d('getPlaybackExtras: serverId=$serverId, cacheKey=$cacheKey'); - final cached = await _cache.get(serverId, cacheKey); - if (cached != null) { - final chapters = PlexCacheParser.extractChapters(cached); - appLogger.d('getPlaybackExtras: cache hit, ${chapters?.length ?? 0} chapters'); - return _parsePlaybackExtrasFromCachedResponse(cached); - } - appLogger.d('getPlaybackExtras: cache miss'); - - // Not in cache - fetch and cache try { - final response = await _dio.get( - '/library/metadata/$ratingKey', - queryParameters: {'includeChapters': 1, 'includeMarkers': 1}, + final data = await _fetchWithCacheFirst>( + cacheKey: '/library/metadata/$ratingKey', + networkCall: () => + _dio.get('/library/metadata/$ratingKey', queryParameters: {'includeChapters': 1, 'includeMarkers': 1}), + parseCache: (cached) => cached as Map?, + parseResponse: (response) => response.data as Map?, ); - - // Cache at base endpoint - if (response.data != null) { - await _cache.put(serverId, cacheKey, response.data); - } - - return _parsePlaybackExtrasFromResponse(response); + final metadataJson = _getFirstMetadataJsonFromData(data); + return _parsePlaybackExtrasFromMetadataJson(metadataJson); } catch (e) { - // Network failed - appLogger.w('Network request failed for playback extras', error: e); + appLogger.w('Failed to get playback extras', error: e); return PlaybackExtras(chapters: [], markers: []); } } - /// Parse PlaybackExtras from API response - PlaybackExtras _parsePlaybackExtrasFromResponse(Response response) { - final metadataJson = _getFirstMetadataJson(response); - return _parsePlaybackExtrasFromMetadataJson(metadataJson); - } - - /// Parse PlaybackExtras from cached response - PlaybackExtras _parsePlaybackExtrasFromCachedResponse(Map cached) { - final metadataJson = PlexCacheParser.extractFirstMetadata(cached); - return _parsePlaybackExtrasFromMetadataJson(metadataJson); - } - /// Parse PlaybackExtras from metadata JSON PlaybackExtras _parsePlaybackExtrasFromMetadataJson(Map? metadataJson) { - final chapters = []; - final markers = []; - - if (metadataJson != null) { - // Parse chapters - if (metadataJson['Chapter'] != null) { - final chapterList = metadataJson['Chapter'] as List; - for (var chapter in chapterList) { - chapters.add( - PlexChapter( - id: chapter['id'] as int, - index: chapter['index'] as int?, - startTimeOffset: chapter['startTimeOffset'] as int?, - endTimeOffset: chapter['endTimeOffset'] as int?, - title: chapter['tag'] as String?, - thumb: chapter['thumb'] as String?, - ), - ); - } - } - - // Parse markers - if (metadataJson['Marker'] != null) { - final markerList = metadataJson['Marker'] as List; - for (var marker in markerList) { - markers.add( - PlexMarker( - id: marker['id'] as int, - type: marker['type'] as String, - startTimeOffset: marker['startTimeOffset'] as int, - endTimeOffset: marker['endTimeOffset'] as int, - ), - ); - } - } - } - - return PlaybackExtras.withChapterFallback(chapters: chapters, markers: markers); + return PlaybackExtras.withChapterFallback( + chapters: _parseChapters(metadataJson), + markers: _parseMarkers(metadataJson), + ); } /// Get consolidated video playback data (URL, media info, versions, and markers) in a single API call. @@ -1016,7 +940,7 @@ class PlexClient { Future getVideoPlaybackData(String ratingKey, {int mediaIndex = 0}) async { Map? data; try { - data = await _fetchWithCacheFallback>( + data = await _fetchWithCacheFirst>( cacheKey: '/library/metadata/$ratingKey', networkCall: () => _dio.get('/library/metadata/$ratingKey', queryParameters: {'includeMarkers': 1, 'includeChapters': 1}), @@ -1031,24 +955,9 @@ class PlexClient { String? videoUrl; PlexMediaInfo? mediaInfo; List availableVersions = []; - List markers = []; + final markers = _parseMarkers(metadataJson); if (metadataJson != null) { - // Parse markers (for auto-skip functionality) - if (metadataJson['Marker'] != null) { - final markerList = metadataJson['Marker'] as List; - for (var marker in markerList) { - markers.add( - PlexMarker( - id: marker['id'] as int, - type: marker['type'] as String, - startTimeOffset: marker['startTimeOffset'] as int, - endTimeOffset: marker['endTimeOffset'] as int, - ), - ); - } - } - if (metadataJson['Media'] != null && (metadataJson['Media'] as List).isNotEmpty) { final mediaList = metadataJson['Media'] as List; @@ -1099,7 +1008,7 @@ class PlexClient { /// Uses cache for offline mode support and network fallback. Future getFileInfo(String ratingKey) async { try { - final data = await _fetchWithCacheFallback>( + final data = await _fetchWithCacheFirst>( cacheKey: '/library/metadata/$ratingKey', networkCall: () => _dio.get('/library/metadata/$ratingKey', queryParameters: {'includeMarkers': 1, 'includeChapters': 1}),