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:
+76
-14
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}(),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user