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/main.dart b/lib/main.dart index 28beb0ff..28ff3b1e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -13,6 +13,8 @@ import 'services/fullscreen_state_manager.dart'; import 'providers/user_profile_provider.dart'; import 'providers/plex_client_provider.dart'; import 'providers/theme_provider.dart'; +import 'providers/settings_provider.dart'; +import 'providers/hidden_libraries_provider.dart'; import 'utils/language_codes.dart'; import 'utils/app_logger.dart'; import 'utils/provider_extensions.dart'; @@ -21,6 +23,10 @@ import 'utils/orientation_helper.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + // Configure image cache for large libraries + PaintingBinding.instance.imageCache.maximumSizeBytes = 500 << 20; // 500MB + PaintingBinding.instance.imageCache.maximumSize = 500; // 500 images + // Initialize window_manager for desktop platforms if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) { await windowManager.ensureInitialized(); @@ -62,6 +68,8 @@ class MainApp extends StatelessWidget { create: (context) => UserProfileProvider()..initialize(), ), ChangeNotifierProvider(create: (context) => ThemeProvider()), + ChangeNotifierProvider(create: (context) => SettingsProvider()), + ChangeNotifierProvider(create: (context) => HiddenLibrariesProvider()), ], child: Consumer( builder: (context, themeProvider, child) { diff --git a/lib/models/plex_home_user.dart b/lib/models/plex_home_user.dart index ba48ae37..0a422ba7 100644 --- a/lib/models/plex_home_user.dart +++ b/lib/models/plex_home_user.dart @@ -70,5 +70,5 @@ class PlexHomeUser { bool get isAdminUser => admin; bool get isRestrictedUser => restricted; bool get isGuestUser => guest; - bool get requiresPassword => hasPassword; + bool get requiresPassword => protected; } diff --git a/lib/models/plex_metadata.dart b/lib/models/plex_metadata.dart index 4d5d8f39..eb15bafa 100644 --- a/lib/models/plex_metadata.dart +++ b/lib/models/plex_metadata.dart @@ -12,7 +12,7 @@ class PlexMetadata { final String title; final String? contentRating; final String? summary; - final int? rating; + final double? rating; final int? year; final String? thumb; final String? art; @@ -24,6 +24,7 @@ class PlexMetadata { final String? grandparentArt; // Show art for episodes final String? grandparentRatingKey; // Show rating key for episodes final String? parentTitle; // Season title for episodes + final String? parentThumb; // Season poster for episodes final String? parentRatingKey; // Season rating key for episodes final int? parentIndex; // Season number final int? index; // Episode number @@ -58,6 +59,7 @@ class PlexMetadata { this.grandparentArt, this.grandparentRatingKey, this.parentTitle, + this.parentThumb, this.parentRatingKey, this.parentIndex, this.index, @@ -121,12 +123,21 @@ class PlexMetadata { } // Helper to get the poster (show poster for episodes/seasons, thumb otherwise) - String? get posterThumb { + // If useSeasonPoster is true, episodes will use season poster instead of series poster + String? posterThumb({bool useSeasonPoster = false}) { final itemType = type.toLowerCase(); - // For episodes and seasons, prefer grandparent thumb (show poster) - if ((itemType == 'episode' || itemType == 'season') && - grandparentThumb != null) { + if (itemType == 'episode') { + // If season poster is enabled and available, use it + if (useSeasonPoster && parentThumb != null) { + return parentThumb!; + } + // Otherwise fall back to series poster, then item thumb + if (grandparentThumb != null) { + return grandparentThumb!; + } + } else if (itemType == 'season' && grandparentThumb != null) { + // For seasons, always use series poster return grandparentThumb!; } return thumb; diff --git a/lib/models/plex_metadata.g.dart b/lib/models/plex_metadata.g.dart index 1d0901ac..8d66a3fe 100644 --- a/lib/models/plex_metadata.g.dart +++ b/lib/models/plex_metadata.g.dart @@ -15,7 +15,7 @@ PlexMetadata _$PlexMetadataFromJson(Map json) => PlexMetadata( title: json['title'] as String, contentRating: json['contentRating'] as String?, summary: json['summary'] as String?, - rating: (json['rating'] as num?)?.toInt(), + rating: (json['rating'] as num?)?.toDouble(), year: (json['year'] as num?)?.toInt(), thumb: json['thumb'] as String?, art: json['art'] as String?, @@ -27,6 +27,7 @@ PlexMetadata _$PlexMetadataFromJson(Map json) => PlexMetadata( grandparentArt: json['grandparentArt'] as String?, grandparentRatingKey: json['grandparentRatingKey'] as String?, parentTitle: json['parentTitle'] as String?, + parentThumb: json['parentThumb'] as String?, parentRatingKey: json['parentRatingKey'] as String?, parentIndex: (json['parentIndex'] as num?)?.toInt(), index: (json['index'] as num?)?.toInt(), @@ -59,6 +60,7 @@ Map _$PlexMetadataToJson(PlexMetadata instance) => 'grandparentArt': instance.grandparentArt, 'grandparentRatingKey': instance.grandparentRatingKey, 'parentTitle': instance.parentTitle, + 'parentThumb': instance.parentThumb, 'parentRatingKey': instance.parentRatingKey, 'parentIndex': instance.parentIndex, 'index': instance.index, 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/providers/hidden_libraries_provider.dart b/lib/providers/hidden_libraries_provider.dart new file mode 100644 index 00000000..9f3eca99 --- /dev/null +++ b/lib/providers/hidden_libraries_provider.dart @@ -0,0 +1,61 @@ +import 'package:flutter/foundation.dart'; +import '../services/storage_service.dart'; + +/// Provider for managing hidden library state across the app. +/// This ensures that when a library is hidden/unhidden in one screen, +/// all other screens are automatically updated. +class HiddenLibrariesProvider extends ChangeNotifier { + late StorageService _storageService; + Set _hiddenLibraryKeys = {}; + bool _isInitialized = false; + + /// Get an unmodifiable copy of hidden library keys + Set get hiddenLibraryKeys => Set.unmodifiable(_hiddenLibraryKeys); + + /// Check if the provider has completed initialization + bool get isInitialized => _isInitialized; + + HiddenLibrariesProvider() { + _initialize(); + } + + /// Initialize the provider by loading hidden libraries from storage + Future _initialize() async { + _storageService = await StorageService.getInstance(); + _hiddenLibraryKeys = _storageService.getHiddenLibraries(); + _isInitialized = true; + notifyListeners(); + } + + /// Hide a library by its key + /// Updates both in-memory state and persistent storage + Future hideLibrary(String libraryKey) async { + if (!_hiddenLibraryKeys.contains(libraryKey)) { + _hiddenLibraryKeys = Set.from(_hiddenLibraryKeys)..add(libraryKey); + await _storageService.saveHiddenLibraries(_hiddenLibraryKeys); + notifyListeners(); + } + } + + /// Unhide a library by its key + /// Updates both in-memory state and persistent storage + Future unhideLibrary(String libraryKey) async { + if (_hiddenLibraryKeys.contains(libraryKey)) { + _hiddenLibraryKeys = Set.from(_hiddenLibraryKeys)..remove(libraryKey); + await _storageService.saveHiddenLibraries(_hiddenLibraryKeys); + notifyListeners(); + } + } + + /// Check if a specific library is hidden + bool isLibraryHidden(String libraryKey) { + return _hiddenLibraryKeys.contains(libraryKey); + } + + /// Refresh hidden libraries from storage + /// Useful if storage was modified outside the provider + Future refresh() async { + _hiddenLibraryKeys = _storageService.getHiddenLibraries(); + notifyListeners(); + } +} diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart new file mode 100644 index 00000000..1a1be3b2 --- /dev/null +++ b/lib/providers/settings_provider.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import '../services/settings_service.dart'; + +class SettingsProvider extends ChangeNotifier { + late SettingsService _settingsService; + LibraryDensity _libraryDensity = LibraryDensity.normal; + bool _useSeasonPoster = false; + + SettingsProvider() { + _initializeSettings(); + } + + Future _initializeSettings() async { + _settingsService = await SettingsService.getInstance(); + _libraryDensity = _settingsService.getLibraryDensity(); + _useSeasonPoster = _settingsService.getUseSeasonPoster(); + notifyListeners(); + } + + LibraryDensity get libraryDensity => _libraryDensity; + bool get useSeasonPoster => _useSeasonPoster; + + Future setLibraryDensity(LibraryDensity density) async { + if (_libraryDensity != density) { + _libraryDensity = density; + await _settingsService.setLibraryDensity(density); + notifyListeners(); + } + } + + Future setUseSeasonPoster(bool value) async { + if (_useSeasonPoster != value) { + _useSeasonPoster = value; + await _settingsService.setUseSeasonPoster(value); + notifyListeners(); + } + } + + String get libraryDensityDisplayName { + switch (_libraryDensity) { + case LibraryDensity.compact: + return 'Compact'; + case LibraryDensity.normal: + return 'Normal'; + case LibraryDensity.comfortable: + return 'Comfortable'; + } + } +} diff --git a/lib/providers/user_profile_provider.dart b/lib/providers/user_profile_provider.dart index 5aaff1c6..03ddca58 100644 --- a/lib/providers/user_profile_provider.dart +++ b/lib/providers/user_profile_provider.dart @@ -1,3 +1,4 @@ +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import '../models/plex_home.dart'; import '../models/plex_home_user.dart'; @@ -6,6 +7,7 @@ import '../services/plex_auth_service.dart'; import '../services/storage_service.dart'; import '../utils/app_logger.dart'; import '../utils/provider_extensions.dart'; +import '../widgets/pin_entry_dialog.dart'; import 'plex_client_provider.dart'; class UserProfileProvider extends ChangeNotifier { @@ -254,15 +256,41 @@ class UserProfileProvider extends ChangeNotifier { _setLoading(true); _clearError(); + return await _attemptUserSwitch(user, context, clientProvider, null); + } + + Future _attemptUserSwitch( + PlexHomeUser user, + BuildContext? context, + PlexClientProvider? clientProvider, + String? errorMessage, + ) async { try { final currentToken = _storageService!.getPlexToken(); if (currentToken == null) { throw Exception('No Plex.tv authentication token available'); } + // Check if user requires PIN + String? pin; + if (user.requiresPassword && context != null && context.mounted) { + pin = await showPinEntryDialog( + context, + user.displayName, + errorMessage: errorMessage, + ); + + // User cancelled the PIN dialog + if (pin == null) { + _setLoading(false); + return false; + } + } + final switchResponse = await _authService!.switchToUser( user.uuid, currentToken, + pin: pin, ); // switchResponse.authToken is the new user's Plex.tv token @@ -348,6 +376,36 @@ class UserProfileProvider extends ChangeNotifier { appLogger.i('Successfully switched to user: ${user.displayName}'); return true; } catch (e) { + // Check if it's a PIN validation error + if (e is DioException && e.response?.statusCode == 403) { + final errors = e.response?.data['errors'] as List?; + if (errors != null && errors.isNotEmpty) { + final errorCode = errors[0]['code'] as int?; + final errorMessage = errors[0]['message'] as String?; + + // Error code 1041 means invalid PIN + if (errorCode == 1041) { + appLogger.w('Invalid PIN for user: ${user.displayName}'); + _clearError(); // Clear any previous error state + + // Retry with error message if context is still available + if (context != null && context.mounted) { + return await _attemptUserSwitch( + user, + context, + clientProvider, + errorMessage ?? 'Incorrect PIN. Please try again.', + ); + } + + // If context not available, return false without showing error + appLogger.d('Cannot retry PIN entry - context not available'); + return false; + } + } + } + + // Only show error for non-PIN validation errors _setError('Failed to switch user: $e'); appLogger.e('Failed to switch to user: ${user.displayName}', error: e); return false; diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 5b37c4bf..e6272432 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -797,7 +797,7 @@ class _DiscoverScreenState extends State [ contentTypeLabel, if (heroItem.rating != null) - '★ ${(heroItem.rating! / 10).toStringAsFixed(1)}', + '★ ${heroItem.rating!.toStringAsFixed(1)}', if (heroItem.contentRating != null) formatContentRating(heroItem.contentRating!), if (heroItem.year != null) diff --git a/lib/screens/libraries_screen.dart b/lib/screens/libraries_screen.dart index 80fb3416..d72c97af 100644 --- a/lib/screens/libraries_screen.dart +++ b/lib/screens/libraries_screen.dart @@ -4,12 +4,17 @@ 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 '../providers/hidden_libraries_provider.dart'; import '../utils/provider_extensions.dart'; import '../widgets/media_card.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/app_bar_back_button.dart'; +import '../widgets/context_menu_wrapper.dart'; import '../services/storage_service.dart'; +import '../services/settings_service.dart'; import '../mixins/refreshable.dart'; import '../mixins/item_updatable.dart'; import '../theme/theme_helper.dart'; @@ -26,14 +31,17 @@ class _LibrariesScreenState extends State @override PlexClient get client => context.clientSafe; - List _libraries = []; + List _allLibraries = []; // All libraries from API (unfiltered) List _items = []; List _filters = []; + List _sortOptions = []; bool _isLoadingLibraries = true; bool _isLoadingItems = false; String? _errorMessage; - int _selectedLibraryIndex = 0; + String? _selectedLibraryKey; Map _selectedFilters = {}; + PlexSort? _selectedSort; + bool _isSortDescending = false; bool _isInitialLoad = true; bool _isReorderMode = false; @@ -44,51 +52,74 @@ class _LibrariesScreenState extends State } Future _loadLibraries() async { + // Extract context dependencies before async gap + final clientProvider = Provider.of( + context, + listen: false, + ); + final hiddenLibrariesProvider = Provider.of( + context, + listen: false, + ); + setState(() { _isLoadingLibraries = true; _errorMessage = null; }); try { - final clientProvider = Provider.of( - context, - listen: false, - ); final client = clientProvider.client; if (client == null) { throw Exception('No client available'); } - final libraries = await client.getLibraries(); + final storage = await StorageService.getInstance(); + final allLibraries = await client.getLibraries(); // Load saved library order and apply it - final storage = await StorageService.getInstance(); final savedOrder = storage.getLibraryOrder(); - final orderedLibraries = _applyLibraryOrder(libraries, savedOrder); + final orderedLibraries = _applyLibraryOrder(allLibraries, savedOrder); setState(() { - _libraries = orderedLibraries; + _allLibraries = orderedLibraries; // Store all libraries with ordering applied _isLoadingLibraries = false; }); - if (libraries.isNotEmpty) { + if (allLibraries.isNotEmpty) { + // Compute visible libraries for initial load + final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys; + final visibleLibraries = allLibraries + .where((lib) => !hiddenKeys.contains(lib.key)) + .toList(); + // Load saved preferences - final storage = await StorageService.getInstance(); - final savedIndex = storage.getSelectedLibraryIndex(); + final savedLibraryKey = storage.getSelectedLibraryKey(); final savedFilters = storage.getLibraryFilters(); - // Use saved index if valid, otherwise default to 0 - final indexToLoad = - (savedIndex != null && savedIndex < libraries.length) - ? savedIndex - : 0; + // Find the library by key in visible libraries + String? libraryKeyToLoad; + if (savedLibraryKey != null) { + // Check if saved library exists and is visible + final libraryExists = visibleLibraries + .any((lib) => lib.key == savedLibraryKey); + if (libraryExists) { + libraryKeyToLoad = savedLibraryKey; + } + } + + // Fallback to first visible library if saved key not found + if (libraryKeyToLoad == null && visibleLibraries.isNotEmpty) { + libraryKeyToLoad = visibleLibraries.first.key; + } // Restore filters BEFORE loading content if (savedFilters.isNotEmpty) { _selectedFilters = Map.from(savedFilters); } - _loadLibraryContent(indexToLoad); + if (libraryKeyToLoad != null) { + _loadLibraryContent(libraryKeyToLoad); + } } } catch (e) { setState(() { @@ -133,7 +164,7 @@ class _LibrariesScreenState extends State Future _saveLibraryOrder() async { final storage = await StorageService.getInstance(); - final libraryKeys = _libraries.map((lib) => lib.key).toList(); + final libraryKeys = _allLibraries.map((lib) => lib.key).toList(); await storage.saveLibraryOrder(libraryKeys); } @@ -142,27 +173,28 @@ class _LibrariesScreenState extends State if (newIndex > oldIndex) { newIndex -= 1; } - final library = _libraries.removeAt(oldIndex); - _libraries.insert(newIndex, library); - - // Update selected index to follow the moved library if needed - if (oldIndex == _selectedLibraryIndex) { - _selectedLibraryIndex = newIndex; - } else if (oldIndex < _selectedLibraryIndex && - newIndex >= _selectedLibraryIndex) { - _selectedLibraryIndex -= 1; - } else if (oldIndex > _selectedLibraryIndex && - newIndex <= _selectedLibraryIndex) { - _selectedLibraryIndex += 1; - } + final library = _allLibraries.removeAt(oldIndex); + _allLibraries.insert(newIndex, library); }); _saveLibraryOrder(); } - Future _loadLibraryContent(int index) async { - if (index < 0 || index >= _libraries.length) return; + Future _loadLibraryContent(String libraryKey) async { + // Compute visible libraries based on current provider state + final hiddenLibrariesProvider = Provider.of( + context, + listen: false, + ); + final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys; + final visibleLibraries = _allLibraries + .where((lib) => !hiddenKeys.contains(lib.key)) + .toList(); - final isChangingLibrary = !_isInitialLoad && _selectedLibraryIndex != index; + // Find the library by key + final libraryIndex = visibleLibraries.indexWhere((lib) => lib.key == libraryKey); + if (libraryIndex == -1) return; // Library not found or hidden + + final isChangingLibrary = !_isInitialLoad && _selectedLibraryKey != libraryKey; // Extract context dependencies before async operations final clientProvider = context.plexClient; @@ -176,7 +208,7 @@ class _LibrariesScreenState extends State } setState(() { - _selectedLibraryIndex = index; + _selectedLibraryKey = libraryKey; _isLoadingItems = true; _errorMessage = null; // Only clear filters when explicitly changing library (not on initial load) @@ -190,9 +222,9 @@ class _LibrariesScreenState extends State _isInitialLoad = false; } - // Save selected library index + // Save selected library key final storage = await StorageService.getInstance(); - await storage.saveSelectedLibraryIndex(index); + await storage.saveSelectedLibraryKey(libraryKey); // Clear filters in storage when changing library if (isChangingLibrary) { @@ -200,13 +232,22 @@ class _LibrariesScreenState extends State } try { - // Load filters for the new library - _loadFilters(index); + // Load filters and sort options for the new library + _loadFilters(libraryKey); + _loadSortOptions(libraryKey); + + // 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, + libraryKey, + filters: filtersWithSort, ); setState(() { _items = items; @@ -220,9 +261,7 @@ class _LibrariesScreenState extends State } } - Future _loadFilters(int index) async { - if (index < 0 || index >= _libraries.length) return; - + Future _loadFilters(String libraryKey) async { try { final clientProvider = Provider.of( context, @@ -233,7 +272,7 @@ class _LibrariesScreenState extends State throw Exception('No client available'); } - final filters = await client.getLibraryFilters(_libraries[index].key); + final filters = await client.getLibraryFilters(libraryKey); setState(() { _filters = filters; }); @@ -244,6 +283,55 @@ class _LibrariesScreenState extends State } } + Future _loadSortOptions(String libraryKey) async { + 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(libraryKey); + + // Load saved sort preference for this library + final storage = await StorageService.getInstance(); + final savedSortKey = storage.getLibrarySort(libraryKey); + + // 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; @@ -260,9 +348,17 @@ 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, + _selectedLibraryKey!, + filters: filtersWithSort, ); setState(() { _items = items; @@ -276,6 +372,24 @@ 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( + _selectedLibraryKey!, + sortKey, + ); + + // Reload content with new sort + _applyFilters(); + } + @override void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { final index = _items.indexWhere((item) => item.ratingKey == ratingKey); @@ -287,11 +401,49 @@ class _LibrariesScreenState extends State // Public method to refresh content @override void refresh() { - if (_libraries.isNotEmpty) { + if (_allLibraries.isNotEmpty) { _applyFilters(); } } + Future _hideLibrary(PlexLibrary library) async { + // Hide library using provider + final hiddenLibrariesProvider = Provider.of( + context, + listen: false, + ); + await hiddenLibrariesProvider.hideLibrary(library.key); + + // Reload libraries to update the visible list + await _loadLibraries(); + + // Show snackbar with undo option + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Hidden "${library.title}"'), + action: SnackBarAction( + label: 'Undo', + onPressed: () => _unhideLibrary(library.key), + ), + duration: const Duration(seconds: 4), + ), + ); + } + } + + Future _unhideLibrary(String libraryKey) async { + // Unhide library using provider + final hiddenLibrariesProvider = Provider.of( + context, + listen: false, + ); + await hiddenLibrariesProvider.unhideLibrary(libraryKey); + + // Reload libraries to update the visible list + await _loadLibraries(); + } + void _showFiltersBottomSheet() { showModalBottomSheet( context: context, @@ -315,8 +467,33 @@ 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) { + // Watch for hidden libraries changes to trigger rebuild + final hiddenLibrariesProvider = context.watch(); + final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys; + + // Compute visible libraries (filtered from all libraries) + final visibleLibraries = _allLibraries + .where((lib) => !hiddenKeys.contains(lib.key)) + .toList(); + return Scaffold( body: CustomScrollView( slivers: [ @@ -329,7 +506,7 @@ class _LibrariesScreenState extends State shadowColor: Colors.transparent, scrolledUnderElevation: 0, actions: [ - if (_libraries.isNotEmpty) + if (_allLibraries.isNotEmpty) IconButton( icon: Icon( _isReorderMode ? Icons.check : Icons.edit, @@ -341,6 +518,11 @@ class _LibrariesScreenState extends State }); }, ), + if (_sortOptions.isNotEmpty) + IconButton( + icon: const Icon(Icons.swap_vert, semanticLabel: 'Sort'), + onPressed: _showSortBottomSheet, + ), if (_filters.isNotEmpty) IconButton( icon: Badge( @@ -355,7 +537,7 @@ class _LibrariesScreenState extends State ), IconButton( icon: const Icon(Icons.refresh, semanticLabel: 'Refresh'), - onPressed: () => _loadLibraryContent(_selectedLibraryIndex), + onPressed: () => _loadLibraryContent(_selectedLibraryKey!), ), ], ), @@ -363,7 +545,7 @@ class _LibrariesScreenState extends State const SliverFillRemaining( child: Center(child: CircularProgressIndicator()), ) - else if (_errorMessage != null && _libraries.isEmpty) + else if (_errorMessage != null && visibleLibraries.isEmpty) SliverFillRemaining( child: Center( child: Column( @@ -385,7 +567,7 @@ class _LibrariesScreenState extends State ), ), ) - else if (_libraries.isEmpty) + else if (visibleLibraries.isEmpty) const SliverFillRemaining( child: Center( child: Column( @@ -415,7 +597,7 @@ class _LibrariesScreenState extends State ? ReorderableListView.builder( scrollDirection: Axis.horizontal, onReorder: _reorderLibraries, - itemCount: _libraries.length, + itemCount: _allLibraries.length, proxyDecorator: (child, index, animation) { return Material( elevation: 4, @@ -424,8 +606,9 @@ class _LibrariesScreenState extends State ); }, itemBuilder: (context, index) { - final library = _libraries[index]; - final isSelected = index == _selectedLibraryIndex; + final library = _allLibraries[index]; + final isSelected = library.key == _selectedLibraryKey; + final isHidden = hiddenKeys.contains(library.key); final t = tokens(context); return Container( key: ValueKey(library.key), @@ -447,6 +630,14 @@ class _LibrariesScreenState extends State ), const SizedBox(width: 6), Text(library.title), + if (isHidden) ...[ + const SizedBox(width: 6), + Icon( + Icons.visibility_off, + size: 16, + color: isSelected ? t.bg : t.text, + ), + ], ], ), backgroundColor: isSelected ? t.text : t.surface, @@ -464,41 +655,55 @@ class _LibrariesScreenState extends State : SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( - children: List.generate(_libraries.length, (index) { - final library = _libraries[index]; - final isSelected = index == _selectedLibraryIndex; + children: List.generate(visibleLibraries.length, (index) { + final library = visibleLibraries[index]; + final isSelected = library.key == _selectedLibraryKey; final t = tokens(context); return Padding( padding: const EdgeInsets.only(right: 8), - child: ChoiceChip( - label: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - _getLibraryIcon(library.type), - size: 16, - color: isSelected ? t.bg : t.text, - ), - const SizedBox(width: 6), - Text(library.title), - ], - ), - selected: isSelected, - onSelected: (selected) { - if (selected) { - _loadLibraryContent(index); + child: ContextMenuWrapper( + menuItems: [ + ContextMenuItem( + value: 'hide', + icon: Icons.visibility_off, + label: 'Hide "${library.title}"', + ), + ], + onMenuItemSelected: (value) { + if (value == 'hide') { + _hideLibrary(library); } }, - backgroundColor: t.surface, - selectedColor: t.text, - side: BorderSide(color: t.outline), - labelStyle: TextStyle( - color: isSelected ? t.bg : t.text, - fontWeight: isSelected - ? FontWeight.w600 - : FontWeight.w400, + child: ChoiceChip( + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _getLibraryIcon(library.type), + size: 16, + color: isSelected ? t.bg : t.text, + ), + const SizedBox(width: 6), + Text(library.title), + ], + ), + selected: isSelected, + onSelected: (selected) { + if (selected) { + _loadLibraryContent(library.key); + } + }, + backgroundColor: t.surface, + selectedColor: t.text, + side: BorderSide(color: t.outline), + labelStyle: TextStyle( + color: isSelected ? t.bg : t.text, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.w400, + ), + showCheckmark: false, ), - showCheckmark: false, ), ); }), @@ -528,7 +733,7 @@ class _LibrariesScreenState extends State const SizedBox(height: 16), ElevatedButton( onPressed: () => - _loadLibraryContent(_selectedLibraryIndex), + _loadLibraryContent(_selectedLibraryKey!), child: const Text('Retry'), ), ], @@ -552,8 +757,11 @@ class _LibrariesScreenState extends State SliverPadding( padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 190, + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: _getMaxCrossAxisExtent( + context, + context.watch().libraryDensity, + ), childAspectRatio: 2 / 3.3, crossAxisSpacing: 0, mainAxisSpacing: 0, @@ -588,6 +796,51 @@ class _LibrariesScreenState extends State return Icons.folder; } } + + double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) { + final screenWidth = MediaQuery.of(context).size.width; + final padding = 16.0; // 8px left + 8px right + final availableWidth = screenWidth - padding; + + if (screenWidth >= 900) { + // Wide screens (desktop/large tablet landscape): Responsive division + double divisor; + double maxItemWidth; + + switch (density) { + case LibraryDensity.comfortable: + divisor = 6.5; + maxItemWidth = 280; + break; + case LibraryDensity.normal: + divisor = 8.0; + maxItemWidth = 200; + break; + case LibraryDensity.compact: + divisor = 10.0; + maxItemWidth = 160; + break; + } + + return (availableWidth / divisor).clamp(0, maxItemWidth); + } else if (screenWidth >= 600) { + // Medium screens (tablets): Fixed 4-5-6 items + int targetItemCount = switch (density) { + LibraryDensity.comfortable => 4, + LibraryDensity.normal => 5, + LibraryDensity.compact => 6, + }; + return availableWidth / targetItemCount; + } else { + // Small screens (phones): Fixed 2-3-4 items + int targetItemCount = switch (density) { + LibraryDensity.comfortable => 2, + LibraryDensity.normal => 3, + LibraryDensity.compact => 4, + }; + return availableWidth / targetItemCount; + } + } } class _FiltersBottomSheet extends StatefulWidget { @@ -904,3 +1157,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/screens/search_screen.dart b/lib/screens/search_screen.dart index 588353e3..fe2a2d7a 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -1,7 +1,10 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import '../client/plex_client.dart'; import '../models/plex_metadata.dart'; +import '../services/settings_service.dart'; +import '../providers/settings_provider.dart'; import '../utils/provider_extensions.dart'; import '../widgets/media_card.dart'; import '../widgets/desktop_app_bar.dart'; @@ -213,8 +216,11 @@ class _SearchScreenState extends State SliverPadding( padding: const EdgeInsets.all(16), sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 180, + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: _getMaxCrossAxisExtent( + context, + context.watch().libraryDensity, + ), childAspectRatio: 2 / 3.3, crossAxisSpacing: 8, mainAxisSpacing: 8, @@ -234,4 +240,49 @@ class _SearchScreenState extends State ), ); } + + double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) { + final screenWidth = MediaQuery.of(context).size.width; + final padding = 32.0; // 16px left + 16px right from SliverPadding + final availableWidth = screenWidth - padding; + + if (screenWidth >= 900) { + // Wide screens (desktop/large tablet landscape): Responsive division + double divisor; + double maxItemWidth; + + switch (density) { + case LibraryDensity.comfortable: + divisor = 6.5; + maxItemWidth = 280; + break; + case LibraryDensity.normal: + divisor = 8.0; + maxItemWidth = 200; + break; + case LibraryDensity.compact: + divisor = 10.0; + maxItemWidth = 160; + break; + } + + return (availableWidth / divisor).clamp(0, maxItemWidth); + } else if (screenWidth >= 600) { + // Medium screens (tablets): Fixed 4-5-6 items + int targetItemCount = switch (density) { + LibraryDensity.comfortable => 4, + LibraryDensity.normal => 5, + LibraryDensity.compact => 6, + }; + return availableWidth / targetItemCount; + } else { + // Small screens (phones): Fixed 2-3-4 items + int targetItemCount = switch (density) { + LibraryDensity.comfortable => 2, + LibraryDensity.normal => 3, + LibraryDensity.compact => 4, + }; + return availableWidth / targetItemCount; + } + } } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index ac32a9ed..96927186 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2,8 +2,12 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:hotkey_manager/hotkey_manager.dart'; import '../providers/theme_provider.dart'; +import '../providers/settings_provider.dart'; +import '../providers/plex_client_provider.dart'; +import '../providers/hidden_libraries_provider.dart'; import '../services/settings_service.dart' as settings; import '../services/keyboard_shortcuts_service.dart'; +import '../models/plex_library.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/hotkey_recorder_widget.dart'; import 'about_screen.dart'; @@ -42,6 +46,21 @@ class _SettingsScreenState extends State { }); } + Future _unhideLibrary(String libraryKey) async { + // Unhide library using provider + final hiddenLibrariesProvider = Provider.of( + context, + listen: false, + ); + await hiddenLibrariesProvider.unhideLibrary(libraryKey); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Library shown')), + ); + } + } + @override Widget build(BuildContext context) { if (_isLoading) { @@ -58,6 +77,8 @@ class _SettingsScreenState extends State { delegate: SliverChildListDelegate([ _buildAppearanceSection(), const SizedBox(height: 24), + _buildLibraryManagementSection(), + const SizedBox(height: 24), _buildVideoPlaybackSection(), const SizedBox(height: 24), _buildKeyboardShortcutsSection(), @@ -99,11 +120,123 @@ class _SettingsScreenState extends State { ); }, ), + Consumer( + builder: (context, settingsProvider, child) { + return ListTile( + leading: const Icon(Icons.grid_view), + title: const Text('Library Density'), + subtitle: Text(settingsProvider.libraryDensityDisplayName), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showLibraryDensityDialog(), + ); + }, + ), + Consumer( + builder: (context, settingsProvider, child) { + return SwitchListTile( + secondary: const Icon(Icons.image), + title: const Text('Use Season Posters'), + subtitle: const Text( + 'Show season poster instead of series poster for episodes', + ), + value: settingsProvider.useSeasonPoster, + onChanged: (value) async { + await settingsProvider.setUseSeasonPoster(value); + }, + ); + }, + ), ], ), ); } + Widget _buildLibraryManagementSection() { + // Watch for hidden libraries changes to trigger rebuild + final hiddenLibrariesProvider = context.watch(); + final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys; + + return Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Library Management', + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + ), + if (hiddenKeys.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Text( + 'No hidden libraries', + style: TextStyle(color: Colors.grey[600]), + ), + ) + else + // Use FutureBuilder to fetch library details for hidden keys + FutureBuilder>( + future: _fetchHiddenLibraries(hiddenKeys), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ); + } + + if (snapshot.hasError || !snapshot.hasData) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Text( + 'Error loading hidden libraries', + style: TextStyle(color: Colors.grey[600]), + ), + ); + } + + final hiddenLibraries = snapshot.data!; + return Column( + children: hiddenLibraries.map((library) { + return ListTile( + leading: const Icon(Icons.visibility_off), + title: Text(library.title), + subtitle: Text('${library.type} library'), + trailing: TextButton( + onPressed: () => _unhideLibrary(library.key), + child: const Text('Show'), + ), + ); + }).toList(), + ); + }, + ), + ], + ), + ); + } + + Future> _fetchHiddenLibraries(Set hiddenKeys) async { + final clientProvider = Provider.of( + context, + listen: false, + ); + final client = clientProvider.client; + + if (client == null) { + return []; + } + + final allLibraries = await client.getLibraries(); + return allLibraries + .where((lib) => hiddenKeys.contains(lib.key)) + .toList(); + } + Widget _buildVideoPlaybackSection() { return Card( child: Column( @@ -410,6 +543,79 @@ class _SettingsScreenState extends State { }, ); } + + void _showLibraryDensityDialog() { + final settingsProvider = context.read(); + showDialog( + context: context, + builder: (BuildContext context) { + return Consumer( + builder: (context, provider, child) { + return AlertDialog( + title: const Text('Library Density'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: Icon( + provider.libraryDensity == settings.LibraryDensity.compact + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + ), + title: const Text('Compact'), + subtitle: const Text('Smaller cards, more items visible'), + onTap: () async { + await settingsProvider.setLibraryDensity( + settings.LibraryDensity.compact, + ); + if (context.mounted) Navigator.pop(context); + }, + ), + ListTile( + leading: Icon( + provider.libraryDensity == settings.LibraryDensity.normal + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + ), + title: const Text('Normal'), + subtitle: const Text('Default size'), + onTap: () async { + await settingsProvider.setLibraryDensity( + settings.LibraryDensity.normal, + ); + if (context.mounted) Navigator.pop(context); + }, + ), + ListTile( + leading: Icon( + provider.libraryDensity == + settings.LibraryDensity.comfortable + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + ), + title: const Text('Comfortable'), + subtitle: const Text('Larger cards, fewer items visible'), + onTap: () async { + await settingsProvider.setLibraryDensity( + settings.LibraryDensity.comfortable, + ); + if (context.mounted) Navigator.pop(context); + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ], + ); + }, + ); + }, + ); + } } class _KeyboardShortcutsScreen extends StatefulWidget { diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index a25129e5..3bde3830 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -179,8 +179,9 @@ class PlexAuthService { /// Switch to a different user in the home Future switchToUser( String userUUID, - String currentToken, - ) async { + String currentToken, { + String? pin, + }) async { final queryParams = { 'includeSubscriptions': '1', 'includeProviders': '1', @@ -193,6 +194,7 @@ class PlexAuthService { 'X-Plex-Platform-Version': '3.8.1', 'X-Plex-Token': currentToken, 'X-Plex-Language': 'en', + if (pin != null) 'pin': pin, }; final queryString = queryParams.entries diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 01668be8..ad28b843 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -5,6 +5,8 @@ import 'package:hotkey_manager/hotkey_manager.dart'; enum ThemeMode { system, light, dark } +enum LibraryDensity { compact, normal, comfortable } + class SettingsService { static const String _keyThemeMode = 'theme_mode'; static const String _keyEnableDebugLogging = 'enable_debug_logging'; @@ -14,6 +16,8 @@ class SettingsService { static const String _keyEnableHardwareDecoding = 'enable_hardware_decoding'; static const String _keyPreferredVideoCodec = 'preferred_video_codec'; static const String _keyPreferredAudioCodec = 'preferred_audio_codec'; + static const String _keyLibraryDensity = 'library_density'; + static const String _keyUseSeasonPoster = 'use_season_poster'; static SettingsService? _instance; late SharedPreferences _prefs; @@ -91,6 +95,28 @@ class SettingsService { return _prefs.getString(_keyPreferredAudioCodec) ?? 'auto'; } + // Library Density + Future setLibraryDensity(LibraryDensity density) async { + await _prefs.setString(_keyLibraryDensity, density.name); + } + + LibraryDensity getLibraryDensity() { + final densityString = _prefs.getString(_keyLibraryDensity); + return LibraryDensity.values.firstWhere( + (density) => density.name == densityString, + orElse: () => LibraryDensity.normal, + ); + } + + // Use Season Poster + Future setUseSeasonPoster(bool enabled) async { + await _prefs.setBool(_keyUseSeasonPoster, enabled); + } + + bool getUseSeasonPoster() { + return _prefs.getBool(_keyUseSeasonPoster) ?? false; // Default: false (use series poster) + } + // Keyboard Shortcuts (Legacy String-based) Map getDefaultKeyboardShortcuts() { return { @@ -553,6 +579,8 @@ class SettingsService { _prefs.remove(_keyEnableHardwareDecoding), _prefs.remove(_keyPreferredVideoCodec), _prefs.remove(_keyPreferredAudioCodec), + _prefs.remove(_keyLibraryDensity), + _prefs.remove(_keyUseSeasonPoster), ]); } @@ -575,6 +603,8 @@ class SettingsService { 'enableHardwareDecoding': getEnableHardwareDecoding(), 'preferredVideoCodec': getPreferredVideoCodec(), 'preferredAudioCodec': getPreferredAudioCodec(), + 'libraryDensity': getLibraryDensity().name, + 'useSeasonPoster': getUseSeasonPoster(), 'keyboardShortcuts': getKeyboardShortcuts(), 'keyboardHotkeys': hotkeys.map( (key, value) => MapEntry(key, _serializeHotKey(value)), diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index dfe3b4fe..1b1c9349 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -8,12 +8,14 @@ class StorageService { static const String _keyServerData = 'server_data'; static const String _keyClientId = 'client_identifier'; static const String _keySelectedLibraryIndex = 'selected_library_index'; + static const String _keySelectedLibraryKey = 'selected_library_key'; static const String _keyLibraryFilters = 'library_filters'; static const String _keyLibraryOrder = 'library_order'; static const String _keyUserProfile = 'user_profile'; static const String _keyCurrentUserUUID = 'current_user_uuid'; static const String _keyHomeUsersCache = 'home_users_cache'; static const String _keyHomeUsersCacheExpiry = 'home_users_cache_expiry'; + static const String _keyHiddenLibraries = 'hidden_libraries'; static StorageService? _instance; late SharedPreferences _prefs; @@ -136,7 +138,7 @@ class StorageService { }; } - // Selected Library Index + // Selected Library Index (deprecated - use library key instead) Future saveSelectedLibraryIndex(int index) async { await _prefs.setInt(_keySelectedLibraryIndex, index); } @@ -145,6 +147,15 @@ class StorageService { return _prefs.getInt(_keySelectedLibraryIndex); } + // Selected Library Key (replaces index-based selection) + Future saveSelectedLibraryKey(String key) async { + await _prefs.setString(_keySelectedLibraryKey, key); + } + + String? getSelectedLibraryKey() { + return _prefs.getString(_keySelectedLibraryKey); + } + // Library Filters (stored as JSON string) Future saveLibraryFilters(Map filters) async { final jsonString = json.encode(filters); @@ -163,13 +174,48 @@ 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'; + } + + // Hidden Libraries (stored as JSON array of library section IDs) + Future saveHiddenLibraries(Set libraryKeys) async { + final list = libraryKeys.toList(); + final jsonString = json.encode(list); + await _prefs.setString(_keyHiddenLibraries, jsonString); + } + + Set getHiddenLibraries() { + final jsonString = _prefs.getString(_keyHiddenLibraries); + if (jsonString == null) return {}; + + try { + final list = json.decode(jsonString) as List; + return list.map((e) => e.toString()).toSet(); + } catch (e) { + return {}; + } + } + // Clear library preferences Future clearLibraryPreferences() async { await Future.wait([ _prefs.remove(_keySelectedLibraryIndex), _prefs.remove(_keyLibraryFilters), _prefs.remove(_keyLibraryOrder), + _prefs.remove(_keyHiddenLibraries), ]); + + // 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))); } // Library Order (stored as JSON list of library keys) diff --git a/lib/utils/provider_extensions.dart b/lib/utils/provider_extensions.dart index 3ebede88..973fd4c4 100644 --- a/lib/utils/provider_extensions.dart +++ b/lib/utils/provider_extensions.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/plex_client_provider.dart'; import '../providers/user_profile_provider.dart'; +import '../providers/hidden_libraries_provider.dart'; import '../client/plex_client.dart'; import '../models/plex_user_profile.dart'; @@ -18,6 +19,12 @@ extension ProviderExtensions on BuildContext { UserProfileProvider watchUserProfile() => Provider.of(this, listen: true); + HiddenLibrariesProvider get hiddenLibraries => + Provider.of(this, listen: false); + + HiddenLibrariesProvider watchHiddenLibraries() => + Provider.of(this, listen: true); + // Direct client access (nullable) PlexClient? get client => plexClient.client; diff --git a/lib/widgets/context_menu_wrapper.dart b/lib/widgets/context_menu_wrapper.dart new file mode 100644 index 00000000..255bcdbb --- /dev/null +++ b/lib/widgets/context_menu_wrapper.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; +import '../utils/platform_detector.dart'; + +/// A menu action item for context menus +class ContextMenuItem { + final String value; + final IconData icon; + final String label; + + const ContextMenuItem({ + required this.value, + required this.icon, + required this.label, + }); +} + +/// A wrapper widget that shows context menus differently based on platform. +/// On mobile (iOS/Android): Shows a bottom sheet on long-press +/// On desktop (Windows/macOS/Linux): Shows a popup menu on right-click or long-press +class ContextMenuWrapper extends StatefulWidget { + final Widget child; + final List menuItems; + final Function(String)? onMenuItemSelected; + final VoidCallback? onTap; + final String? title; + + const ContextMenuWrapper({ + super.key, + required this.child, + required this.menuItems, + this.onMenuItemSelected, + this.onTap, + this.title, + }); + + @override + State createState() => _ContextMenuWrapperState(); +} + +class _ContextMenuWrapperState extends State { + Offset _tapPosition = Offset.zero; + + void _storeTapPosition(TapDownDetails details) { + _tapPosition = details.globalPosition; + } + + Future _showContextMenu(BuildContext context) async { + final useBottomSheet = PlatformDetector.isMobile(context); + String? selected; + + if (useBottomSheet) { + // Mobile: Show bottom sheet + selected = await showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.title != null) + Padding( + padding: const EdgeInsets.all(16), + child: Text( + widget.title!, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ...widget.menuItems.map( + (item) => ListTile( + leading: Icon(item.icon), + title: Text(item.label), + onTap: () => Navigator.pop(context, item.value), + ), + ), + ], + ), + ), + ); + } else { + // Desktop: Show popup menu + final RenderBox overlay = + Overlay.of(context).context.findRenderObject() as RenderBox; + final overlayRect = Rect.fromPoints( + _tapPosition, + _tapPosition.translate(1, 1), + ); + + final menuItems = widget.menuItems + .map( + (item) => PopupMenuItem( + value: item.value, + child: Row( + children: [ + Icon(item.icon, size: 20), + const SizedBox(width: 12), + Expanded(child: Text(item.label)), + ], + ), + ), + ) + .toList(); + + selected = await showMenu( + context: context, + position: RelativeRect.fromRect( + overlayRect, + Offset.zero & overlay.size, + ), + items: menuItems, + elevation: 8, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + popUpAnimationStyle: AnimationStyle( + duration: const Duration(milliseconds: 150), + reverseDuration: const Duration(milliseconds: 100), + ), + ); + } + + if (selected != null && widget.onMenuItemSelected != null) { + widget.onMenuItemSelected!(selected); + } + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: widget.onTap, + onTapDown: _storeTapPosition, + onLongPress: () => _showContextMenu(context), + onSecondaryTapDown: _storeTapPosition, + onSecondaryTap: () => _showContextMenu(context), + child: widget.child, + ); + } +} diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index de5030e6..89e029fe 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -3,6 +3,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:provider/provider.dart'; import '../models/plex_metadata.dart'; import '../providers/plex_client_provider.dart'; +import '../providers/settings_provider.dart'; import '../utils/provider_extensions.dart'; import '../utils/video_player_navigation.dart'; import '../screens/media_detail_screen.dart'; @@ -172,7 +173,9 @@ class _MediaCardState extends State { } Widget _buildPosterImage(BuildContext context) { - if (widget.item.posterThumb != null) { + final useSeasonPoster = context.watch().useSeasonPoster; + final posterUrl = widget.item.posterThumb(useSeasonPoster: useSeasonPoster); + if (posterUrl != null) { return Consumer( builder: (context, clientProvider, child) { final client = clientProvider.client; @@ -185,7 +188,7 @@ class _MediaCardState extends State { } return CachedNetworkImage( - imageUrl: client.getThumbnailUrl(widget.item.posterThumb), + imageUrl: client.getThumbnailUrl(posterUrl), fit: BoxFit.cover, width: double.infinity, height: double.infinity, diff --git a/lib/widgets/pin_entry_dialog.dart b/lib/widgets/pin_entry_dialog.dart new file mode 100644 index 00000000..86fad92a --- /dev/null +++ b/lib/widgets/pin_entry_dialog.dart @@ -0,0 +1,158 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Dialog for entering a PIN to access a protected profile +class PinEntryDialog extends StatefulWidget { + final String userName; + final String? errorMessage; + + const PinEntryDialog({super.key, required this.userName, this.errorMessage}); + + @override + State createState() => _PinEntryDialogState(); +} + +class _PinEntryDialogState extends State + with SingleTickerProviderStateMixin { + final _pinController = TextEditingController(); + final _focusNode = FocusNode(); + bool _obscureText = true; + late AnimationController _shakeController; + late Animation _shakeAnimation; + + @override + void initState() { + super.initState(); + + // Setup shake animation + _shakeController = AnimationController( + duration: const Duration(milliseconds: 600), + vsync: this, + ); + + // Create a shake effect that oscillates + _shakeAnimation = + TweenSequence([ + TweenSequenceItem(tween: Tween(begin: 0.0, end: 10.0), weight: 1), + TweenSequenceItem(tween: Tween(begin: 10.0, end: -10.0), weight: 1), + TweenSequenceItem(tween: Tween(begin: -10.0, end: 10.0), weight: 1), + TweenSequenceItem(tween: Tween(begin: 10.0, end: -10.0), weight: 1), + TweenSequenceItem(tween: Tween(begin: -10.0, end: 0.0), weight: 1), + ]).animate( + CurvedAnimation(parent: _shakeController, curve: Curves.easeInOut), + ); + + // Auto-focus the PIN field when dialog opens + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNode.requestFocus(); + + // If there's an error message, trigger shake and clear field + if (widget.errorMessage != null) { + _pinController.clear(); + _shakeController.forward(from: 0); + } + }); + } + + @override + void dispose() { + _pinController.dispose(); + _focusNode.dispose(); + _shakeController.dispose(); + super.dispose(); + } + + void _submit() { + final pin = _pinController.text.trim(); + if (pin.isEmpty) { + return; + } + Navigator.of(context).pop(pin); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return AnimatedBuilder( + animation: _shakeAnimation, + builder: (context, child) { + return Transform.translate( + offset: Offset(_shakeAnimation.value, 0), + child: child, + ); + }, + child: AlertDialog( + title: Row( + children: [ + Icon( + Icons.lock_outline, + size: 24, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 12), + Expanded( + child: Text(widget.userName, overflow: TextOverflow.ellipsis), + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _pinController, + focusNode: _focusNode, + obscureText: _obscureText, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(10), + ], + decoration: InputDecoration( + hintText: 'Enter PIN', + border: const OutlineInputBorder(), + errorText: widget.errorMessage, + errorMaxLines: 2, + suffixIcon: IconButton( + icon: Icon( + _obscureText ? Icons.visibility_off : Icons.visibility, + size: 20, + ), + onPressed: () { + setState(() { + _obscureText = !_obscureText; + }); + }, + tooltip: _obscureText ? 'Show PIN' : 'Hide PIN', + ), + ), + onSubmitted: (_) => _submit(), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(null), + child: const Text('Cancel'), + ), + FilledButton(onPressed: _submit, child: const Text('Submit')), + ], + ), + ); + } +} + +/// Shows the PIN entry dialog and returns the entered PIN, or null if cancelled +Future showPinEntryDialog( + BuildContext context, + String userName, { + String? errorMessage, +}) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) => + PinEntryDialog(userName: userName, errorMessage: errorMessage), + ); +}