feat: sorting

This commit is contained in:
edde746
2025-11-04 07:45:24 +01:00
parent 69e7e796b4
commit 679e5d5c39
4 changed files with 377 additions and 3 deletions
+62
View File
@@ -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<List<PlexSort>> 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<String, dynamic>))
.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
+53
View File
@@ -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<String, dynamic> 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;
}
+247 -3
View File
@@ -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<LibrariesScreen>
List<PlexLibrary> _libraries = [];
List<PlexMetadata> _items = [];
List<PlexFilter> _filters = [];
List<PlexSort> _sortOptions = [];
bool _isLoadingLibraries = true;
bool _isLoadingItems = false;
String? _errorMessage;
int _selectedLibraryIndex = 0;
Map<String, String> _selectedFilters = {};
PlexSort? _selectedSort;
bool _isSortDescending = false;
bool _isInitialLoad = true;
@override
@@ -134,13 +138,21 @@ class _LibrariesScreenState extends State<LibrariesScreen>
}
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<String, String>.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<LibrariesScreen>
}
}
Future<void> _loadSortOptions(int index) async {
if (index < 0 || index >= _libraries.length) return;
try {
final clientProvider = Provider.of<PlexClientProvider>(
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<void> _applyFilters() async {
setState(() {
_isLoadingItems = true;
@@ -194,9 +257,16 @@ class _LibrariesScreenState extends State<LibrariesScreen>
throw Exception('No client available');
}
// Add sort parameter to filters if selected
final filtersWithSort = Map<String, String>.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<LibrariesScreen>
}
}
Future<void> _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<LibrariesScreen>
);
}
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<LibrariesScreen>
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<PlexSort> 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<bool>(
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<bool> selected) {
widget.onSortChanged(sort, selected.first);
},
),
],
)
: null,
leading: Radio<String>(
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);
},
);
},
),
),
],
);
},
);
}
}
+15
View File
@@ -162,12 +162,27 @@ class StorageService {
}
}
// Library Sort (per-library, stored individually)
Future<void> 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<void> 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)