diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart index 65b9e324..5ad70e89 100644 --- a/lib/client/plex_client.dart +++ b/lib/client/plex_client.dart @@ -10,6 +10,7 @@ import '../models/plex_library.dart'; import '../models/plex_media_info.dart'; import '../models/plex_media_version.dart'; import '../models/plex_metadata.dart'; +import '../models/plex_video_playback_data.dart'; import '../models/plex_sort.dart'; import '../utils/app_logger.dart'; @@ -639,6 +640,124 @@ class PlexClient { return []; } + /// Get consolidated video playback data (URL, media info, and versions) in a single API call + /// This method combines the functionality of getVideoUrl(), getMediaInfo(), and getMediaVersions() + /// to reduce redundant API calls during video playback initialization. + Future getVideoPlaybackData( + String ratingKey, { + int mediaIndex = 0, + }) async { + final response = await _dio.get('/library/metadata/$ratingKey'); + final metadataJson = _getFirstMetadataJson(response); + + String? videoUrl; + PlexMediaInfo? mediaInfo; + List availableVersions = []; + + if (metadataJson != null && + metadataJson['Media'] != null && + (metadataJson['Media'] as List).isNotEmpty) { + final mediaList = metadataJson['Media'] as List; + + // Parse available media versions first + availableVersions = mediaList + .map( + (media) => PlexMediaVersion.fromJson(media as Map), + ) + .toList(); + + // 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?; + + if (partKey != null) { + // Get video URL + videoUrl = '${config.baseUrl}$partKey?X-Plex-Token=${config.token}'; + + // Parse streams (audio and subtitle tracks) for media info + final streams = part['Stream'] as List? ?? []; + final audioTracks = []; + final subtitleTracks = []; + + for (var stream in streams) { + final streamType = stream['streamType'] as int?; + + if (streamType == 2) { + // Audio track + audioTracks.add( + PlexAudioTrack( + id: stream['id'] as int, + index: stream['index'] as int?, + codec: stream['codec'] as String?, + language: stream['language'] as String?, + languageCode: stream['languageCode'] as String?, + title: stream['title'] as String?, + displayTitle: stream['displayTitle'] as String?, + channels: stream['channels'] as int?, + selected: stream['selected'] == 1, + ), + ); + } else if (streamType == 3) { + // Subtitle track + subtitleTracks.add( + PlexSubtitleTrack( + id: stream['id'] as int, + index: stream['index'] as int?, + codec: stream['codec'] as String?, + language: stream['language'] as String?, + languageCode: stream['languageCode'] as String?, + title: stream['title'] as String?, + displayTitle: stream['displayTitle'] as String?, + selected: stream['selected'] == 1, + forced: stream['forced'] == 1, + key: stream['key'] as String?, + ), + ); + } + } + + // Parse chapters + final 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['title'] as String?, + thumb: chapter['thumb'] as String?, + ), + ); + } + } + + // Create media info + mediaInfo = PlexMediaInfo( + videoUrl: videoUrl, + audioTracks: audioTracks, + subtitleTracks: subtitleTracks, + chapters: chapters, + ); + } + } + } + + return PlexVideoPlaybackData( + videoUrl: videoUrl, + mediaInfo: mediaInfo, + availableVersions: availableVersions, + ); + } + /// Get file information for a media item Future getFileInfo(String ratingKey) async { try { diff --git a/lib/models/plex_video_playback_data.dart b/lib/models/plex_video_playback_data.dart new file mode 100644 index 00000000..90851cde --- /dev/null +++ b/lib/models/plex_video_playback_data.dart @@ -0,0 +1,30 @@ +import 'plex_media_info.dart'; +import 'plex_media_version.dart'; + +/// Consolidated data model containing all information needed for video playback. +/// This model combines data from multiple Plex API endpoints to reduce redundant requests. +class PlexVideoPlaybackData { + /// Direct video URL for playback + final String? videoUrl; + + /// Media information including audio/subtitle tracks and chapters + final PlexMediaInfo? mediaInfo; + + /// Available media versions/qualities for this content + final List availableVersions; + + PlexVideoPlaybackData({ + required this.videoUrl, + required this.mediaInfo, + required this.availableVersions, + }); + + /// Returns true if this playback data has a valid video URL + bool get hasValidVideoUrl => videoUrl != null && videoUrl!.isNotEmpty; + + /// Returns true if media info is available + bool get hasMediaInfo => mediaInfo != null; + + /// Returns true if there are multiple media versions available + bool get hasMultipleVersions => availableVersions.length > 1; +} \ No newline at end of file diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 690c8931..a425f19f 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -10,6 +10,7 @@ import 'package:provider/provider.dart'; import '../models/plex_media_version.dart'; import '../models/plex_metadata.dart'; import '../models/plex_user_profile.dart'; +import '../models/plex_video_playback_data.dart'; import '../providers/playback_state_provider.dart'; import '../providers/plex_client_provider.dart'; import '../providers/settings_provider.dart'; @@ -181,9 +182,6 @@ class VideoPlayerScreenState extends State { // Get the video URL and start playback _startPlayback(); - // Load available media versions - _loadMediaVersions(); - // Set fullscreen mode and orientation based on rotation lock setting if (mounted) { try { @@ -304,18 +302,22 @@ class VideoPlayerScreenState extends State { throw Exception('No client available'); } - // Get the direct file URL from the server using the selected media index - final videoUrl = await client.getVideoUrl( + // Get consolidated playback data (URL, media info, and versions) in a single API call + final playbackData = await client.getVideoPlaybackData( widget.metadata.ratingKey, mediaIndex: widget.selectedMediaIndex, ); - if (videoUrl != null) { - // Fetch media info to check for external subtitle tracks - final mediaInfo = await client.getMediaInfo( - widget.metadata.ratingKey, - mediaIndex: widget.selectedMediaIndex, - ); + if (playbackData.hasValidVideoUrl) { + final videoUrl = playbackData.videoUrl!; + final mediaInfo = playbackData.mediaInfo; + + // Update available versions from the playback data + if (mounted) { + setState(() { + _availableVersions = playbackData.availableVersions; + }); + } // Build list of external subtitle tracks for media_kit final externalSubtitles = []; @@ -434,24 +436,6 @@ 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'); - } - } - /// Cycle through BoxFit modes: contain → cover → fill → contain (for button) void _cycleBoxFitMode() { setState(() {