Merge branch 'main' into feature/library-reordering-6

# Conflicts:
#	lib/screens/libraries_screen.dart
#	lib/services/storage_service.dart
This commit is contained in:
edde746
2025-11-04 06:56:21 +01:00
20 changed files with 1436 additions and 104 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
+8
View File
@@ -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<ThemeProvider>(
builder: (context, themeProvider, child) {
+1 -1
View File
@@ -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;
}
+16 -5
View File
@@ -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;
+3 -1
View File
@@ -15,7 +15,7 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) =>
'grandparentArt': instance.grandparentArt,
'grandparentRatingKey': instance.grandparentRatingKey,
'parentTitle': instance.parentTitle,
'parentThumb': instance.parentThumb,
'parentRatingKey': instance.parentRatingKey,
'parentIndex': instance.parentIndex,
'index': instance.index,
+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;
}
@@ -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<String> _hiddenLibraryKeys = {};
bool _isInitialized = false;
/// Get an unmodifiable copy of hidden library keys
Set<String> 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<void> _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<void> 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<void> 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<void> refresh() async {
_hiddenLibraryKeys = _storageService.getHiddenLibraries();
notifyListeners();
}
}
+49
View File
@@ -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<void> _initializeSettings() async {
_settingsService = await SettingsService.getInstance();
_libraryDensity = _settingsService.getLibraryDensity();
_useSeasonPoster = _settingsService.getUseSeasonPoster();
notifyListeners();
}
LibraryDensity get libraryDensity => _libraryDensity;
bool get useSeasonPoster => _useSeasonPoster;
Future<void> setLibraryDensity(LibraryDensity density) async {
if (_libraryDensity != density) {
_libraryDensity = density;
await _settingsService.setLibraryDensity(density);
notifyListeners();
}
}
Future<void> 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';
}
}
}
+58
View File
@@ -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<bool> _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;
+1 -1
View File
@@ -797,7 +797,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
[
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)
+477 -89
View File
@@ -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<LibrariesScreen>
@override
PlexClient get client => context.clientSafe;
List<PlexLibrary> _libraries = [];
List<PlexLibrary> _allLibraries = []; // All libraries from API (unfiltered)
List<PlexMetadata> _items = [];
List<PlexFilter> _filters = [];
List<PlexSort> _sortOptions = [];
bool _isLoadingLibraries = true;
bool _isLoadingItems = false;
String? _errorMessage;
int _selectedLibraryIndex = 0;
String? _selectedLibraryKey;
Map<String, String> _selectedFilters = {};
PlexSort? _selectedSort;
bool _isSortDescending = false;
bool _isInitialLoad = true;
bool _isReorderMode = false;
@@ -44,51 +52,74 @@ class _LibrariesScreenState extends State<LibrariesScreen>
}
Future<void> _loadLibraries() async {
// Extract context dependencies before async gap
final clientProvider = Provider.of<PlexClientProvider>(
context,
listen: false,
);
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(
context,
listen: false,
);
setState(() {
_isLoadingLibraries = true;
_errorMessage = null;
});
try {
final clientProvider = Provider.of<PlexClientProvider>(
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<LibrariesScreen>
Future<void> _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<LibrariesScreen>
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<void> _loadLibraryContent(int index) async {
if (index < 0 || index >= _libraries.length) return;
Future<void> _loadLibraryContent(String libraryKey) async {
// Compute visible libraries based on current provider state
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(
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<LibrariesScreen>
}
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<LibrariesScreen>
_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<LibrariesScreen>
}
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<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,
libraryKey,
filters: filtersWithSort,
);
setState(() {
_items = items;
@@ -220,9 +261,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
}
}
Future<void> _loadFilters(int index) async {
if (index < 0 || index >= _libraries.length) return;
Future<void> _loadFilters(String libraryKey) async {
try {
final clientProvider = Provider.of<PlexClientProvider>(
context,
@@ -233,7 +272,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
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<LibrariesScreen>
}
}
Future<void> _loadSortOptions(String libraryKey) async {
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(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<void> _applyFilters() async {
setState(() {
_isLoadingItems = true;
@@ -260,9 +348,17 @@ 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,
_selectedLibraryKey!,
filters: filtersWithSort,
);
setState(() {
_items = items;
@@ -276,6 +372,24 @@ 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(
_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<LibrariesScreen>
// Public method to refresh content
@override
void refresh() {
if (_libraries.isNotEmpty) {
if (_allLibraries.isNotEmpty) {
_applyFilters();
}
}
Future<void> _hideLibrary(PlexLibrary library) async {
// Hide library using provider
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(
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<void> _unhideLibrary(String libraryKey) async {
// Unhide library using provider
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(
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<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) {
// Watch for hidden libraries changes to trigger rebuild
final hiddenLibrariesProvider = context.watch<HiddenLibrariesProvider>();
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<LibrariesScreen>
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<LibrariesScreen>
});
},
),
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<LibrariesScreen>
),
IconButton(
icon: const Icon(Icons.refresh, semanticLabel: 'Refresh'),
onPressed: () => _loadLibraryContent(_selectedLibraryIndex),
onPressed: () => _loadLibraryContent(_selectedLibraryKey!),
),
],
),
@@ -363,7 +545,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
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<LibrariesScreen>
),
),
)
else if (_libraries.isEmpty)
else if (visibleLibraries.isEmpty)
const SliverFillRemaining(
child: Center(
child: Column(
@@ -415,7 +597,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
? 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<LibrariesScreen>
);
},
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<LibrariesScreen>
),
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<LibrariesScreen>
: 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<LibrariesScreen>
const SizedBox(height: 16),
ElevatedButton(
onPressed: () =>
_loadLibraryContent(_selectedLibraryIndex),
_loadLibraryContent(_selectedLibraryKey!),
child: const Text('Retry'),
),
],
@@ -552,8 +757,11 @@ class _LibrariesScreenState extends State<LibrariesScreen>
SliverPadding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 190,
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: _getMaxCrossAxisExtent(
context,
context.watch<SettingsProvider>().libraryDensity,
),
childAspectRatio: 2 / 3.3,
crossAxisSpacing: 0,
mainAxisSpacing: 0,
@@ -588,6 +796,51 @@ class _LibrariesScreenState extends State<LibrariesScreen>
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<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);
},
);
},
),
),
],
);
},
);
}
}
+53 -2
View File
@@ -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<SearchScreen>
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 180,
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: _getMaxCrossAxisExtent(
context,
context.watch<SettingsProvider>().libraryDensity,
),
childAspectRatio: 2 / 3.3,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
@@ -234,4 +240,49 @@ class _SearchScreenState extends State<SearchScreen>
),
);
}
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;
}
}
}
+206
View File
@@ -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<SettingsScreen> {
});
}
Future<void> _unhideLibrary(String libraryKey) async {
// Unhide library using provider
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(
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<SettingsScreen> {
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<SettingsScreen> {
);
},
),
Consumer<SettingsProvider>(
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<SettingsProvider>(
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<HiddenLibrariesProvider>();
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<List<PlexLibrary>>(
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<List<PlexLibrary>> _fetchHiddenLibraries(Set<String> hiddenKeys) async {
final clientProvider = Provider.of<PlexClientProvider>(
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<SettingsScreen> {
},
);
}
void _showLibraryDensityDialog() {
final settingsProvider = context.read<SettingsProvider>();
showDialog(
context: context,
builder: (BuildContext context) {
return Consumer<SettingsProvider>(
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 {
+4 -2
View File
@@ -179,8 +179,9 @@ class PlexAuthService {
/// Switch to a different user in the home
Future<UserSwitchResponse> 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
+30
View File
@@ -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<void> 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<void> 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<String, String> 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)),
+47 -1
View File
@@ -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<void> 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<void> saveSelectedLibraryKey(String key) async {
await _prefs.setString(_keySelectedLibraryKey, key);
}
String? getSelectedLibraryKey() {
return _prefs.getString(_keySelectedLibraryKey);
}
// Library Filters (stored as JSON string)
Future<void> saveLibraryFilters(Map<String, String> filters) async {
final jsonString = json.encode(filters);
@@ -163,13 +174,48 @@ 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';
}
// Hidden Libraries (stored as JSON array of library section IDs)
Future<void> saveHiddenLibraries(Set<String> libraryKeys) async {
final list = libraryKeys.toList();
final jsonString = json.encode(list);
await _prefs.setString(_keyHiddenLibraries, jsonString);
}
Set<String> getHiddenLibraries() {
final jsonString = _prefs.getString(_keyHiddenLibraries);
if (jsonString == null) return {};
try {
final list = json.decode(jsonString) as List<dynamic>;
return list.map((e) => e.toString()).toSet();
} catch (e) {
return {};
}
}
// Clear library preferences
Future<void> 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)
+7
View File
@@ -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<UserProfileProvider>(this, listen: true);
HiddenLibrariesProvider get hiddenLibraries =>
Provider.of<HiddenLibrariesProvider>(this, listen: false);
HiddenLibrariesProvider watchHiddenLibraries() =>
Provider.of<HiddenLibrariesProvider>(this, listen: true);
// Direct client access (nullable)
PlexClient? get client => plexClient.client;
+137
View File
@@ -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<ContextMenuItem> 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<ContextMenuWrapper> createState() => _ContextMenuWrapperState();
}
class _ContextMenuWrapperState extends State<ContextMenuWrapper> {
Offset _tapPosition = Offset.zero;
void _storeTapPosition(TapDownDetails details) {
_tapPosition = details.globalPosition;
}
Future<void> _showContextMenu(BuildContext context) async {
final useBottomSheet = PlatformDetector.isMobile(context);
String? selected;
if (useBottomSheet) {
// Mobile: Show bottom sheet
selected = await showModalBottomSheet<String>(
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<String>(
value: item.value,
child: Row(
children: [
Icon(item.icon, size: 20),
const SizedBox(width: 12),
Expanded(child: Text(item.label)),
],
),
),
)
.toList();
selected = await showMenu<String>(
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,
);
}
}
+5 -2
View File
@@ -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<MediaCard> {
}
Widget _buildPosterImage(BuildContext context) {
if (widget.item.posterThumb != null) {
final useSeasonPoster = context.watch<SettingsProvider>().useSeasonPoster;
final posterUrl = widget.item.posterThumb(useSeasonPoster: useSeasonPoster);
if (posterUrl != null) {
return Consumer<PlexClientProvider>(
builder: (context, clientProvider, child) {
final client = clientProvider.client;
@@ -185,7 +188,7 @@ class _MediaCardState extends State<MediaCard> {
}
return CachedNetworkImage(
imageUrl: client.getThumbnailUrl(widget.item.posterThumb),
imageUrl: client.getThumbnailUrl(posterUrl),
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
+158
View File
@@ -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<PinEntryDialog> createState() => _PinEntryDialogState();
}
class _PinEntryDialogState extends State<PinEntryDialog>
with SingleTickerProviderStateMixin {
final _pinController = TextEditingController();
final _focusNode = FocusNode();
bool _obscureText = true;
late AnimationController _shakeController;
late Animation<double> _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<double>([
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<String?> showPinEntryDialog(
BuildContext context,
String userName, {
String? errorMessage,
}) {
return showDialog<String>(
context: context,
barrierDismissible: false,
builder: (context) =>
PinEntryDialog(userName: userName, errorMessage: errorMessage),
);
}