diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart index d6054f83..608cfc03 100644 --- a/lib/client/plex_client.dart +++ b/lib/client/plex_client.dart @@ -7,6 +7,7 @@ import '../models/plex_file_info.dart'; import '../models/plex_filter.dart'; import '../models/plex_sort.dart'; import '../models/plex_media_version.dart'; +import '../models/plex_hub.dart'; import '../utils/app_logger.dart'; /// Result of testing a connection, including success status and latency @@ -346,7 +347,7 @@ class PlexClient { queryParameters: {'X-Plex-Container-Size': limit, 'includeGuids': 1}, ); final allItems = _extractMetadataList(response); - + // Filter out music content (artists, albums, tracks) return allItems.where((item) { final type = item.type.toLowerCase(); @@ -362,7 +363,7 @@ class PlexClient { final allItems = (container['Metadata'] as List) .map((json) => PlexMetadata.fromJsonWithImages(json)) .toList(); - + // Filter out music content (artists, albums, tracks) return allItems.where((item) { final type = item.type.toLowerCase(); @@ -446,7 +447,10 @@ class PlexClient { /// Get detailed media info including chapters and tracks /// [mediaIndex] specifies which Media item to use (defaults to 0 - first version) - Future getMediaInfo(String ratingKey, {int mediaIndex = 0}) async { + Future getMediaInfo( + String ratingKey, { + int mediaIndex = 0, + }) async { final response = await _dio.get('/library/metadata/$ratingKey'); final metadataJson = _getFirstMetadataJson(response); @@ -550,7 +554,9 @@ class PlexClient { (metadataJson['Media'] as List).isNotEmpty) { final mediaList = metadataJson['Media'] as List; return mediaList - .map((media) => PlexMediaVersion.fromJson(media as Map)) + .map( + (media) => PlexMediaVersion.fromJson(media as Map), + ) .toList(); } @@ -712,11 +718,7 @@ class PlexClient { // Fallback: return common sort options if API doesn't provide them return [ - PlexSort( - key: 'titleSort', - title: 'Title', - defaultDirection: 'asc', - ), + PlexSort(key: 'titleSort', title: 'Title', defaultDirection: 'asc'), PlexSort( key: 'addedAt', descKey: 'addedAt:desc', @@ -740,11 +742,7 @@ class PlexClient { appLogger.e('Failed to get library sorts: $e'); // Return fallback sort options on error return [ - PlexSort( - key: 'titleSort', - title: 'Title', - defaultDirection: 'asc', - ), + PlexSort(key: 'titleSort', title: 'Title', defaultDirection: 'asc'), PlexSort( key: 'addedAt', descKey: 'addedAt:desc', @@ -830,4 +828,68 @@ class PlexClient { return null; } + + /// Get library hubs (recommendations for a specific library section) + /// Returns a list of recommendation hubs like "Trending Movies", "Top in Genre", etc. + Future> getLibraryHubs( + String sectionId, { + int limit = 10, + }) async { + try { + final response = await _dio.get( + '/hubs/sections/$sectionId', + queryParameters: {'count': limit, 'includeGuids': 1}, + ); + + final container = _getMediaContainer(response); + if (container != null && container['Hub'] != null) { + final hubs = []; + for (final hubJson in container['Hub'] as List) { + try { + final hub = PlexHub.fromJson(hubJson); + // Only include hubs that have items and are movie/show content + if (hub.items.isNotEmpty) { + // Filter out non-video content types + final videoItems = hub.items.where((item) { + final type = item.type.toLowerCase(); + return type == 'movie' || type == 'show'; + }).toList(); + + if (videoItems.isNotEmpty) { + hubs.add( + PlexHub( + hubKey: hub.hubKey, + title: hub.title, + type: hub.type, + hubIdentifier: hub.hubIdentifier, + size: hub.size, + more: hub.more, + items: videoItems, + ), + ); + } + } + } catch (e) { + appLogger.w('Failed to parse hub', error: e); + } + } + return hubs; + } + } catch (e) { + appLogger.e('Failed to get library hubs: $e'); + } + return []; + } + + /// Get playlist content by playlist ID + /// Returns the list of metadata items in the playlist + Future> getPlaylist(String playlistId) async { + try { + final response = await _dio.get('/playlists/$playlistId/items'); + return _extractMetadataList(response); + } catch (e) { + appLogger.e('Failed to get playlist: $e'); + return []; + } + } } diff --git a/lib/models/plex_hub.dart b/lib/models/plex_hub.dart new file mode 100644 index 00000000..e568718f --- /dev/null +++ b/lib/models/plex_hub.dart @@ -0,0 +1,57 @@ +import 'plex_metadata.dart'; + +/// Represents a Plex hub/recommendation section (e.g., Trending Movies, Top Thrillers) +class PlexHub { + final String hubKey; + final String title; + final String type; + final String? hubIdentifier; + final int size; + final bool more; + final List items; + + PlexHub({ + required this.hubKey, + required this.title, + required this.type, + this.hubIdentifier, + required this.size, + required this.more, + required this.items, + }); + + factory PlexHub.fromJson(Map json) { + final metadataList = []; + + // Hubs can contain either Metadata or Directory entries + if (json['Metadata'] != null) { + for (final item in json['Metadata'] as List) { + try { + metadataList.add(PlexMetadata.fromJson(item)); + } catch (e) { + // Skip items that fail to parse + } + } + } + + if (json['Directory'] != null) { + for (final item in json['Directory'] as List) { + try { + metadataList.add(PlexMetadata.fromJson(item)); + } catch (e) { + // Skip items that fail to parse + } + } + } + + return PlexHub( + hubKey: json['key'] as String? ?? '', + title: json['title'] as String? ?? 'Unknown', + type: json['type'] as String? ?? 'hub', + hubIdentifier: json['hubIdentifier'] as String?, + size: (json['size'] as num?)?.toInt() ?? metadataList.length, + more: json['more'] == true || json['more'] == 1, + items: metadataList, + ); + } +} diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 4324d97c..2ce5ac64 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:provider/provider.dart'; import '../client/plex_client.dart'; import '../models/plex_metadata.dart'; +import '../models/plex_hub.dart'; import '../providers/plex_client_provider.dart'; import '../services/storage_service.dart'; import '../services/plex_auth_service.dart'; @@ -40,6 +41,7 @@ class _DiscoverScreenState extends State List _onDeck = []; List _recentlyAdded = []; + List _hubs = []; bool _isLoading = true; String? _errorMessage; final PageController _heroController = PageController(); @@ -74,8 +76,11 @@ class _DiscoverScreenState extends State _indicatorAnimationController.forward(from: 0.0); _autoScrollTimer = Timer.periodic(_heroAutoScrollDuration, (timer) { - if (_onDeck.isEmpty || !_heroController.hasClients || _isAutoScrollPaused) + if (_onDeck.isEmpty || + !_heroController.hasClients || + _isAutoScrollPaused) { return; + } // Validate current index is within bounds before calculating next page if (_currentHeroIndex >= _onDeck.length) { @@ -170,12 +175,47 @@ class _DiscoverScreenState extends State final onDeck = await client.getOnDeck(); final recentlyAdded = await client.getRecentlyAdded(limit: 20); + // Load hubs from all libraries + final libraries = await client.getLibraries(); + final allHubs = []; + + for (final library in libraries) { + // Only fetch hubs for movie and show libraries + if (library.type == 'movie' || library.type == 'show') { + try { + final libraryHubs = await client.getLibraryHubs( + library.key, + limit: 12, + ); + // Filter out duplicate hubs that we already fetch separately + final filteredHubs = libraryHubs.where((hub) { + final hubId = hub.hubIdentifier?.toLowerCase() ?? ''; + final title = hub.title.toLowerCase(); + // Skip "Continue Watching", "On Deck", and "Recently Added" hubs + return !hubId.contains('ondeck') && + !hubId.contains('continue') && + !hubId.contains('recentlyadded') && + !title.contains('continue watching') && + !title.contains('on deck') && + !title.contains('recently added'); + }).toList(); + allHubs.addAll(filteredHubs); + } catch (e) { + appLogger.w( + 'Failed to load hubs for library ${library.title}', + error: e, + ); + } + } + } + appLogger.d( - 'Received ${onDeck.length} on deck items and ${recentlyAdded.length} recently added items', + 'Received ${onDeck.length} on deck items, ${recentlyAdded.length} recently added items, and ${allHubs.length} hubs', ); setState(() { _onDeck = onDeck; _recentlyAdded = recentlyAdded; + _hubs = allHubs; _isLoading = false; // Reset hero index to avoid sync issues @@ -204,6 +244,106 @@ class _DiscoverScreenState extends State _loadContent(); } + /// Get icon for hub based on its title + IconData _getHubIcon(String title) { + final lowerTitle = title.toLowerCase(); + + // Trending/Popular content + if (lowerTitle.contains('trending')) { + return Icons.trending_up; + } + if (lowerTitle.contains('popular') || lowerTitle.contains('imdb')) { + return Icons.whatshot; + } + + // Seasonal/Time-based + if (lowerTitle.contains('seasonal')) { + return Icons.calendar_month; + } + if (lowerTitle.contains('newly') || lowerTitle.contains('new release')) { + return Icons.new_releases; + } + if (lowerTitle.contains('recently released') || + lowerTitle.contains('recent')) { + return Icons.schedule; + } + + // Top/Rated content + if (lowerTitle.contains('top rated') || + lowerTitle.contains('highest rated')) { + return Icons.star; + } + if (lowerTitle.contains('top ')) { + return Icons.military_tech; + } + + // Genre-specific + if (lowerTitle.contains('thriller')) { + return Icons.warning_amber_rounded; + } + if (lowerTitle.contains('comedy') || lowerTitle.contains('comedier')) { + return Icons.mood; + } + if (lowerTitle.contains('action')) { + return Icons.flash_on; + } + if (lowerTitle.contains('drama')) { + return Icons.theater_comedy; + } + if (lowerTitle.contains('fantasy')) { + return Icons.auto_fix_high; + } + if (lowerTitle.contains('science') || lowerTitle.contains('sci-fi')) { + return Icons.rocket_launch; + } + if (lowerTitle.contains('horror') || lowerTitle.contains('skräck')) { + return Icons.nights_stay; + } + if (lowerTitle.contains('romance') || lowerTitle.contains('romantic')) { + return Icons.favorite_border; + } + if (lowerTitle.contains('adventure') || lowerTitle.contains('äventyr')) { + return Icons.explore; + } + + // Watchlist/Playlists + if (lowerTitle.contains('playlist') || lowerTitle.contains('watchlist')) { + return Icons.playlist_play; + } + if (lowerTitle.contains('unwatched') || lowerTitle.contains('unplayed')) { + return Icons.visibility_off; + } + if (lowerTitle.contains('watched') || lowerTitle.contains('played')) { + return Icons.visibility; + } + + // Network/Studio + if (lowerTitle.contains('network') || lowerTitle.contains('more from')) { + return Icons.tv; + } + + // Actor/Director + if (lowerTitle.contains('actor') || lowerTitle.contains('director')) { + return Icons.person; + } + + // Year-based (80s, 90s, etc.) + if (lowerTitle.contains('80') || + lowerTitle.contains('90') || + lowerTitle.contains('00')) { + return Icons.history; + } + + // Rediscover/Start Watching + if (lowerTitle.contains('rediscover') || + lowerTitle.contains('start watching')) { + return Icons.play_arrow; + } + + // Default icon for other hubs + return Icons.auto_awesome; + } + @override void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { // Check and update in _onDeck list @@ -460,7 +600,27 @@ class _DiscoverScreenState extends State _buildHorizontalList(_recentlyAdded, isLarge: false), ], - if (_onDeck.isEmpty && _recentlyAdded.isEmpty) + // Recommendation Hubs (Trending, Top in Genre, etc.) + for (final hub in _hubs) ...[ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), + child: Row( + children: [ + Icon(_getHubIcon(hub.title)), + const SizedBox(width: 8), + Text( + hub.title, + style: Theme.of(context).textTheme.titleLarge, + ), + ], + ), + ), + ), + _buildHorizontalList(hub.items, isLarge: false), + ], + + if (_onDeck.isEmpty && _recentlyAdded.isEmpty && _hubs.isEmpty) const SliverFillRemaining( child: Center( child: Column( @@ -543,63 +703,70 @@ class _DiscoverScreenState extends State // Page indicators (limited to 5 dots) ...() { final range = _getVisibleDotRange(); - return List.generate( - range.end - range.start + 1, - (i) { - final index = range.start + i; - final isActive = _currentHeroIndex == index; - final dotSize = _getDotSize(index, range.start, range.end); + return List.generate(range.end - range.start + 1, (i) { + final index = range.start + i; + final isActive = _currentHeroIndex == index; + final dotSize = _getDotSize( + index, + range.start, + range.end, + ); - if (isActive) { - // Animated progress indicator for active page - return AnimatedBuilder( - animation: _indicatorAnimationController, - builder: (context, child) { - // Fill width animates based on dot size - final maxWidth = dotSize * 3; // 24px for normal, 15px for small - final fillWidth = - dotSize + - ((maxWidth - dotSize) * _indicatorAnimationController.value); - return AnimatedContainer( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - margin: const EdgeInsets.symmetric(horizontal: 4), - width: maxWidth, - height: dotSize, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(dotSize / 2), + if (isActive) { + // Animated progress indicator for active page + return AnimatedBuilder( + animation: _indicatorAnimationController, + builder: (context, child) { + // Fill width animates based on dot size + final maxWidth = + dotSize * 3; // 24px for normal, 15px for small + final fillWidth = + dotSize + + ((maxWidth - dotSize) * + _indicatorAnimationController.value); + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + margin: const EdgeInsets.symmetric(horizontal: 4), + width: maxWidth, + height: dotSize, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular( + dotSize / 2, ), - child: Align( - alignment: Alignment.centerLeft, - child: Container( - width: fillWidth, - height: dotSize, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(dotSize / 2), + ), + child: Align( + alignment: Alignment.centerLeft, + child: Container( + width: fillWidth, + height: dotSize, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular( + dotSize / 2, ), ), ), - ); - }, - ); - } else { - // Static indicator for inactive pages - return AnimatedContainer( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - margin: const EdgeInsets.symmetric(horizontal: 4), - width: dotSize, - height: dotSize, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(dotSize / 2), - ), - ); - } - }, - ); + ), + ); + }, + ); + } else { + // Static indicator for inactive pages + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + margin: const EdgeInsets.symmetric(horizontal: 4), + width: dotSize, + height: dotSize, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(dotSize / 2), + ), + ); + } + }); }(), ], ),