Merge branch 'main' into feature/library-reordering-6
# Conflicts: # lib/screens/libraries_screen.dart # lib/services/storage_service.dart
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user