feat(library): indicate active filters in browse view

Badge the mobile Library options icon with a dot while filters are
active, and replace the "This library is empty" state with a
"No items match the active filters" message plus a reset button.

close #1470
This commit is contained in:
edde746
2026-07-04 20:36:23 +02:00
parent 8fe66f6e36
commit eb8796fa26
6 changed files with 117 additions and 18 deletions
+2
View File
@@ -736,6 +736,8 @@
"allLibrariesHidden": "All libraries are hidden",
"hiddenLibrariesCount": "Hidden libraries (${count})",
"thisLibraryIsEmpty": "This library is empty",
"noItemsMatchFilters": "No items match the active filters",
"resetFilters": "Reset filters",
"all": "All",
"clearAll": "Clear All",
"scanLibraryConfirm": "Are you sure you want to scan \"${title}\"?",
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 16
/// Strings: 20716 (1294 per locale)
/// Strings: 20718 (1294 per locale)
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+10 -2
View File
@@ -2269,6 +2269,12 @@ class TranslationsLibrariesEn {
/// en: 'This library is empty'
String get thisLibraryIsEmpty => 'This library is empty';
/// en: 'No items match the active filters'
String get noItemsMatchFilters => 'No items match the active filters';
/// en: 'Reset filters'
String get resetFilters => 'Reset filters';
/// en: 'All'
String get all => 'All';
@@ -5261,6 +5267,8 @@ extension on Translations {
'libraries.allLibrariesHidden' => 'All libraries are hidden',
'libraries.hiddenLibrariesCount' => ({required Object count}) => 'Hidden libraries (${count})',
'libraries.thisLibraryIsEmpty' => 'This library is empty',
'libraries.noItemsMatchFilters' => 'No items match the active filters',
'libraries.resetFilters' => 'Reset filters',
'libraries.all' => 'All',
'libraries.clearAll' => 'Clear All',
'libraries.scanLibraryConfirm' => ({required Object title}) => 'Are you sure you want to scan "${title}"?',
@@ -5597,10 +5605,10 @@ extension on Translations {
'shaders.shaderImportFailed' => 'Failed to import shader',
'shaders.deleteShader' => 'Delete Shader',
'shaders.deleteShaderConfirm' => ({required Object name}) => 'Delete "${name}"?',
'companionRemote.title' => 'Companion Remote',
'companionRemote.connectedTo' => ({required Object name}) => 'Connected to ${name}',
_ => null,
} ?? switch (path) {
'companionRemote.title' => 'Companion Remote',
'companionRemote.connectedTo' => ({required Object name}) => 'Connected to ${name}',
'companionRemote.unknownDevice' => 'Unknown Device',
'companionRemote.session.startingServer' => 'Starting remote server...',
'companionRemote.session.failedToCreate' => 'Failed to start remote server:',
@@ -27,12 +27,30 @@ class SliverEmptyState extends StatelessWidget {
final String message;
final IconData? icon;
final String? subtitle;
final VoidCallback? onAction;
final String? actionLabel;
final IconData? actionIcon;
const SliverEmptyState({super.key, required this.message, required this.icon, this.subtitle});
const SliverEmptyState({
super.key,
required this.message,
required this.icon,
this.subtitle,
this.onAction,
this.actionLabel,
this.actionIcon,
});
@override
Widget build(BuildContext context) => SliverFillRemaining(
child: EmptyStateWidget(message: message, icon: icon, subtitle: subtitle),
child: EmptyStateWidget(
message: message,
icon: icon,
subtitle: subtitle,
onAction: onAction,
actionLabel: actionLabel,
actionIcon: actionIcon,
),
);
}
@@ -107,6 +107,9 @@ class _LibrariesScreenState extends State<LibrariesScreen>
/// Track which tabs have loaded data (used to trigger focus after tab restore)
final Set<int> _loadedTabs = {};
/// Whether the browse tab has active filters (badges the Library options icon)
bool _browseFiltersActive = false;
/// Key for the library dropdown menu button.
final _libraryDropdownKey = GlobalKey<AppMenuButtonState<String>>();
@@ -324,6 +327,12 @@ class _LibrariesScreenState extends State<LibrariesScreen>
(tabState as dynamic).showBrowseOptionsSheet();
}
/// Handle when the browse tab's active-filter state changes
void _handleBrowseFiltersActiveChanged(bool active) {
if (_browseFiltersActive == active) return;
setState(() => _browseFiltersActive = active);
}
/// Handle when a tab's data has finished loading
void _handleTabDataLoaded(int tabIndex) {
// Track that this tab has loaded
@@ -426,6 +435,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
onDataLoaded: () => _handleTabDataLoaded(tabIndex),
onBack: focusTabBar,
onResetScroll: _resetOuterScroll,
onFiltersActiveChanged: _handleBrowseFiltersActiveChanged,
),
LibraryTabType.collections => LibraryCollectionsTab(
key: _collectionsTabKey,
@@ -1031,6 +1041,32 @@ class _LibrariesScreenState extends State<LibrariesScreen>
icon: Symbols.tune_rounded,
tooltip: t.libraries.libraryOptions,
onPressed: _showBrowseOptionsForCurrentTab,
// Badge the icon with a dot while the browse tab has active filters
// (issue #1470). A null child keeps the default rendering.
child: _browseFiltersActive
? IconButton(
tooltip: t.libraries.libraryOptions,
onPressed: _showBrowseOptionsForCurrentTab,
icon: Stack(
clipBehavior: Clip.none,
children: [
const AppIcon(Symbols.tune_rounded, fill: 1),
Positioned(
top: -2,
right: -2,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
),
),
),
],
),
)
: null,
),
FocusableAction(icon: Symbols.refresh_rounded, tooltip: t.common.refresh, onPressed: _refreshSelectedLibraryTabs),
];
@@ -73,6 +73,10 @@ class LibraryBrowseTab extends BaseLibraryTab<MediaItem> {
/// (filter/sort change, library reload, etc.). Lets the parent resync the
/// outer floating header — see `_resetOuterScroll` in libraries_screen.
final VoidCallback? onResetScroll;
/// Notifies the parent when the active-filter state changes so the app
/// bar can badge the Library options action on mobile.
final ValueChanged<bool>? onFiltersActiveChanged;
final bool canGroupByFolders;
const LibraryBrowseTab({
@@ -86,6 +90,7 @@ class LibraryBrowseTab extends BaseLibraryTab<MediaItem> {
super.suppressAutoFocus,
super.onBack,
this.onResetScroll,
this.onFiltersActiveChanged,
});
@override
@@ -566,6 +571,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
}
}
});
_notifyFiltersActive();
if (_isJellyfinLibrary) {
_loadJellyfinFiltersInBackground(generation);
@@ -601,6 +607,18 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
);
}
/// Reports `_selectedFilters.isNotEmpty` to the parent post-frame, since
/// filter state also mutates during load paths driven by initState /
/// didUpdateWidget where a synchronous parent setState would throw.
void _notifyFiltersActive() {
final cb = widget.onFiltersActiveChanged;
if (cb == null) return;
final active = _selectedFilters.isNotEmpty;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) cb(active);
});
}
/// Initial UI state both Plex and Jellyfin paths need before fetching:
/// loading flag set, lists cleared, filter/sort caches reset.
void _resetTopOfPageState() {
@@ -622,6 +640,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
_scrollMetrics = LibraryAlphaScrollMetrics.empty;
_measuredListRowHeight = null;
});
_notifyFiltersActive();
}
/// Build the filter params map for API calls
@@ -916,22 +935,27 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
// with the category listing (Jellyfin's `/Items/Filters`). The empty
// map for Plex libraries falls through to lazy `getFilterValues`.
cachedValues: _jellyfinFilterValues.isEmpty ? null : _jellyfinFilterValues,
onFiltersChanged: (filters) async {
setState(() {
_selectedFilters.clear();
_selectedFilters.addAll(filters);
});
// Save filters to storage
final storage = await StorageService.getInstance();
await storage.saveLibraryFilters(filters, sectionId: widget.library.globalKey);
unawaited(_loadItems());
unawaited(_loadFirstCharacters());
},
onFiltersChanged: _applyFilters,
);
}
Future<void> _applyFilters(Map<String, String> filters) async {
setState(() {
_selectedFilters.clear();
_selectedFilters.addAll(filters);
});
_notifyFiltersActive();
// Save filters to storage
final storage = await StorageService.getInstance();
await storage.saveLibraryFilters(filters, sectionId: widget.library.globalKey);
unawaited(_loadItems());
unawaited(_loadFirstCharacters());
}
void _resetFilters() => unawaited(_applyFilters(const {}));
Future<List<MediaFilterValue>> _loadFilterValues(MediaFilter filter) async {
if (!mounted) return const [];
@@ -1659,6 +1683,17 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
}
if (totalSize == 0 && !isLoading) {
if (_selectedFilters.isNotEmpty) {
return [
SliverEmptyState(
message: t.libraries.noItemsMatchFilters,
icon: Symbols.filter_alt_off_rounded,
onAction: _resetFilters,
actionLabel: t.libraries.resetFilters,
actionIcon: Symbols.clear_all_rounded,
),
];
}
return [SliverEmptyState(message: t.libraries.thisLibraryIsEmpty, icon: Symbols.folder_open_rounded)];
}