feat: library hiding

This commit is contained in:
edde746
2025-11-04 06:45:16 +01:00
parent dcb4f3ee8a
commit 95263b7625
7 changed files with 536 additions and 85 deletions
+2
View File
@@ -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();
}
}
+177 -79
View File
@@ -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'),
),
],
+120 -5
View File
@@ -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);
},
),
+32 -1
View File
@@ -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
+7
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/plex_client_provider.dart';
import '../providers/user_profile_provider.dart';
import '../providers/hidden_libraries_provider.dart';
import '../client/plex_client.dart';
import '../models/plex_user_profile.dart';
@@ -18,6 +19,12 @@ extension ProviderExtensions on BuildContext {
UserProfileProvider watchUserProfile() =>
Provider.of<UserProfileProvider>(this, listen: true);
HiddenLibrariesProvider get hiddenLibraries =>
Provider.of<HiddenLibrariesProvider>(this, listen: false);
HiddenLibrariesProvider watchHiddenLibraries() =>
Provider.of<HiddenLibrariesProvider>(this, listen: true);
// Direct client access (nullable)
PlexClient? get client => plexClient.client;
+137
View File
@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import '../utils/platform_detector.dart';
/// A menu action item for context menus
class ContextMenuItem {
final String value;
final IconData icon;
final String label;
const ContextMenuItem({
required this.value,
required this.icon,
required this.label,
});
}
/// A wrapper widget that shows context menus differently based on platform.
/// On mobile (iOS/Android): Shows a bottom sheet on long-press
/// On desktop (Windows/macOS/Linux): Shows a popup menu on right-click or long-press
class ContextMenuWrapper extends StatefulWidget {
final Widget child;
final List<ContextMenuItem> menuItems;
final Function(String)? onMenuItemSelected;
final VoidCallback? onTap;
final String? title;
const ContextMenuWrapper({
super.key,
required this.child,
required this.menuItems,
this.onMenuItemSelected,
this.onTap,
this.title,
});
@override
State<ContextMenuWrapper> createState() => _ContextMenuWrapperState();
}
class _ContextMenuWrapperState extends State<ContextMenuWrapper> {
Offset _tapPosition = Offset.zero;
void _storeTapPosition(TapDownDetails details) {
_tapPosition = details.globalPosition;
}
Future<void> _showContextMenu(BuildContext context) async {
final useBottomSheet = PlatformDetector.isMobile(context);
String? selected;
if (useBottomSheet) {
// Mobile: Show bottom sheet
selected = await showModalBottomSheet<String>(
context: context,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.title != null)
Padding(
padding: const EdgeInsets.all(16),
child: Text(
widget.title!,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
...widget.menuItems.map(
(item) => ListTile(
leading: Icon(item.icon),
title: Text(item.label),
onTap: () => Navigator.pop(context, item.value),
),
),
],
),
),
);
} else {
// Desktop: Show popup menu
final RenderBox overlay =
Overlay.of(context).context.findRenderObject() as RenderBox;
final overlayRect = Rect.fromPoints(
_tapPosition,
_tapPosition.translate(1, 1),
);
final menuItems = widget.menuItems
.map(
(item) => PopupMenuItem<String>(
value: item.value,
child: Row(
children: [
Icon(item.icon, size: 20),
const SizedBox(width: 12),
Expanded(child: Text(item.label)),
],
),
),
)
.toList();
selected = await showMenu<String>(
context: context,
position: RelativeRect.fromRect(
overlayRect,
Offset.zero & overlay.size,
),
items: menuItems,
elevation: 8,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
popUpAnimationStyle: AnimationStyle(
duration: const Duration(milliseconds: 150),
reverseDuration: const Duration(milliseconds: 100),
),
);
}
if (selected != null && widget.onMenuItemSelected != null) {
widget.onMenuItemSelected!(selected);
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: widget.onTap,
onTapDown: _storeTapPosition,
onLongPress: () => _showContextMenu(context),
onSecondaryTapDown: _storeTapPosition,
onSecondaryTap: () => _showContextMenu(context),
child: widget.child,
);
}
}