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:
@@ -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}\"?",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user