From 65057750f7aaf5dd7c679018fb4b4f854e29eaa4 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 4 Nov 2025 10:28:37 +0100 Subject: [PATCH] feat: file version picker --- lib/client/plex_client.dart | 43 ++++++++- lib/models/plex_media_version.dart | 77 +++++++++++++++ lib/models/plex_metadata.dart | 68 +++++++++++++ lib/screens/video_player_screen.dart | 34 ++++++- lib/services/settings_service.dart | 43 +++++++++ lib/utils/video_player_navigation.dart | 20 ++++ lib/widgets/plex_video_controls.dart | 128 +++++++++++++++++++++++++ 7 files changed, 407 insertions(+), 6 deletions(-) create mode 100644 lib/models/plex_media_version.dart diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart index ea7619fa..e5a1cd27 100644 --- a/lib/client/plex_client.dart +++ b/lib/client/plex_client.dart @@ -6,6 +6,7 @@ import '../models/plex_media_info.dart'; import '../models/plex_file_info.dart'; import '../models/plex_filter.dart'; import '../models/plex_sort.dart'; +import '../models/plex_media_version.dart'; import '../utils/app_logger.dart'; /// Result of testing a connection, including success status and latency @@ -372,14 +373,22 @@ class PlexClient { } /// Get video URL for direct playback - Future getVideoUrl(String ratingKey) async { + /// [mediaIndex] specifies which Media item to use (defaults to 0 - first version) + Future getVideoUrl(String ratingKey, {int mediaIndex = 0}) async { final response = await _dio.get('/library/metadata/$ratingKey'); final metadataJson = _getFirstMetadataJson(response); if (metadataJson != null && metadataJson['Media'] != null && (metadataJson['Media'] as List).isNotEmpty) { - final media = metadataJson['Media'][0]; + final mediaList = metadataJson['Media'] as List; + + // Ensure the requested index is valid + if (mediaIndex < 0 || mediaIndex >= mediaList.length) { + mediaIndex = 0; + } + + final media = mediaList[mediaIndex]; if (media['Part'] != null && (media['Part'] as List).isNotEmpty) { final part = media['Part'][0]; final partKey = part['key'] as String?; @@ -420,14 +429,22 @@ class PlexClient { } /// Get detailed media info including chapters and tracks - Future getMediaInfo(String ratingKey) async { + /// [mediaIndex] specifies which Media item to use (defaults to 0 - first version) + Future getMediaInfo(String ratingKey, {int mediaIndex = 0}) async { final response = await _dio.get('/library/metadata/$ratingKey'); final metadataJson = _getFirstMetadataJson(response); if (metadataJson != null && metadataJson['Media'] != null && (metadataJson['Media'] as List).isNotEmpty) { - final media = metadataJson['Media'][0]; + final mediaList = metadataJson['Media'] as List; + + // Ensure the requested index is valid + if (mediaIndex < 0 || mediaIndex >= mediaList.length) { + mediaIndex = 0; + } + + final media = mediaList[mediaIndex]; if (media['Part'] != null && (media['Part'] as List).isNotEmpty) { final part = media['Part'][0]; final partKey = part['key'] as String?; @@ -506,6 +523,24 @@ class PlexClient { return null; } + /// Get all available media versions for a media item + /// Returns a list of PlexMediaVersion objects representing different quality/format options + Future> getMediaVersions(String ratingKey) async { + final response = await _dio.get('/library/metadata/$ratingKey'); + final metadataJson = _getFirstMetadataJson(response); + + if (metadataJson != null && + metadataJson['Media'] != null && + (metadataJson['Media'] as List).isNotEmpty) { + final mediaList = metadataJson['Media'] as List; + return mediaList + .map((media) => PlexMediaVersion.fromJson(media as Map)) + .toList(); + } + + return []; + } + /// Get file information for a media item Future getFileInfo(String ratingKey) async { try { diff --git a/lib/models/plex_media_version.dart b/lib/models/plex_media_version.dart new file mode 100644 index 00000000..d165fc5c --- /dev/null +++ b/lib/models/plex_media_version.dart @@ -0,0 +1,77 @@ +class PlexMediaVersion { + final int id; + final String? videoResolution; + final String? videoCodec; + final int? bitrate; + final int? width; + final int? height; + final String? container; + final String partKey; + + PlexMediaVersion({ + required this.id, + this.videoResolution, + this.videoCodec, + this.bitrate, + this.width, + this.height, + this.container, + required this.partKey, + }); + + /// Creates a PlexMediaVersion from Plex API Media object + factory PlexMediaVersion.fromJson(Map json) { + // Get the first Part key for playback + final parts = json['Part'] as List?; + final partKey = parts != null && parts.isNotEmpty + ? parts[0]['key'] as String? ?? '' + : ''; + + return PlexMediaVersion( + id: json['id'] as int? ?? 0, + videoResolution: json['videoResolution'] as String?, + videoCodec: json['videoCodec'] as String?, + bitrate: json['bitrate'] as int?, + width: json['width'] as int?, + height: json['height'] as int?, + container: json['container'] as String?, + partKey: partKey, + ); + } + + /// Display label with detailed information: "1080p H.264 MKV (8.5 Mbps)" + String get displayLabel { + final parts = []; + + // Add resolution + if (videoResolution != null && videoResolution!.isNotEmpty) { + parts.add('${videoResolution}p'); + } else if (height != null) { + parts.add('${height}p'); + } + + // Add codec + if (videoCodec != null && videoCodec!.isNotEmpty) { + parts.add(videoCodec!.toUpperCase()); + } + + // Add container + if (container != null && container!.isNotEmpty) { + parts.add(container!.toUpperCase()); + } + + // Build main label + String label = parts.isNotEmpty ? parts.join(' ') : 'Unknown'; + + // Add bitrate in parentheses + if (bitrate != null && bitrate! > 0) { + final bitrateInMbps = (bitrate! / 1000).toStringAsFixed(1); + label += ' ($bitrateInMbps Mbps)'; + } + + return label; + } + + @override + String toString() => displayLabel; +} diff --git a/lib/models/plex_metadata.dart b/lib/models/plex_metadata.dart index eb15bafa..985de30a 100644 --- a/lib/models/plex_metadata.dart +++ b/lib/models/plex_metadata.dart @@ -70,6 +70,74 @@ class PlexMetadata { this.viewedLeafCount, }); + /// Create a copy of this metadata with optional field overrides + PlexMetadata copyWith({ + String? ratingKey, + String? key, + String? guid, + String? studio, + String? type, + String? title, + String? contentRating, + String? summary, + double? rating, + int? year, + String? thumb, + String? art, + int? duration, + int? addedAt, + int? updatedAt, + String? grandparentTitle, + String? grandparentThumb, + String? grandparentArt, + String? grandparentRatingKey, + String? parentTitle, + String? parentThumb, + String? parentRatingKey, + int? parentIndex, + int? index, + String? grandparentTheme, + int? viewOffset, + int? viewCount, + int? leafCount, + int? viewedLeafCount, + }) { + final copy = PlexMetadata( + ratingKey: ratingKey ?? this.ratingKey, + key: key ?? this.key, + guid: guid ?? this.guid, + studio: studio ?? this.studio, + type: type ?? this.type, + title: title ?? this.title, + contentRating: contentRating ?? this.contentRating, + summary: summary ?? this.summary, + rating: rating ?? this.rating, + year: year ?? this.year, + thumb: thumb ?? this.thumb, + art: art ?? this.art, + duration: duration ?? this.duration, + addedAt: addedAt ?? this.addedAt, + updatedAt: updatedAt ?? this.updatedAt, + grandparentTitle: grandparentTitle ?? this.grandparentTitle, + grandparentThumb: grandparentThumb ?? this.grandparentThumb, + grandparentArt: grandparentArt ?? this.grandparentArt, + grandparentRatingKey: grandparentRatingKey ?? this.grandparentRatingKey, + parentTitle: parentTitle ?? this.parentTitle, + parentThumb: parentThumb ?? this.parentThumb, + parentRatingKey: parentRatingKey ?? this.parentRatingKey, + parentIndex: parentIndex ?? this.parentIndex, + index: index ?? this.index, + grandparentTheme: grandparentTheme ?? this.grandparentTheme, + viewOffset: viewOffset ?? this.viewOffset, + viewCount: viewCount ?? this.viewCount, + leafCount: leafCount ?? this.leafCount, + viewedLeafCount: viewedLeafCount ?? this.viewedLeafCount, + ); + // Preserve clearLogo + copy._clearLogo = _clearLogo; + return copy; + } + // Extract clearLogo from Image array in raw JSON void _extractClearLogo(Map json) { if (!json.containsKey('Image')) return; diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index e217aba2..598ed3ef 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -14,12 +14,14 @@ import '../services/settings_service.dart'; import '../utils/orientation_helper.dart'; import '../utils/video_player_navigation.dart'; import '../utils/platform_detector.dart'; +import '../models/plex_media_version.dart'; class VideoPlayerScreen extends StatefulWidget { final PlexMetadata metadata; final AudioTrack? preferredAudioTrack; final SubtitleTrack? preferredSubtitleTrack; final double? preferredPlaybackRate; + final int selectedMediaIndex; const VideoPlayerScreen({ super.key, @@ -27,6 +29,7 @@ class VideoPlayerScreen extends StatefulWidget { this.preferredAudioTrack, this.preferredSubtitleTrack, this.preferredPlaybackRate, + this.selectedMediaIndex = 0, }); @override @@ -44,6 +47,7 @@ class _VideoPlayerScreenState extends State { bool _showPlayNextDialog = false; PlexClientProvider? _cachedClientProvider; bool _isPhone = false; + List _availableVersions = []; @override void initState() { @@ -119,6 +123,9 @@ class _VideoPlayerScreenState extends State { // Get the video URL and start playback _startPlayback(); + // Load available media versions + _loadMediaVersions(); + // Set fullscreen mode and landscape orientation if (mounted) { try { @@ -182,8 +189,11 @@ class _VideoPlayerScreenState extends State { throw Exception('No client available'); } - // Get the direct file URL from the server - final videoUrl = await client.getVideoUrl(widget.metadata.ratingKey); + // Get the direct file URL from the server using the selected media index + final videoUrl = await client.getVideoUrl( + widget.metadata.ratingKey, + mediaIndex: widget.selectedMediaIndex, + ); if (videoUrl != null) { // Open video without auto-playing @@ -226,6 +236,24 @@ class _VideoPlayerScreenState extends State { } } + /// Load available media versions for this item + Future _loadMediaVersions() async { + try { + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) return; + + final versions = await client.getMediaVersions(widget.metadata.ratingKey); + if (mounted) { + setState(() { + _availableVersions = versions; + }); + } + } catch (e) { + appLogger.e('Error loading media versions: $e'); + } + } + @override void dispose() { // Stop progress tracking @@ -781,6 +809,8 @@ class _VideoPlayerScreenState extends State { widget.metadata, onNext: _nextEpisode != null ? _playNext : null, onPrevious: _previousEpisode != null ? _playPrevious : null, + availableVersions: _availableVersions, + selectedMediaIndex: widget.selectedMediaIndex, ), ), ), diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index df43f2fa..c8cfb022 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -20,6 +20,7 @@ class SettingsService { static const String _keyUseSeasonPoster = 'use_season_poster'; static const String _keySeekTimeSmall = 'seek_time_small'; static const String _keySeekTimeLarge = 'seek_time_large'; + static const String _keyMediaVersionPreferences = 'media_version_preferences'; static SettingsService? _instance; late SharedPreferences _prefs; @@ -588,6 +589,47 @@ class SettingsService { } } + // Media Version Preferences + /// Save media version preference for a series + /// [seriesRatingKey] is the grandparentRatingKey for TV series, or ratingKey for movies + /// [mediaIndex] is the index of the selected media version + Future setMediaVersionPreference(String seriesRatingKey, int mediaIndex) async { + final preferences = _getMediaVersionPreferences(); + preferences[seriesRatingKey] = mediaIndex; + + final jsonString = json.encode(preferences); + await _prefs.setString(_keyMediaVersionPreferences, jsonString); + } + + /// Get saved media version preference for a series + /// Returns null if no preference is saved + int? getMediaVersionPreference(String seriesRatingKey) { + final preferences = _getMediaVersionPreferences(); + return preferences[seriesRatingKey]; + } + + /// Clear media version preference for a series + Future clearMediaVersionPreference(String seriesRatingKey) async { + final preferences = _getMediaVersionPreferences(); + preferences.remove(seriesRatingKey); + + final jsonString = json.encode(preferences); + await _prefs.setString(_keyMediaVersionPreferences, jsonString); + } + + /// Get all media version preferences + Map _getMediaVersionPreferences() { + final jsonString = _prefs.getString(_keyMediaVersionPreferences); + if (jsonString == null) return {}; + + try { + final decoded = json.decode(jsonString) as Map; + return decoded.map((key, value) => MapEntry(key, value as int)); + } catch (e) { + return {}; + } + } + // Reset all settings to defaults Future resetAllSettings() async { await Future.wait([ @@ -603,6 +645,7 @@ class SettingsService { _prefs.remove(_keyUseSeasonPoster), _prefs.remove(_keySeekTimeSmall), _prefs.remove(_keySeekTimeLarge), + _prefs.remove(_keyMediaVersionPreferences), ]); } diff --git a/lib/utils/video_player_navigation.dart b/lib/utils/video_player_navigation.dart index 41b632a5..4db81424 100644 --- a/lib/utils/video_player_navigation.dart +++ b/lib/utils/video_player_navigation.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import '../models/plex_metadata.dart'; import '../screens/video_player_screen.dart'; +import '../services/settings_service.dart'; /// Navigates to the VideoPlayerScreen with instant transitions to prevent white flash. /// @@ -15,6 +16,8 @@ import '../screens/video_player_screen.dart'; /// - [preferredAudioTrack]: Optional audio track to select on playback start /// - [preferredSubtitleTrack]: Optional subtitle track to select on playback start /// - [preferredPlaybackRate]: Optional playback speed to set 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. /// - [usePushReplacement]: If true, replaces current route instead of pushing; /// useful for episode-to-episode navigation. Defaults to false. /// @@ -26,14 +29,31 @@ Future navigateToVideoPlayer( AudioTrack? preferredAudioTrack, SubtitleTrack? preferredSubtitleTrack, double? preferredPlaybackRate, + int? selectedMediaIndex, bool usePushReplacement = false, }) async { + // Load saved media version preference if not explicitly provided + int mediaIndex = selectedMediaIndex ?? 0; + if (selectedMediaIndex == null) { + try { + final settingsService = await SettingsService.getInstance(); + final seriesKey = metadata.grandparentRatingKey ?? metadata.ratingKey; + final savedPreference = settingsService.getMediaVersionPreference(seriesKey); + if (savedPreference != null) { + mediaIndex = savedPreference; + } + } catch (e) { + // Ignore errors loading preference, use default + } + } + final route = PageRouteBuilder( pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen( metadata: metadata, preferredAudioTrack: preferredAudioTrack, preferredSubtitleTrack: preferredSubtitleTrack, preferredPlaybackRate: preferredPlaybackRate, + selectedMediaIndex: mediaIndex, ), transitionDuration: Duration.zero, reverseTransitionDuration: Duration.zero, diff --git a/lib/widgets/plex_video_controls.dart b/lib/widgets/plex_video_controls.dart index df6e84e9..8e6eb030 100644 --- a/lib/widgets/plex_video_controls.dart +++ b/lib/widgets/plex_video_controls.dart @@ -7,6 +7,7 @@ import 'package:window_manager/window_manager.dart'; import 'package:macos_window_utils/macos_window_utils.dart'; import '../models/plex_metadata.dart'; import '../models/plex_media_info.dart'; +import '../models/plex_media_version.dart'; import '../providers/plex_client_provider.dart'; import '../services/fullscreen_state_manager.dart'; import '../services/keyboard_shortcuts_service.dart'; @@ -14,6 +15,7 @@ import '../services/settings_service.dart'; import '../utils/desktop_window_padding.dart'; import '../utils/platform_detector.dart'; import '../utils/provider_extensions.dart'; +import '../screens/video_player_screen.dart'; import 'app_bar_back_button.dart'; /// Custom video controls builder for Plex with chapter, audio, and subtitle support @@ -22,12 +24,16 @@ Widget plexVideoControlsBuilder( PlexMetadata metadata, { VoidCallback? onNext, VoidCallback? onPrevious, + List? availableVersions, + int? selectedMediaIndex, }) { return PlexVideoControls( player: player, metadata: metadata, onNext: onNext, onPrevious: onPrevious, + availableVersions: availableVersions ?? [], + selectedMediaIndex: selectedMediaIndex ?? 0, ); } @@ -36,6 +42,8 @@ class PlexVideoControls extends StatefulWidget { final PlexMetadata metadata; final VoidCallback? onNext; final VoidCallback? onPrevious; + final List availableVersions; + final int selectedMediaIndex; const PlexVideoControls({ super.key, @@ -43,6 +51,8 @@ class PlexVideoControls extends StatefulWidget { required this.metadata, this.onNext, this.onPrevious, + this.availableVersions = const [], + this.selectedMediaIndex = 0, }); @override @@ -289,6 +299,11 @@ class _PlexVideoControlsState extends State icon: const Icon(Icons.video_library, color: Colors.white), onPressed: _showChapterBottomSheet, ), + if (widget.availableVersions.length > 1) + IconButton( + icon: const Icon(Icons.video_file, color: Colors.white), + onPressed: _showVersionBottomSheet, + ), ], ); }, @@ -1780,6 +1795,119 @@ class _PlexVideoControlsState extends State ); } + void _showVersionBottomSheet() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.grey[900], + isScrollControlled: true, + constraints: _getBottomSheetConstraints(), + builder: (context) { + final versions = widget.availableVersions; + final currentIndex = widget.selectedMediaIndex; + + return SafeArea( + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.75, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Icon(Icons.video_file, color: Colors.white), + const SizedBox(width: 12), + const Text( + 'Video Version', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + const Divider(color: Colors.white24, height: 1), + Expanded( + child: ListView.builder( + itemCount: versions.length, + itemBuilder: (context, index) { + final version = versions[index]; + final isSelected = index == currentIndex; + + return ListTile( + title: Text( + version.displayLabel, + style: TextStyle( + color: isSelected ? Colors.blue : Colors.white, + ), + ), + trailing: isSelected + ? const Icon(Icons.check, color: Colors.blue) + : null, + onTap: () { + Navigator.pop(context); + _switchMediaVersion(index); + }, + ); + }, + ), + ), + ], + ), + ), + ); + }, + ); + } + + /// Switch to a different media version + Future _switchMediaVersion(int newMediaIndex) async { + if (newMediaIndex == widget.selectedMediaIndex) { + return; // Already using this version + } + + try { + // Save current playback position + final currentPosition = widget.player.state.position; + + // Save the preference + final settingsService = await SettingsService.getInstance(); + final seriesKey = widget.metadata.grandparentRatingKey ?? + widget.metadata.ratingKey; + await settingsService.setMediaVersionPreference(seriesKey, newMediaIndex); + + // Navigate to new player screen with the selected version + // Use PageRouteBuilder with zero-duration transitions to prevent orientation reset + if (mounted) { + Navigator.pushReplacement( + context, + PageRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen( + metadata: widget.metadata.copyWith( + viewOffset: currentPosition.inMilliseconds, + ), + selectedMediaIndex: newMediaIndex, + ), + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error switching version: $e')), + ); + } + } + } + String _formatDuration(Duration duration) { final hours = duration.inHours; final minutes = duration.inMinutes.remainder(60);