feat: Add support for Plex recommendation hubs on Discover screen

- Add PlexHub model to represent recommendation sections (trending, top in genre, etc.)
- Add getLibraryHubs() and getPlaylist() API methods to PlexClient
- Display library hubs below Recently Added on Discover screen
- Filter out duplicate hubs (Continue Watching, Recently Added) that are already shown
- Add dynamic context-aware icons based on hub titles (trending, genres, ratings, etc.)
- Support 20+ hub types with matching icons: trending, popular, seasonal, genres, top rated, playlists, and more
- Support for custom collections from Kometa/Pulsarr

Implements feature request #28
This commit is contained in:
Sam Matthews
2025-11-04 18:04:43 +00:00
parent feb9254aea
commit bc54a2230b
3 changed files with 355 additions and 69 deletions
+76 -14
View File
@@ -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<PlexMediaInfo?> getMediaInfo(String ratingKey, {int mediaIndex = 0}) async {
Future<PlexMediaInfo?> 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<String, dynamic>))
.map(
(media) => PlexMediaVersion.fromJson(media as Map<String, dynamic>),
)
.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<List<PlexHub>> 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 = <PlexHub>[];
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<List<PlexMetadata>> 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 [];
}
}
}
+57
View File
@@ -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<PlexMetadata> 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<String, dynamic> json) {
final metadataList = <PlexMetadata>[];
// 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,
);
}
}
+222 -55
View File
@@ -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<DiscoverScreen>
List<PlexMetadata> _onDeck = [];
List<PlexMetadata> _recentlyAdded = [];
List<PlexHub> _hubs = [];
bool _isLoading = true;
String? _errorMessage;
final PageController _heroController = PageController();
@@ -74,8 +76,11 @@ class _DiscoverScreenState extends State<DiscoverScreen>
_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<DiscoverScreen>
final onDeck = await client.getOnDeck();
final recentlyAdded = await client.getRecentlyAdded(limit: 20);
// Load hubs from all libraries
final libraries = await client.getLibraries();
final allHubs = <PlexHub>[];
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<DiscoverScreen>
_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<DiscoverScreen>
_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<DiscoverScreen>
// 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),
),
);
}
});
}(),
],
),