From 12ee13419152723048028e32c31040033497bf95 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 28 Oct 2025 09:30:30 +0100 Subject: [PATCH] refactor: deduplicate --- README.md | 17 - lib/client/plex_client.dart | 510 ++++++++++++--------------- lib/screens/video_player_screen.dart | 93 +++-- lib/services/plex_auth_service.dart | 226 +++++------- lib/widgets/media_card.dart | 197 ++++------- lib/widgets/media_context_menu.dart | 167 +++++---- 6 files changed, 503 insertions(+), 707 deletions(-) diff --git a/README.md b/README.md index 356dbb39..f1650c20 100644 --- a/README.md +++ b/README.md @@ -43,14 +43,6 @@ Coming soon - Playback progress sync and resume functionality - Auto-play next episode -## Platform Support - -- ✅ macOS -- ✅ Windows -- ✅ Linux -- ✅ Android -- ✅ iOS - ## Prerequisites - Flutter SDK 3.8.1 or higher @@ -80,15 +72,6 @@ dart run build_runner build flutter run ``` -## Configuration - -On first launch, Plezy will guide you through: -1. Authenticating with your Plex account -2. Selecting your Plex Media Server -3. Testing connections to find the optimal one - -Your credentials and preferences are securely stored locally for automatic sign-in on subsequent launches. - ## Development ### Code Generation diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart index 97299fb0..4debfbd7 100644 --- a/lib/client/plex_client.dart +++ b/lib/client/plex_client.dart @@ -139,6 +139,64 @@ class PlexClient { return ConnectionTestResult(success: true, latencyMs: avgLatency); } + // ============================================================================ + // API Response Parsing Helpers + // ============================================================================ + + /// Extract MediaContainer from API response + Map? _getMediaContainer(Response response) { + if (response.data is Map && response.data.containsKey('MediaContainer')) { + return response.data['MediaContainer']; + } + return null; + } + + /// Extract list of PlexMetadata from response + List _extractMetadataList(Response response) { + final container = _getMediaContainer(response); + if (container != null && container['Metadata'] != null) { + return (container['Metadata'] as List) + .map((json) => PlexMetadata.fromJson(json)) + .toList(); + } + return []; + } + + /// Extract first metadata JSON from response (returns raw Map or null) + Map? _getFirstMetadataJson(Response response) { + final container = _getMediaContainer(response); + if (container != null && + container['Metadata'] != null && + (container['Metadata'] as List).isNotEmpty) { + return container['Metadata'][0] as Map; + } + return null; + } + + /// Extract single PlexMetadata from response (returns first item or null) + PlexMetadata? _extractSingleMetadata(Response response) { + final metadataJson = _getFirstMetadataJson(response); + return metadataJson != null ? PlexMetadata.fromJson(metadataJson) : null; + } + + /// Generic helper to extract and map Directory list from response + List _extractDirectoryList( + Response response, + T Function(Map) fromJson, + ) { + final container = _getMediaContainer(response); + if (container != null && container['Directory'] != null) { + return (container['Directory'] as List) + .map((json) => fromJson(json as Map)) + .toList(); + } + return []; + } + + // ============================================================================ + // API Methods + // ============================================================================ + /// Get server identity Future> getServerIdentity() async { final response = await _dio.get('/identity'); @@ -148,17 +206,7 @@ class PlexClient { /// Get library sections Future> getLibraries() async { final response = await _dio.get('/library/sections'); - - if (response.data is Map && response.data.containsKey('MediaContainer')) { - final container = response.data['MediaContainer']; - if (container['Directory'] != null) { - return (container['Directory'] as List) - .map((json) => PlexLibrary.fromJson(json)) - .toList(); - } - } - - return []; + return _extractDirectoryList(response, PlexLibrary.fromJson); } /// Get library content by section ID @@ -182,31 +230,13 @@ class PlexClient { queryParameters: queryParams, ); - if (response.data is Map && response.data.containsKey('MediaContainer')) { - final container = response.data['MediaContainer']; - if (container['Metadata'] != null) { - return (container['Metadata'] as List) - .map((json) => PlexMetadata.fromJson(json)) - .toList(); - } - } - - return []; + return _extractMetadataList(response); } /// Get metadata by rating key Future getMetadata(String ratingKey) async { final response = await _dio.get('/library/metadata/$ratingKey'); - - if (response.data is Map && response.data.containsKey('MediaContainer')) { - final container = response.data['MediaContainer']; - if (container['Metadata'] != null && - (container['Metadata'] as List).isNotEmpty) { - return PlexMetadata.fromJson(container['Metadata'][0]); - } - } - - return null; + return _extractSingleMetadata(response); } /// Get metadata by rating key with images (includes clearLogo and OnDeck) @@ -221,26 +251,20 @@ class PlexClient { PlexMetadata? metadata; PlexMetadata? onDeckEpisode; - if (response.data is Map && response.data.containsKey('MediaContainer')) { - final container = response.data['MediaContainer']; + final metadataJson = _getFirstMetadataJson(response); + if (metadataJson != null) { + metadata = PlexMetadata.fromJsonWithImages(metadataJson); - // Get main metadata - if (container['Metadata'] != null && - (container['Metadata'] as List).isNotEmpty) { - final metadataJson = container['Metadata'][0]; - metadata = PlexMetadata.fromJsonWithImages(metadataJson); + // Check if OnDeck is nested inside Metadata + if (metadataJson.containsKey('OnDeck') && + metadataJson['OnDeck'] != null) { + final onDeckData = metadataJson['OnDeck']; - // Check if OnDeck is nested inside Metadata - if (metadataJson.containsKey('OnDeck') && - metadataJson['OnDeck'] != null) { - final onDeckData = metadataJson['OnDeck']; - - // OnDeck can be either a Map with 'Metadata' key or direct metadata - if (onDeckData is Map && onDeckData.containsKey('Metadata')) { - final onDeckMetadata = onDeckData['Metadata']; - if (onDeckMetadata != null) { - onDeckEpisode = PlexMetadata.fromJson(onDeckMetadata); - } + // OnDeck can be either a Map with 'Metadata' key or direct metadata + if (onDeckData is Map && onDeckData.containsKey('Metadata')) { + final onDeckMetadata = onDeckData['Metadata']; + if (onDeckMetadata != null) { + onDeckEpisode = PlexMetadata.fromJson(onDeckMetadata); } } } @@ -252,16 +276,10 @@ class PlexClient { /// Get metadata by rating key with images (includes clearLogo) Future getMetadataWithImages(String ratingKey) async { final response = await _dio.get('/library/metadata/$ratingKey'); - - if (response.data is Map && response.data.containsKey('MediaContainer')) { - final container = response.data['MediaContainer']; - if (container['Metadata'] != null && - (container['Metadata'] as List).isNotEmpty) { - return PlexMetadata.fromJsonWithImages(container['Metadata'][0]); - } - } - - return null; + final metadataJson = _getFirstMetadataJson(response); + return metadataJson != null + ? PlexMetadata.fromJsonWithImages(metadataJson) + : null; } /// Search across all libraries using the hub search endpoint @@ -326,49 +344,25 @@ class PlexClient { '/library/recentlyAdded', queryParameters: {'X-Plex-Container-Size': limit, 'includeGuids': 1}, ); - - if (response.data is Map && response.data.containsKey('MediaContainer')) { - final container = response.data['MediaContainer']; - if (container['Metadata'] != null) { - return (container['Metadata'] as List) - .map((json) => PlexMetadata.fromJson(json)) - .toList(); - } - } - - return []; + return _extractMetadataList(response); } /// Get on deck items (continue watching) Future> getOnDeck() async { final response = await _dio.get('/library/onDeck'); - - if (response.data is Map && response.data.containsKey('MediaContainer')) { - final container = response.data['MediaContainer']; - if (container['Metadata'] != null) { - return (container['Metadata'] as List) - .map((json) => PlexMetadata.fromJsonWithImages(json)) - .toList(); - } + final container = _getMediaContainer(response); + if (container != null && container['Metadata'] != null) { + return (container['Metadata'] as List) + .map((json) => PlexMetadata.fromJsonWithImages(json)) + .toList(); } - return []; } /// Get children of a metadata item (e.g., seasons for a show, episodes for a season) Future> getChildren(String ratingKey) async { final response = await _dio.get('/library/metadata/$ratingKey/children'); - - if (response.data is Map && response.data.containsKey('MediaContainer')) { - final container = response.data['MediaContainer']; - if (container['Metadata'] != null) { - return (container['Metadata'] as List) - .map((json) => PlexMetadata.fromJson(json)) - .toList(); - } - } - - return []; + return _extractMetadataList(response); } /// Get thumbnail URL @@ -384,26 +378,19 @@ class PlexClient { /// Get video URL for direct playback Future getVideoUrl(String ratingKey) async { final response = await _dio.get('/library/metadata/$ratingKey'); + final metadataJson = _getFirstMetadataJson(response); - if (response.data is Map && response.data.containsKey('MediaContainer')) { - final container = response.data['MediaContainer']; - if (container['Metadata'] != null && - (container['Metadata'] as List).isNotEmpty) { - final metadata = container['Metadata'][0]; + if (metadataJson != null && + metadataJson['Media'] != null && + (metadataJson['Media'] as List).isNotEmpty) { + final media = metadataJson['Media'][0]; + if (media['Part'] != null && (media['Part'] as List).isNotEmpty) { + final part = media['Part'][0]; + final partKey = part['key'] as String?; - // Get the first Media item and its Part - if (metadata['Media'] != null && - (metadata['Media'] as List).isNotEmpty) { - final media = metadata['Media'][0]; - if (media['Part'] != null && (media['Part'] as List).isNotEmpty) { - final part = media['Part'][0]; - final partKey = part['key'] as String?; - - if (partKey != null) { - // Return direct play URL - return '${config.baseUrl}$partKey?X-Plex-Token=${config.token}'; - } - } + if (partKey != null) { + // Return direct play URL + return '${config.baseUrl}$partKey?X-Plex-Token=${config.token}'; } } } @@ -418,26 +405,19 @@ class PlexClient { queryParameters: {'includeChapters': 1}, ); - if (response.data is Map && response.data.containsKey('MediaContainer')) { - final container = response.data['MediaContainer']; - if (container['Metadata'] != null && - (container['Metadata'] as List).isNotEmpty) { - final metadata = container['Metadata'][0]; - - if (metadata['Chapter'] != null) { - final chapterList = metadata['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(); - } - } + 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 []; @@ -446,91 +426,84 @@ class PlexClient { /// Get detailed media info including chapters and tracks Future getMediaInfo(String ratingKey) async { final response = await _dio.get('/library/metadata/$ratingKey'); + final metadataJson = _getFirstMetadataJson(response); - if (response.data is Map && response.data.containsKey('MediaContainer')) { - final container = response.data['MediaContainer']; - if (container['Metadata'] != null && - (container['Metadata'] as List).isNotEmpty) { - final metadata = container['Metadata'][0]; + if (metadataJson != null && + metadataJson['Media'] != null && + (metadataJson['Media'] as List).isNotEmpty) { + final media = metadataJson['Media'][0]; + if (media['Part'] != null && (media['Part'] as List).isNotEmpty) { + final part = media['Part'][0]; + final partKey = part['key'] as String?; - // Get the first Media item and its Part - if (metadata['Media'] != null && - (metadata['Media'] as List).isNotEmpty) { - final media = metadata['Media'][0]; - if (media['Part'] != null && (media['Part'] as List).isNotEmpty) { - final part = media['Part'][0]; - final partKey = part['key'] as String?; + if (partKey != null) { + // Parse streams (audio and subtitle tracks) + final streams = part['Stream'] as List? ?? []; + final audioTracks = []; + final subtitleTracks = []; - if (partKey != null) { - // Parse streams (audio and subtitle tracks) - final streams = part['Stream'] as List? ?? []; - final audioTracks = []; - final subtitleTracks = []; + for (var stream in streams) { + final streamType = stream['streamType'] as int?; - 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 (metadata['Chapter'] != null) { - final chapterList = metadata['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?, - ), - ); - } - } - - return PlexMediaInfo( - videoUrl: - '${config.baseUrl}$partKey?X-Plex-Token=${config.token}', - audioTracks: audioTracks, - subtitleTracks: subtitleTracks, - chapters: chapters, + 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?, + ), + ); + } + } + + return PlexMediaInfo( + videoUrl: + '${config.baseUrl}$partKey?X-Plex-Token=${config.token}', + audioTracks: audioTracks, + subtitleTracks: subtitleTracks, + chapters: chapters, + ); } } } @@ -588,51 +561,34 @@ class PlexClient { /// Get sessions (currently playing) Future> getSessions() async { final response = await _dio.get('/status/sessions'); - - if (response.data is Map && response.data.containsKey('MediaContainer')) { - final container = response.data['MediaContainer']; - if (container['Metadata'] != null) { - return container['Metadata'] as List; - } + final container = _getMediaContainer(response); + if (container != null && container['Metadata'] != null) { + return container['Metadata'] as List; } - return []; } /// Get available filters for a library section Future> getLibraryFilters(String sectionId) async { final response = await _dio.get('/library/sections/$sectionId/filters'); - - if (response.data is Map && response.data.containsKey('MediaContainer')) { - final container = response.data['MediaContainer']; - if (container['Directory'] != null) { - return (container['Directory'] as List) - .map((json) => PlexFilter.fromJson(json)) - .toList(); - } - } - - return []; + return _extractDirectoryList(response, PlexFilter.fromJson); } /// Get filter values (e.g., list of genres, years, etc.) Future> getFilterValues(String filterKey) async { final response = await _dio.get(filterKey); - - if (response.data is Map && response.data.containsKey('MediaContainer')) { - final container = response.data['MediaContainer']; - if (container['Directory'] != null) { - return (container['Directory'] as List) - .map((json) => PlexFilterValue.fromJson(json)) - .toList(); - } - } - - return []; + return _extractDirectoryList(response, PlexFilterValue.fromJson); } - /// Get next episode for a TV show episode - Future getNextEpisode(PlexMetadata currentEpisode) async { + /// Find adjacent episode in a given direction + /// + /// [direction]: +1 for next episode, -1 for previous episode + /// + /// Handles navigation within current season and across seasons automatically. + Future findAdjacentEpisode( + PlexMetadata currentEpisode, + int direction, + ) async { if (currentEpisode.type.toLowerCase() != 'episode') { return null; } @@ -653,73 +609,43 @@ class PlexClient { (e) => e.ratingKey == currentEpisode.ratingKey, ); - if (currentIndex != -1 && currentIndex < episodes.length - 1) { - // Return next episode in the same season - return episodes[currentIndex + 1]; - } else if (currentIndex == episodes.length - 1) { - // Last episode of the season, try to get first episode of next season - final seasons = await getChildren(grandparentKey); - final currentSeasonIndex = seasons.indexWhere( - (s) => s.ratingKey == parentKey, - ); + if (currentIndex == -1) return null; - if (currentSeasonIndex != -1 && - currentSeasonIndex < seasons.length - 1) { - final nextSeason = seasons[currentSeasonIndex + 1]; - final nextSeasonEpisodes = await getChildren(nextSeason.ratingKey); + final targetIndex = currentIndex + direction; - if (nextSeasonEpisodes.isNotEmpty) { - return nextSeasonEpisodes.first; - } - } + // Check if target episode is within current season + if (targetIndex >= 0 && targetIndex < episodes.length) { + return episodes[targetIndex]; } - } catch (e) { - // Silently handle errors - } - return null; - } + // Need to move to adjacent season + final isAtBoundary = direction > 0 + ? currentIndex == episodes.length - 1 + : currentIndex == 0; - /// Get previous episode for a TV show episode - Future getPreviousEpisode(PlexMetadata currentEpisode) async { - if (currentEpisode.type.toLowerCase() != 'episode') { - return null; - } - - final parentKey = currentEpisode.parentRatingKey; - final grandparentKey = currentEpisode.grandparentRatingKey; - - if (parentKey == null || grandparentKey == null) { - return null; - } - - try { - // Get all episodes in the current season - final episodes = await getChildren(parentKey); - - // Find the current episode index - final currentIndex = episodes.indexWhere( - (e) => e.ratingKey == currentEpisode.ratingKey, - ); - - if (currentIndex > 0) { - // Return previous episode in the same season - return episodes[currentIndex - 1]; - } else if (currentIndex == 0) { - // First episode of the season, try to get last episode of previous season + if (isAtBoundary) { + // Get all seasons final seasons = await getChildren(grandparentKey); final currentSeasonIndex = seasons.indexWhere( (s) => s.ratingKey == parentKey, ); - if (currentSeasonIndex > 0) { - final previousSeason = seasons[currentSeasonIndex - 1]; - final previousSeasonEpisodes = await getChildren( - previousSeason.ratingKey, + if (currentSeasonIndex == -1) return null; + + final targetSeasonIndex = currentSeasonIndex + direction; + + // Check if target season exists + if (targetSeasonIndex >= 0 && targetSeasonIndex < seasons.length) { + final targetSeason = seasons[targetSeasonIndex]; + final targetSeasonEpisodes = await getChildren( + targetSeason.ratingKey, ); - if (previousSeasonEpisodes.isNotEmpty) { - return previousSeasonEpisodes.last; + if (targetSeasonEpisodes.isNotEmpty) { + // Return first episode for next season, last for previous + return direction > 0 + ? targetSeasonEpisodes.first + : targetSeasonEpisodes.last; } } } diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 1884aa23..045fd07c 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -105,8 +105,8 @@ class _VideoPlayerScreenState extends State { } try { - final next = await widget.client.getNextEpisode(widget.metadata); - final previous = await widget.client.getPreviousEpisode(widget.metadata); + final next = await widget.client.findAdjacentEpisode(widget.metadata, 1); + final previous = await widget.client.findAdjacentEpisode(widget.metadata, -1); if (mounted) { setState(() { @@ -196,38 +196,50 @@ class _VideoPlayerScreenState extends State { }); } - AudioTrack? _findBestAudioMatch( - List availableTracks, - AudioTrack preferred, + /// Generic track matching for audio and subtitle tracks + /// Returns the best matching track based on hierarchical criteria: + /// 1. Exact match (id + title + language) + /// 2. Partial match (title + language) + /// 3. Language-only match + T? _findBestTrackMatch( + List availableTracks, + T preferred, + String Function(T) getId, + String? Function(T) getTitle, + String? Function(T) getLanguage, ) { if (availableTracks.isEmpty) return null; // Filter out auto and no tracks final validTracks = availableTracks - .where((t) => t.id != 'auto' && t.id != 'no') + .where((t) => getId(t) != 'auto' && getId(t) != 'no') .toList(); if (validTracks.isEmpty) return null; - // Try to match: index, title, and language + final preferredId = getId(preferred); + final preferredTitle = getTitle(preferred); + final preferredLanguage = getLanguage(preferred); + + // Try to match: id, title, and language for (var track in validTracks) { - if (track.id == preferred.id && - track.title == preferred.title && - track.language == preferred.language) { + if (getId(track) == preferredId && + getTitle(track) == preferredTitle && + getLanguage(track) == preferredLanguage) { return track; } } // Try to match: title and language for (var track in validTracks) { - if (track.title == preferred.title && - track.language == preferred.language) { + if (getTitle(track) == preferredTitle && + getLanguage(track) == preferredLanguage) { return track; } } // Try to match: language only for (var track in validTracks) { - if (track.language == preferred.language) { + if (getLanguage(track) == preferredLanguage) { return track; } } @@ -235,6 +247,19 @@ class _VideoPlayerScreenState extends State { return null; } + AudioTrack? _findBestAudioMatch( + List availableTracks, + AudioTrack preferred, + ) { + return _findBestTrackMatch( + availableTracks, + preferred, + (t) => t.id, + (t) => t.title, + (t) => t.language, + ); + } + AudioTrack? _findAudioTrackByProfile( List availableTracks, PlexUserProfile profile, @@ -284,44 +309,18 @@ class _VideoPlayerScreenState extends State { List availableTracks, SubtitleTrack preferred, ) { - // If preferred is "no", return no subtitles + // Handle special "no subtitles" case if (preferred.id == 'no') { return SubtitleTrack.no(); } - if (availableTracks.isEmpty) return null; - - // Filter out auto and no tracks - final validTracks = availableTracks - .where((t) => t.id != 'auto' && t.id != 'no') - .toList(); - if (validTracks.isEmpty) return null; - - // Try to match: index, title, and language - for (var track in validTracks) { - if (track.id == preferred.id && - track.title == preferred.title && - track.language == preferred.language) { - return track; - } - } - - // Try to match: title and language - for (var track in validTracks) { - if (track.title == preferred.title && - track.language == preferred.language) { - return track; - } - } - - // Try to match: language only - for (var track in validTracks) { - if (track.language == preferred.language) { - return track; - } - } - - return null; + return _findBestTrackMatch( + availableTracks, + preferred, + (t) => t.id, + (t) => t.title, + (t) => t.language, + ); } SubtitleTrack? _findSubtitleTrackByProfile( diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index e5b181ac..9dfaad74 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -11,7 +11,7 @@ class PlexAuthService { static const String _clientsApi = 'https://clients.plex.tv/api/v2'; final Dio _dio; - late final String _clientIdentifier; + final String _clientIdentifier; PlexAuthService._(this._dio, this._clientIdentifier); @@ -20,33 +20,43 @@ class PlexAuthService { final dio = Dio(); // Get or create client identifier - String? clientId = storage.getClientIdentifier(); - if (clientId == null) { - clientId = const Uuid().v4(); - await storage.saveClientIdentifier(clientId); + String? clientIdentifier = storage.getClientIdentifier(); + if (clientIdentifier == null) { + clientIdentifier = const Uuid().v4(); + await storage.saveClientIdentifier(clientIdentifier); } - return PlexAuthService._(dio, clientId); + return PlexAuthService._(dio, clientIdentifier); } String get clientIdentifier => _clientIdentifier; + Options _getCommonOptions({String? authToken}) { + final headers = { + 'Accept': 'application/json', + 'X-Plex-Product': _appName, + 'X-Plex-Client-Identifier': _clientIdentifier, + }; + + if (authToken != null) { + headers['X-Plex-Token'] = authToken; + } + + return Options(headers: headers); + } + + Future _getUser(String authToken) { + return _dio.get( + '$_plexApiBase/user', + options: _getCommonOptions(authToken: authToken), + ); + } + /// Verify if a plex.tv token is valid - Future verifyToken(String token) async { + Future verifyToken(String authToken) async { try { - final response = await _dio.get( - '$_plexApiBase/user', - options: Options( - headers: { - 'Accept': 'application/json', - 'X-Plex-Product': _appName, - 'X-Plex-Client-Identifier': _clientIdentifier, - 'X-Plex-Token': token, - }, - validateStatus: (status) => status != null && status < 500, - ), - ); - return response.statusCode == 200; + await _getUser(authToken); + return true; } catch (e) { return false; } @@ -56,13 +66,7 @@ class PlexAuthService { Future> createPin() async { final response = await _dio.post( '$_plexApiBase/pins?strong=true', - options: Options( - headers: { - 'Accept': 'application/json', - 'X-Plex-Product': _appName, - 'X-Plex-Client-Identifier': _clientIdentifier, - }, - ), + options: _getCommonOptions(), ); return response.data as Map; @@ -91,12 +95,7 @@ class PlexAuthService { try { final response = await _dio.get( '$_plexApiBase/pins/$pinId', - options: Options( - headers: { - 'Accept': 'application/json', - 'X-Plex-Client-Identifier': _clientIdentifier, - }, - ), + options: _getCommonOptions(), ); final data = response.data as Map; @@ -133,17 +132,10 @@ class PlexAuthService { } /// Fetch available Plex servers for the authenticated user - Future> fetchServers(String plexToken) async { + Future> fetchServers(String authToken) async { final response = await _dio.get( '$_clientsApi/resources?includeHttps=1&includeRelay=1&includeIPv6=1', - options: Options( - headers: { - 'Accept': 'application/json', - 'X-Plex-Product': _appName, - 'X-Plex-Client-Identifier': _clientIdentifier, - 'X-Plex-Token': plexToken, - }, - ), + options: _getCommonOptions(authToken: authToken), ); final List resources = response.data as List; @@ -156,34 +148,17 @@ class PlexAuthService { } /// Get user information - Future> getUserInfo(String token) async { - final response = await _dio.get( - '$_plexApiBase/user', - options: Options( - headers: { - 'Accept': 'application/json', - 'X-Plex-Product': _appName, - 'X-Plex-Client-Identifier': _clientIdentifier, - 'X-Plex-Token': token, - }, - ), - ); + Future> getUserInfo(String authToken) async { + final response = await _getUser(authToken); return response.data as Map; } /// Get user profile with preferences (audio/subtitle settings) - Future getUserProfile(String token) async { + Future getUserProfile(String authToken) async { final response = await _dio.get( '$_clientsApi/user', - options: Options( - headers: { - 'Accept': 'application/json', - 'X-Plex-Product': _appName, - 'X-Plex-Client-Identifier': _clientIdentifier, - 'X-Plex-Token': token, - }, - ), + options: _getCommonOptions(authToken: authToken), ); return PlexUserProfile.fromJson(response.data as Map); @@ -259,25 +234,50 @@ class PlexServer { /// Check if server is online using the presence field bool get isOnline => presence; + PlexConnection? _selectBest(Iterable candidates) { + final local = candidates.where((c) => c.local && !c.relay).toList(); + if (local.isNotEmpty) return local.first; + + final remote = candidates.where((c) => !c.local && !c.relay).toList(); + if (remote.isNotEmpty) return remote.first; + + final relay = candidates.where((c) => c.relay).toList(); + if (relay.isNotEmpty) return relay.first; + + if (candidates.isNotEmpty) return candidates.first; + return null; + } + /// Get the best connection URL /// Priority: local > remote > relay PlexConnection? getBestConnection() { - if (connections.isEmpty) return null; + return _selectBest(connections); + } - // Try to find local connection first - final local = connections.where((c) => c.local && !c.relay).toList(); - if (local.isNotEmpty) return local.first; + PlexConnection? _findLowestLatency( + List> entries, + ) { + if (entries.isEmpty) return null; + final bestEntry = entries.reduce( + (a, b) => a.value.latencyMs < b.value.latencyMs ? a : b, + ); + return bestEntry.key; + } - // Try remote (non-relay) connection - final remote = connections.where((c) => !c.local && !c.relay).toList(); - if (remote.isNotEmpty) return remote.first; + PlexConnection? _selectBestWithLatency( + Map results, + ) { + final localEntries = results.entries + .where((e) => e.key.local && !e.key.relay) + .toList(); + final remoteEntries = results.entries + .where((e) => !e.key.local && !e.key.relay) + .toList(); + final relayEntries = results.entries.where((e) => e.key.relay).toList(); - // Fall back to relay as last resort - final relay = connections.where((c) => c.relay).toList(); - if (relay.isNotEmpty) return relay.first; - - // Return any connection - return connections.first; + return _findLowestLatency(localEntries) ?? + _findLowestLatency(remoteEntries) ?? + _findLowestLatency(relayEntries); } /// Find the best working connection by testing them @@ -344,47 +344,7 @@ class PlexServer { } // Find the best connection considering both priority and latency - PlexConnection? bestConnection; - int bestLatency = double.maxFinite.toInt(); - - // Group connections by priority - final localConnections = connectionResults.entries - .where((e) => e.key.local && !e.key.relay) - .toList(); - final remoteConnections = connectionResults.entries - .where((e) => !e.key.local && !e.key.relay) - .toList(); - final relayConnections = connectionResults.entries - .where((e) => e.key.relay) - .toList(); - - // Find best local connection - for (final entry in localConnections) { - if (entry.value.latencyMs < bestLatency) { - bestLatency = entry.value.latencyMs; - bestConnection = entry.key; - } - } - - // If no local connection, find best remote connection - if (bestConnection == null) { - for (final entry in remoteConnections) { - if (entry.value.latencyMs < bestLatency) { - bestLatency = entry.value.latencyMs; - bestConnection = entry.key; - } - } - } - - // If no remote connection, find best relay connection - if (bestConnection == null) { - for (final entry in relayConnections) { - if (entry.value.latencyMs < bestLatency) { - bestLatency = entry.value.latencyMs; - bestConnection = entry.key; - } - } - } + final bestConnection = _selectBestWithLatency(connectionResults); // Emit the best connection if it's different from the first one if (bestConnection != null && bestConnection.uri != firstConnection.uri) { @@ -399,40 +359,20 @@ class PlexServer { if (connections.isEmpty) return null; // Test all connections simultaneously - final results = await Future.wait( + final working = []; + await Future.wait( connections.map((connection) async { final works = await PlexClient.testConnectionUrl( connection.uri, accessToken, ); - return works ? connection : null; + if (works) { + working.add(connection); + } }), ); - // Filter out failed connections - final workingConnections = results - .where((c) => c != null) - .cast() - .toList(); - - if (workingConnections.isEmpty) return null; - - // From working connections, prefer local > remote > relay - final localWorking = workingConnections - .where((c) => c.local && !c.relay) - .toList(); - if (localWorking.isNotEmpty) return localWorking.first; - - final remoteWorking = workingConnections - .where((c) => !c.local && !c.relay) - .toList(); - if (remoteWorking.isNotEmpty) return remoteWorking.first; - - final relayWorking = workingConnections.where((c) => c.relay).toList(); - if (relayWorking.isNotEmpty) return relayWorking.first; - - // Fallback to any working connection - return workingConnections.first; + return _selectBest(working); } } diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 26c87e5a..db15fa14 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -104,131 +104,11 @@ class _MediaCardState extends State { SizedBox( width: double.infinity, height: widget.height, - child: Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: SizedBox( - width: double.infinity, - height: widget.height, - child: _buildPosterImage(context), - ), - ), - if (widget.item.isWatched) - Positioned( - top: 4, - right: 4, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Colors.green, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 4, - ), - ], - ), - child: const Icon( - Icons.check, - color: Colors.white, - size: 16, - ), - ), - ), - // Progress bar for partially watched episodes - if (widget.item.viewOffset != null && - widget.item.duration != null && - widget.item.viewOffset! > 0 && - !widget.item.isWatched) - Positioned( - bottom: 0, - left: 0, - right: 0, - child: ClipRRect( - borderRadius: const BorderRadius.only( - bottomLeft: Radius.circular(8), - bottomRight: Radius.circular(8), - ), - child: LinearProgressIndicator( - value: - widget.item.viewOffset! / - widget.item.duration!, - backgroundColor: Colors.black.withValues( - alpha: 0.5, - ), - valueColor: const AlwaysStoppedAnimation( - Colors.red, - ), - minHeight: 4, - ), - ), - ), - ], - ), + child: _buildPosterWithOverlay(context), ) else Expanded( - child: Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: _buildPosterImage(context), - ), - if (widget.item.isWatched) - Positioned( - top: 4, - right: 4, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Colors.green, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 4, - ), - ], - ), - child: const Icon( - Icons.check, - color: Colors.white, - size: 16, - ), - ), - ), - // Progress bar for partially watched episodes - if (widget.item.viewOffset != null && - widget.item.duration != null && - widget.item.viewOffset! > 0 && - !widget.item.isWatched) - Positioned( - bottom: 0, - left: 0, - right: 0, - child: ClipRRect( - borderRadius: const BorderRadius.only( - bottomLeft: Radius.circular(8), - bottomRight: Radius.circular(8), - ), - child: LinearProgressIndicator( - value: - widget.item.viewOffset! / - widget.item.duration!, - backgroundColor: Colors.black.withValues( - alpha: 0.5, - ), - valueColor: const AlwaysStoppedAnimation( - Colors.red, - ), - minHeight: 4, - ), - ), - ), - ], - ), + child: _buildPosterWithOverlay(context), ), const SizedBox(height: 4), // Text content @@ -287,6 +167,18 @@ class _MediaCardState extends State { ); } + Widget _buildPosterWithOverlay(BuildContext context) { + return Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: _buildPosterImage(context), + ), + _PosterOverlay(item: widget.item), + ], + ); + } + Widget _buildPosterImage(BuildContext context) { if (widget.item.posterThumb != null) { return CachedNetworkImage( @@ -311,3 +203,64 @@ class _MediaCardState extends State { } } } + +/// Overlay widget for poster showing watched indicator and progress bar +class _PosterOverlay extends StatelessWidget { + final PlexMetadata item; + + const _PosterOverlay({required this.item}); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + // Watched indicator (green checkmark) + if (item.isWatched) + Positioned( + top: 4, + right: 4, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + ), + ], + ), + child: const Icon( + Icons.check, + color: Colors.white, + size: 16, + ), + ), + ), + // Progress bar for partially watched content + if (item.viewOffset != null && + item.duration != null && + item.viewOffset! > 0 && + !item.isWatched) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: ClipRRect( + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(8), + bottomRight: Radius.circular(8), + ), + child: LinearProgressIndicator( + value: item.viewOffset! / item.duration!, + backgroundColor: Colors.black.withValues(alpha: 0.5), + valueColor: const AlwaysStoppedAnimation(Colors.red), + minHeight: 4, + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index fd6eb531..c4eeda72 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -175,102 +175,97 @@ class _MediaContextMenuState extends State { switch (selected) { case 'watch': - try { - await widget.client.markAsWatched(widget.metadata.ratingKey); - if (context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Marked as watched'))); - // Refresh parent screen to update UI - widget.onRefresh?.call(); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Error: $e'))); - } - } + await _executeAction( + context, + () => widget.client.markAsWatched(widget.metadata.ratingKey), + 'Marked as watched', + ); break; + case 'unwatch': - try { - await widget.client.markAsUnwatched(widget.metadata.ratingKey); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Marked as unwatched')), - ); - // Refresh parent screen to update UI - widget.onRefresh?.call(); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Error: $e'))); - } - } + await _executeAction( + context, + () => widget.client.markAsUnwatched(widget.metadata.ratingKey), + 'Marked as unwatched', + ); break; + case 'series': - // Navigate to series detail screen - if (widget.metadata.grandparentRatingKey != null) { - try { - final seriesMetadata = await widget.client.getMetadata( - widget.metadata.grandparentRatingKey!, - ); - if (seriesMetadata != null && context.mounted) { - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => MediaDetailScreen( - client: widget.client, - metadata: seriesMetadata, - ), - ), - ); - // Refresh parent screen after returning - widget.onRefresh?.call(); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error loading series: $e')), - ); - } - } - } + await _navigateToRelated( + context, + widget.metadata.grandparentRatingKey, + (metadata) => MediaDetailScreen( + client: widget.client, + metadata: metadata, + ), + 'Error loading series', + ); break; + case 'season': - // Navigate to season detail screen - if (widget.metadata.parentRatingKey != null) { - try { - final seasonMetadata = await widget.client.getMetadata( - widget.metadata.parentRatingKey!, - ); - if (seasonMetadata != null && context.mounted) { - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => SeasonDetailScreen( - client: widget.client, - season: seasonMetadata, - ), - ), - ); - // Refresh parent screen after returning - widget.onRefresh?.call(); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error loading season: $e')), - ); - } - } - } + await _navigateToRelated( + context, + widget.metadata.parentRatingKey, + (metadata) => SeasonDetailScreen( + client: widget.client, + season: metadata, + ), + 'Error loading season', + ); break; } } + /// Execute an action with error handling and refresh + Future _executeAction( + BuildContext context, + Future Function() action, + String successMessage, + ) async { + try { + await action(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(successMessage)), + ); + widget.onRefresh?.call(); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } + } + } + + /// Navigate to a related item (series or season) + Future _navigateToRelated( + BuildContext context, + String? ratingKey, + Widget Function(PlexMetadata) screenBuilder, + String errorPrefix, + ) async { + if (ratingKey == null) return; + + try { + final metadata = await widget.client.getMetadata(ratingKey); + if (metadata != null && context.mounted) { + await Navigator.push( + context, + MaterialPageRoute(builder: (context) => screenBuilder(metadata)), + ); + widget.onRefresh?.call(); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('$errorPrefix: $e')), + ); + } + } + } + @override Widget build(BuildContext context) { return GestureDetector(