feat: file version picker

This commit is contained in:
edde746
2025-11-04 10:28:37 +01:00
parent e50bd46386
commit 65057750f7
7 changed files with 407 additions and 6 deletions
+39 -4
View File
@@ -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<String?> getVideoUrl(String ratingKey) async {
/// [mediaIndex] specifies which Media item to use (defaults to 0 - first version)
Future<String?> 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<PlexMediaInfo?> getMediaInfo(String ratingKey) async {
/// [mediaIndex] specifies which Media item to use (defaults to 0 - first version)
Future<PlexMediaInfo?> 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<List<PlexMediaVersion>> 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<String, dynamic>))
.toList();
}
return [];
}
/// Get file information for a media item
Future<PlexFileInfo?> getFileInfo(String ratingKey) async {
try {
+77
View File
@@ -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<String, dynamic> json) {
// Get the first Part key for playback
final parts = json['Part'] as List<dynamic>?;
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 = <String>[];
// 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;
}
+68
View File
@@ -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<String, dynamic> json) {
if (!json.containsKey('Image')) return;
+32 -2
View File
@@ -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<VideoPlayerScreen> {
bool _showPlayNextDialog = false;
PlexClientProvider? _cachedClientProvider;
bool _isPhone = false;
List<PlexMediaVersion> _availableVersions = [];
@override
void initState() {
@@ -119,6 +123,9 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
// 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<VideoPlayerScreen> {
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<VideoPlayerScreen> {
}
}
/// Load available media versions for this item
Future<void> _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<VideoPlayerScreen> {
widget.metadata,
onNext: _nextEpisode != null ? _playNext : null,
onPrevious: _previousEpisode != null ? _playPrevious : null,
availableVersions: _availableVersions,
selectedMediaIndex: widget.selectedMediaIndex,
),
),
),
+43
View File
@@ -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<void> 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<void> 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<String, int> _getMediaVersionPreferences() {
final jsonString = _prefs.getString(_keyMediaVersionPreferences);
if (jsonString == null) return {};
try {
final decoded = json.decode(jsonString) as Map<String, dynamic>;
return decoded.map((key, value) => MapEntry(key, value as int));
} catch (e) {
return {};
}
}
// Reset all settings to defaults
Future<void> resetAllSettings() async {
await Future.wait([
@@ -603,6 +645,7 @@ class SettingsService {
_prefs.remove(_keyUseSeasonPoster),
_prefs.remove(_keySeekTimeSmall),
_prefs.remove(_keySeekTimeLarge),
_prefs.remove(_keyMediaVersionPreferences),
]);
}
+20
View File
@@ -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<bool?> 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<bool>(
pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen(
metadata: metadata,
preferredAudioTrack: preferredAudioTrack,
preferredSubtitleTrack: preferredSubtitleTrack,
preferredPlaybackRate: preferredPlaybackRate,
selectedMediaIndex: mediaIndex,
),
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
+128
View File
@@ -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<PlexMediaVersion>? 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<PlexMediaVersion> 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<PlexVideoControls>
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<PlexVideoControls>
);
}
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<void> _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<bool>(
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);