From 679e5d5c39e6c69891538807299f9f6a4d82b4f3 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 4 Nov 2025 05:25:29 +0100 Subject: [PATCH] feat: sorting --- lib/client/plex_client.dart | 62 ++++++++ lib/models/plex_sort.dart | 53 +++++++ lib/screens/libraries_screen.dart | 250 +++++++++++++++++++++++++++++- lib/services/storage_service.dart | 15 ++ 4 files changed, 377 insertions(+), 3 deletions(-) create mode 100644 lib/models/plex_sort.dart diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart index 95f1428d..ea7619fa 100644 --- a/lib/client/plex_client.dart +++ b/lib/client/plex_client.dart @@ -5,6 +5,7 @@ import '../models/plex_metadata.dart'; import '../models/plex_media_info.dart'; import '../models/plex_file_info.dart'; import '../models/plex_filter.dart'; +import '../models/plex_sort.dart'; import '../utils/app_logger.dart'; /// Result of testing a connection, including success status and latency @@ -642,6 +643,67 @@ class PlexClient { return _extractDirectoryList(response, PlexFilterValue.fromJson); } + /// Get available sort options for a library section + Future> getLibrarySorts(String sectionId) async { + try { + // Fetch library content with minimal data to get Sort metadata + final response = await _dio.get( + '/library/sections/$sectionId/all', + queryParameters: {'X-Plex-Container-Size': 0}, + ); + + final container = _getMediaContainer(response); + if (container != null && container['Sort'] != null) { + return (container['Sort'] as List) + .map((json) => PlexSort.fromJson(json as Map)) + .toList(); + } + + // Fallback: return common sort options if API doesn't provide them + return [ + PlexSort( + key: 'titleSort', + title: 'Title', + defaultDirection: 'asc', + ), + PlexSort( + key: 'addedAt', + descKey: 'addedAt:desc', + title: 'Date Added', + defaultDirection: 'desc', + ), + PlexSort( + key: 'originallyAvailableAt', + descKey: 'originallyAvailableAt:desc', + title: 'Release Date', + defaultDirection: 'desc', + ), + PlexSort( + key: 'rating', + descKey: 'rating:desc', + title: 'Rating', + defaultDirection: 'desc', + ), + ]; + } catch (e) { + appLogger.e('Failed to get library sorts: $e'); + // Return fallback sort options on error + return [ + PlexSort( + key: 'titleSort', + title: 'Title', + defaultDirection: 'asc', + ), + PlexSort( + key: 'addedAt', + descKey: 'addedAt:desc', + title: 'Date Added', + defaultDirection: 'desc', + ), + ]; + } + } + /// Find adjacent episode in a given direction /// /// [direction]: +1 for next episode, -1 for previous episode diff --git a/lib/models/plex_sort.dart b/lib/models/plex_sort.dart new file mode 100644 index 00000000..8b5317b0 --- /dev/null +++ b/lib/models/plex_sort.dart @@ -0,0 +1,53 @@ +class PlexSort { + final String key; + final String? descKey; + final String title; + final String? defaultDirection; + + PlexSort({ + required this.key, + this.descKey, + required this.title, + this.defaultDirection, + }); + + factory PlexSort.fromJson(Map json) { + return PlexSort( + key: json['key'] as String, + descKey: json['descKey'] as String?, + title: json['title'] as String, + defaultDirection: json['defaultDirection'] as String?, + ); + } + + /// Gets the full sort key with direction + /// If [descending] is true, returns the descKey or key:desc + /// Otherwise returns the key for ascending sort + String getSortKey({bool descending = false}) { + if (!descending) { + return key; + } + + // Use descKey if available, otherwise append :desc to key + return descKey ?? '$key:desc'; + } + + /// Returns true if this sort's default direction is descending + bool get isDefaultDescending { + return defaultDirection?.toLowerCase() == 'desc'; + } + + @override + String toString() { + return 'PlexSort(key: $key, title: $title, defaultDirection: $defaultDirection)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is PlexSort && other.key == key; + } + + @override + int get hashCode => key.hashCode; +} diff --git a/lib/screens/libraries_screen.dart b/lib/screens/libraries_screen.dart index a44cf7f9..f871ec52 100644 --- a/lib/screens/libraries_screen.dart +++ b/lib/screens/libraries_screen.dart @@ -4,6 +4,7 @@ import '../client/plex_client.dart'; import '../models/plex_library.dart'; import '../models/plex_metadata.dart'; import '../models/plex_filter.dart'; +import '../models/plex_sort.dart'; import '../providers/plex_client_provider.dart'; import '../providers/settings_provider.dart'; import '../utils/provider_extensions.dart'; @@ -31,11 +32,14 @@ class _LibrariesScreenState extends State List _libraries = []; List _items = []; List _filters = []; + List _sortOptions = []; bool _isLoadingLibraries = true; bool _isLoadingItems = false; String? _errorMessage; int _selectedLibraryIndex = 0; Map _selectedFilters = {}; + PlexSort? _selectedSort; + bool _isSortDescending = false; bool _isInitialLoad = true; @override @@ -134,13 +138,21 @@ class _LibrariesScreenState extends State } try { - // Load filters for the new library + // Load filters and sort options for the new library _loadFilters(index); + _loadSortOptions(index); + + // Add sort parameter to filters if selected + final filtersWithSort = Map.from(_selectedFilters); + if (_selectedSort != null) { + filtersWithSort['sort'] = + _selectedSort!.getSortKey(descending: _isSortDescending); + } // Load content final items = await client.getLibraryContent( _libraries[index].key, - filters: _selectedFilters, + filters: filtersWithSort, ); setState(() { _items = items; @@ -178,6 +190,57 @@ class _LibrariesScreenState extends State } } + Future _loadSortOptions(int index) async { + if (index < 0 || index >= _libraries.length) return; + + try { + final clientProvider = Provider.of( + context, + listen: false, + ); + final client = clientProvider.client; + if (client == null) { + throw Exception('No client available'); + } + + final sortOptions = await client.getLibrarySorts(_libraries[index].key); + + // Load saved sort preference for this library + final storage = await StorageService.getInstance(); + final savedSortKey = storage.getLibrarySort(_libraries[index].key); + + // Find the saved sort in the options + PlexSort? savedSort; + bool descending = false; + + if (savedSortKey.endsWith(':desc')) { + descending = true; + final baseKey = savedSortKey.replaceAll(':desc', ''); + savedSort = sortOptions.firstWhere( + (s) => s.key == baseKey, + orElse: () => sortOptions.first, + ); + } else { + savedSort = sortOptions.firstWhere( + (s) => s.key == savedSortKey, + orElse: () => sortOptions.first, + ); + } + + setState(() { + _sortOptions = sortOptions; + _selectedSort = savedSort; + _isSortDescending = descending; + }); + } catch (e) { + setState(() { + _sortOptions = []; + _selectedSort = null; + _isSortDescending = false; + }); + } + } + Future _applyFilters() async { setState(() { _isLoadingItems = true; @@ -194,9 +257,16 @@ class _LibrariesScreenState extends State throw Exception('No client available'); } + // Add sort parameter to filters if selected + final filtersWithSort = Map.from(_selectedFilters); + if (_selectedSort != null) { + filtersWithSort['sort'] = + _selectedSort!.getSortKey(descending: _isSortDescending); + } + final items = await client.getLibraryContent( _libraries[_selectedLibraryIndex].key, - filters: _selectedFilters, + filters: filtersWithSort, ); setState(() { _items = items; @@ -210,6 +280,21 @@ class _LibrariesScreenState extends State } } + Future _applySort(PlexSort sort, bool descending) async { + setState(() { + _selectedSort = sort; + _isSortDescending = descending; + }); + + // Save sort preference for this library + final storage = await StorageService.getInstance(); + final sortKey = sort.getSortKey(descending: descending); + await storage.saveLibrarySort(_libraries[_selectedLibraryIndex].key, sortKey); + + // Reload content with new sort + _applyFilters(); + } + @override void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { final index = _items.indexWhere((item) => item.ratingKey == ratingKey); @@ -249,6 +334,22 @@ class _LibrariesScreenState extends State ); } + void _showSortBottomSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => _SortBottomSheet( + sortOptions: _sortOptions, + selectedSort: _selectedSort, + isSortDescending: _isSortDescending, + onSortChanged: (sort, descending) { + Navigator.pop(context); + _applySort(sort, descending); + }, + ), + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -263,6 +364,14 @@ class _LibrariesScreenState extends State shadowColor: Colors.transparent, scrolledUnderElevation: 0, actions: [ + if (_sortOptions.isNotEmpty) + IconButton( + icon: const Icon( + Icons.swap_vert, + semanticLabel: 'Sort', + ), + onPressed: _showSortBottomSheet, + ), if (_filters.isNotEmpty) IconButton( icon: Badge( @@ -823,3 +932,138 @@ class _FiltersBottomSheetState extends State<_FiltersBottomSheet> { ); } } + +class _SortBottomSheet extends StatefulWidget { + final List sortOptions; + final PlexSort? selectedSort; + final bool isSortDescending; + final Function(PlexSort, bool) onSortChanged; + + const _SortBottomSheet({ + required this.sortOptions, + required this.selectedSort, + required this.isSortDescending, + required this.onSortChanged, + }); + + @override + State<_SortBottomSheet> createState() => _SortBottomSheetState(); +} + +class _SortBottomSheetState extends State<_SortBottomSheet> { + late PlexSort? _tempSelectedSort; + late bool _tempDescending; + + @override + void initState() { + super.initState(); + _tempSelectedSort = widget.selectedSort; + _tempDescending = widget.isSortDescending; + } + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.6, + minChildSize: 0.4, + maxChildSize: 0.9, + expand: false, + builder: (context, scrollController) { + return Column( + children: [ + // Header + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: Theme.of(context).dividerColor), + ), + ), + child: Row( + children: [ + const Expanded( + child: Text( + 'Sort By', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + + // Sort options list + Expanded( + child: ListView.builder( + controller: scrollController, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: widget.sortOptions.length, + itemBuilder: (context, index) { + final sort = widget.sortOptions[index]; + final isSelected = _tempSelectedSort?.key == sort.key; + + return ListTile( + title: Text(sort.title), + trailing: isSelected + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Direction toggle buttons + SegmentedButton( + showSelectedIcon: false, + segments: const [ + ButtonSegment( + value: false, + icon: Icon(Icons.arrow_upward, size: 16), + ), + ButtonSegment( + value: true, + icon: Icon(Icons.arrow_downward, size: 16), + ), + ], + selected: {_tempDescending}, + onSelectionChanged: (Set selected) { + widget.onSortChanged(sort, selected.first); + }, + ), + ], + ) + : null, + leading: Radio( + value: sort.key, + groupValue: _tempSelectedSort?.key, + onChanged: (value) { + setState(() { + _tempSelectedSort = sort; + // Use default direction for newly selected sort + _tempDescending = sort.isDefaultDescending; + }); + // Apply sort immediately with default direction + widget.onSortChanged(sort, sort.isDefaultDescending); + }, + ), + onTap: () { + setState(() { + _tempSelectedSort = sort; + // Use default direction for newly selected sort + _tempDescending = sort.isDefaultDescending; + }); + // Apply sort immediately with default direction + widget.onSortChanged(sort, sort.isDefaultDescending); + }, + ); + }, + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 7d569b97..a0895e11 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -162,12 +162,27 @@ class StorageService { } } + // Library Sort (per-library, stored individually) + Future saveLibrarySort(String sectionId, String sortKey) async { + await _prefs.setString('library_sort_$sectionId', sortKey); + } + + String getLibrarySort(String sectionId) { + // Return saved sort or default to titleSort (alphabetical) + return _prefs.getString('library_sort_$sectionId') ?? 'titleSort'; + } + // Clear library preferences Future clearLibraryPreferences() async { await Future.wait([ _prefs.remove(_keySelectedLibraryIndex), _prefs.remove(_keyLibraryFilters), ]); + + // Also clear all library sort preferences + final keys = _prefs.getKeys(); + final sortKeys = keys.where((key) => key.startsWith('library_sort_')); + await Future.wait(sortKeys.map((key) => _prefs.remove(key))); } // User Profile (stored as JSON string)