feat: library hiding
This commit is contained in:
@@ -14,6 +14,7 @@ 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';
|
||||
@@ -68,6 +69,7 @@ class MainApp extends StatelessWidget {
|
||||
),
|
||||
ChangeNotifierProvider(create: (context) => ThemeProvider()),
|
||||
ChangeNotifierProvider(create: (context) => SettingsProvider()),
|
||||
ChangeNotifierProvider(create: (context) => HiddenLibrariesProvider()),
|
||||
],
|
||||
child: Consumer<ThemeProvider>(
|
||||
builder: (context, themeProvider, child) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,12 @@ 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';
|
||||
@@ -29,14 +31,14 @@ 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;
|
||||
@@ -49,45 +51,70 @@ 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();
|
||||
|
||||
setState(() {
|
||||
_libraries = libraries;
|
||||
_allLibraries = allLibraries; // Store all libraries without filtering
|
||||
_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(() {
|
||||
@@ -97,10 +124,22 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -114,7 +153,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)
|
||||
@@ -128,9 +167,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) {
|
||||
@@ -139,19 +178,20 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
|
||||
try {
|
||||
// Load filters and sort options for the new library
|
||||
_loadFilters(index);
|
||||
_loadSortOptions(index);
|
||||
_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);
|
||||
filtersWithSort['sort'] = _selectedSort!.getSortKey(
|
||||
descending: _isSortDescending,
|
||||
);
|
||||
}
|
||||
|
||||
// Load content
|
||||
final items = await client.getLibraryContent(
|
||||
_libraries[index].key,
|
||||
libraryKey,
|
||||
filters: filtersWithSort,
|
||||
);
|
||||
setState(() {
|
||||
@@ -166,9 +206,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,
|
||||
@@ -179,7 +217,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;
|
||||
});
|
||||
@@ -190,9 +228,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadSortOptions(int index) async {
|
||||
if (index < 0 || index >= _libraries.length) return;
|
||||
|
||||
Future<void> _loadSortOptions(String libraryKey) async {
|
||||
try {
|
||||
final clientProvider = Provider.of<PlexClientProvider>(
|
||||
context,
|
||||
@@ -203,11 +239,11 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
throw Exception('No client available');
|
||||
}
|
||||
|
||||
final sortOptions = await client.getLibrarySorts(_libraries[index].key);
|
||||
final sortOptions = await client.getLibrarySorts(libraryKey);
|
||||
|
||||
// Load saved sort preference for this library
|
||||
final storage = await StorageService.getInstance();
|
||||
final savedSortKey = storage.getLibrarySort(_libraries[index].key);
|
||||
final savedSortKey = storage.getLibrarySort(libraryKey);
|
||||
|
||||
// Find the saved sort in the options
|
||||
PlexSort? savedSort;
|
||||
@@ -260,12 +296,13 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
// Add sort parameter to filters if selected
|
||||
final filtersWithSort = Map<String, String>.from(_selectedFilters);
|
||||
if (_selectedSort != null) {
|
||||
filtersWithSort['sort'] =
|
||||
_selectedSort!.getSortKey(descending: _isSortDescending);
|
||||
filtersWithSort['sort'] = _selectedSort!.getSortKey(
|
||||
descending: _isSortDescending,
|
||||
);
|
||||
}
|
||||
|
||||
final items = await client.getLibraryContent(
|
||||
_libraries[_selectedLibraryIndex].key,
|
||||
_selectedLibraryKey!,
|
||||
filters: filtersWithSort,
|
||||
);
|
||||
setState(() {
|
||||
@@ -289,7 +326,10 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
// Save sort preference for this library
|
||||
final storage = await StorageService.getInstance();
|
||||
final sortKey = sort.getSortKey(descending: descending);
|
||||
await storage.saveLibrarySort(_libraries[_selectedLibraryIndex].key, sortKey);
|
||||
await storage.saveLibrarySort(
|
||||
_selectedLibraryKey!,
|
||||
sortKey,
|
||||
);
|
||||
|
||||
// Reload content with new sort
|
||||
_applyFilters();
|
||||
@@ -306,11 +346,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,
|
||||
@@ -352,6 +430,15 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
|
||||
@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: [
|
||||
@@ -366,10 +453,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
actions: [
|
||||
if (_sortOptions.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.swap_vert,
|
||||
semanticLabel: 'Sort',
|
||||
),
|
||||
icon: const Icon(Icons.swap_vert, semanticLabel: 'Sort'),
|
||||
onPressed: _showSortBottomSheet,
|
||||
),
|
||||
if (_filters.isNotEmpty)
|
||||
@@ -386,7 +470,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, semanticLabel: 'Refresh'),
|
||||
onPressed: () => _loadLibraryContent(_selectedLibraryIndex),
|
||||
onPressed: () => _loadLibraryContent(_selectedLibraryKey!),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -394,7 +478,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(
|
||||
@@ -416,7 +500,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (_libraries.isEmpty)
|
||||
else if (visibleLibraries.isEmpty)
|
||||
const SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
@@ -444,41 +528,55 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
child: 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,
|
||||
),
|
||||
);
|
||||
}),
|
||||
@@ -508,7 +606,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () =>
|
||||
_loadLibraryContent(_selectedLibraryIndex),
|
||||
_loadLibraryContent(_selectedLibraryKey!),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -3,8 +3,11 @@ 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';
|
||||
@@ -43,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) {
|
||||
@@ -59,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(),
|
||||
@@ -116,7 +136,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
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'),
|
||||
subtitle: const Text(
|
||||
'Show season poster instead of series poster for episodes',
|
||||
),
|
||||
value: settingsProvider.useSeasonPoster,
|
||||
onChanged: (value) async {
|
||||
await settingsProvider.setUseSeasonPoster(value);
|
||||
@@ -129,6 +151,92 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -457,7 +565,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
title: const Text('Compact'),
|
||||
subtitle: const Text('Smaller cards, more items visible'),
|
||||
onTap: () async {
|
||||
await settingsProvider.setLibraryDensity(settings.LibraryDensity.compact);
|
||||
await settingsProvider.setLibraryDensity(
|
||||
settings.LibraryDensity.compact,
|
||||
);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -470,20 +580,25 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
title: const Text('Normal'),
|
||||
subtitle: const Text('Default size'),
|
||||
onTap: () async {
|
||||
await settingsProvider.setLibraryDensity(settings.LibraryDensity.normal);
|
||||
await settingsProvider.setLibraryDensity(
|
||||
settings.LibraryDensity.normal,
|
||||
);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
provider.libraryDensity == settings.LibraryDensity.comfortable
|
||||
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);
|
||||
await settingsProvider.setLibraryDensity(
|
||||
settings.LibraryDensity.comfortable,
|
||||
);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -8,11 +8,13 @@ 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 _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;
|
||||
@@ -135,7 +137,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);
|
||||
}
|
||||
@@ -144,6 +146,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);
|
||||
@@ -172,11 +183,31 @@ class StorageService {
|
||||
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(_keyHiddenLibraries),
|
||||
]);
|
||||
|
||||
// Also clear all library sort preferences
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user