diff --git a/lib/screens/libraries/filters_bottom_sheet.dart b/lib/screens/libraries/filters_bottom_sheet.dart index fde2456b1..5e3d405e0 100644 --- a/lib/screens/libraries/filters_bottom_sheet.dart +++ b/lib/screens/libraries/filters_bottom_sheet.dart @@ -19,6 +19,7 @@ class FiltersBottomSheet extends StatefulWidget { final Function(Map) onFiltersChanged; final String serverId; final String libraryKey; + final VoidCallback? onBack; /// Optional pre-fetched values per filter name. When non-null the sheet /// reads from this instead of calling `client.getFilterValues` — used @@ -33,6 +34,7 @@ class FiltersBottomSheet extends StatefulWidget { required this.onFiltersChanged, required this.serverId, required this.libraryKey, + this.onBack, this.cachedValues, }); @@ -182,7 +184,7 @@ class _FiltersBottomSheetState extends State { return BottomSheetPageScaffold( title: currentFilter?.title ?? t.libraries.filters, icon: Symbols.filter_alt_rounded, - onBack: currentFilter != null ? _goBack : null, + onBack: currentFilter != null ? _goBack : widget.onBack, action: currentFilter == null && _tempSelectedFilters.isNotEmpty ? FocusableButton( onPressed: _clearFilters, diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index 2bbccee86..ca78e8703 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -313,6 +313,15 @@ class _LibrariesScreenState extends State }; } + void _showBrowseOptionsForCurrentTab() { + if (_visibleTabs.isEmpty) return; + final index = tabController.index.clamp(0, _visibleTabs.length - 1).toInt(); + if (_visibleTabs[index] != LibraryTabType.browse) return; + final tabState = _browseTabKey.currentState; + if (tabState == null) return; + (tabState as dynamic).showBrowseOptionsSheet(); + } + /// Handle when a tab's data has finished loading void _handleTabDataLoaded(int tabIndex) { // Track that this tab has loaded @@ -1003,6 +1012,24 @@ class _LibrariesScreenState extends State final currentTabIndex = _visibleTabs.isEmpty ? 0 : tabController.index.clamp(0, _visibleTabs.length - 1).toInt(); final currentTabType = _visibleTabs.isEmpty ? null : _visibleTabs[currentTabIndex]; final useTvRecommendedBackdrop = PlatformDetector.isTV() && currentTabType == LibraryTabType.recommended; + final showBrowseOptionsAction = + selectedLibrary != null && PlatformDetector.isMobile(context) && currentTabType == LibraryTabType.browse; + + List appBarActions() => [ + if (allLibraries.isNotEmpty) + FocusableAction( + icon: Symbols.edit_rounded, + tooltip: t.libraries.manageLibraries, + onPressed: _showLibraryManagementSheet, + ), + if (showBrowseOptionsAction) + FocusableAction( + icon: Symbols.tune_rounded, + tooltip: t.libraries.libraryOptions, + onPressed: _showBrowseOptionsForCurrentTab, + ), + FocusableAction(icon: Symbols.refresh_rounded, tooltip: t.common.refresh, onPressed: _refreshSelectedLibraryTabs), + ]; Widget appBar({required bool floating}) => DesktopSliverAppBar( title: _buildAppBarTitle(visibleLibraries, selectedLibrary, groupByServer: groupByServerSetting), @@ -1021,19 +1048,7 @@ class _LibrariesScreenState extends State key: _actionBarKey, onNavigateLeft: () => getTabChipFocusNode(_visibleTabs.length - 1).requestFocus(), onNavigateDown: _focusCurrentTab, - actions: [ - if (allLibraries.isNotEmpty) - FocusableAction( - icon: Symbols.edit_rounded, - tooltip: t.libraries.manageLibraries, - onPressed: _showLibraryManagementSheet, - ), - FocusableAction( - icon: Symbols.refresh_rounded, - tooltip: t.common.refresh, - onPressed: _refreshSelectedLibraryTabs, - ), - ], + actions: appBarActions(), ), ], ); @@ -1064,19 +1079,7 @@ class _LibrariesScreenState extends State key: _actionBarKey, onNavigateLeft: () => getTabChipFocusNode(_visibleTabs.length - 1).requestFocus(), onNavigateDown: _focusCurrentTab, - actions: [ - if (allLibraries.isNotEmpty) - FocusableAction( - icon: Symbols.edit_rounded, - tooltip: t.libraries.manageLibraries, - onPressed: _showLibraryManagementSheet, - ), - FocusableAction( - icon: Symbols.refresh_rounded, - tooltip: t.common.refresh, - onPressed: _refreshSelectedLibraryTabs, - ), - ], + actions: appBarActions(), ), ], ), diff --git a/lib/screens/libraries/sort_bottom_sheet.dart b/lib/screens/libraries/sort_bottom_sheet.dart index 7bbb34327..44693f7e7 100644 --- a/lib/screens/libraries/sort_bottom_sheet.dart +++ b/lib/screens/libraries/sort_bottom_sheet.dart @@ -19,6 +19,7 @@ class SortBottomSheet extends StatefulWidget { final bool isSortDescending; final Function(MediaSort, bool) onSortChanged; final VoidCallback? onClear; + final VoidCallback? onBack; const SortBottomSheet({ super.key, @@ -27,6 +28,7 @@ class SortBottomSheet extends StatefulWidget { required this.isSortDescending, required this.onSortChanged, this.onClear, + this.onBack, }); @override @@ -105,6 +107,7 @@ class _SortBottomSheetState extends State { children: [ BottomSheetHeader( title: t.libraries.sortBy, + onBack: widget.onBack, action: widget.onClear != null ? FocusableButton( onPressed: _handleClear, @@ -113,70 +116,126 @@ class _SortBottomSheetState extends State { : null, ), Flexible( - child: RadioGroup( - groupValue: _currentSort, - onChanged: (value) { - if (value != null) _handleSortSelect(value); - }, - child: ListView.builder( - controller: _scrollController, - primary: false, - shrinkWrap: true, - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: widget.sortOptions.length, - itemBuilder: (context, index) { - final sort = widget.sortOptions[index]; - final isSelected = _currentSort?.key == sort.key; + child: ListView.builder( + controller: _scrollController, + primary: false, + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: widget.sortOptions.length, + itemBuilder: (context, index) { + final sort = widget.sortOptions[index]; + final isSelected = _currentSort?.key == sort.key; - return Focus( - key: index == 0 ? _firstItemKey : null, - canRequestFocus: false, - skipTraversal: true, - onKeyEvent: (node, event) { - if (!event.isActionable) return KeyEventResult.ignored; - if (!isSelected) return KeyEventResult.ignored; - if (event.logicalKey.isLeftKey) { - _handleDirectionChange(sort, false); - return KeyEventResult.handled; - } - if (event.logicalKey.isRightKey) { - _handleDirectionChange(sort, true); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, - child: FocusableRadioListTile( - focusNode: (widget.selectedSort?.key == sort.key || (widget.selectedSort == null && index == 0)) - ? _initialFocusNode - : null, - title: Text(sort.title), - value: sort, - secondary: Visibility( - visible: isSelected, - maintainAnimation: true, - maintainSize: true, - maintainState: true, - child: SegmentedButton( - showSelectedIcon: false, - segments: const [ - ButtonSegment(value: false, icon: AppIcon(Symbols.arrow_upward_rounded, fill: 1, size: 16)), - ButtonSegment(value: true, icon: AppIcon(Symbols.arrow_downward_rounded, fill: 1, size: 16)), - ], - selected: {_currentDescending}, - onSelectionChanged: isSelected - ? (Set newSelection) { - _handleDirectionChange(sort, newSelection.first); - } - : null, - ), + return Focus( + key: index == 0 ? _firstItemKey : null, + canRequestFocus: false, + skipTraversal: true, + onKeyEvent: (node, event) { + if (!event.isActionable) return KeyEventResult.ignored; + if (!isSelected) return KeyEventResult.ignored; + if (event.logicalKey.isLeftKey) { + _handleDirectionChange(sort, false); + return KeyEventResult.handled; + } + if (event.logicalKey.isRightKey) { + _handleDirectionChange(sort, true); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: FocusableListTile( + focusNode: (widget.selectedSort?.key == sort.key || (widget.selectedSort == null && index == 0)) + ? _initialFocusNode + : null, + leading: AppIcon( + isSelected ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded, + fill: 1, + ), + title: Text(sort.title), + trailing: Visibility( + visible: isSelected, + maintainAnimation: true, + maintainSize: true, + maintainState: true, + child: SegmentedButton( + // Match FocusableListTile's dense visualDensity so the segment's min + // tap-target height equals ListTile's trailing-height cap. Without this + // the ~48dp button overflows the ~36dp cap from the top and the arrows + // render bottom-aligned instead of centered. + style: SegmentedButton.styleFrom(visualDensity: const VisualDensity(vertical: -3)), + showSelectedIcon: false, + segments: const [ + ButtonSegment(value: false, label: _SortDirectionIcon(upward: true)), + ButtonSegment(value: true, label: _SortDirectionIcon(upward: false)), + ], + selected: {_currentDescending}, + onSelectionChanged: isSelected + ? (Set newSelection) { + _handleDirectionChange(sort, newSelection.first); + } + : null, ), ), - ); - }, - ), + onTap: () => _handleSortSelect(sort), + ), + ); + }, ), ), ], ); } } + +class _SortDirectionIcon extends StatelessWidget { + final bool upward; + + const _SortDirectionIcon({required this.upward}); + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: 16, + child: CustomPaint( + painter: _SortDirectionArrowPainter(color: IconTheme.of(context).color, upward: upward), + ), + ); + } +} + +class _SortDirectionArrowPainter extends CustomPainter { + final Color? color; + final bool upward; + + const _SortDirectionArrowPainter({required this.color, required this.upward}); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color ?? Colors.black + ..strokeWidth = 2.25 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..style = PaintingStyle.stroke; + + final centerX = size.width / 2; + final top = size.height * 0.14; + final bottom = size.height * 0.86; + final headY = upward ? top : bottom; + final tailY = upward ? bottom : top; + final wingY = upward ? top + size.height * 0.28 : bottom - size.height * 0.28; + final wingOffset = size.width * 0.28; + + final path = Path() + ..moveTo(centerX, tailY) + ..lineTo(centerX, headY) + ..moveTo(centerX - wingOffset, wingY) + ..lineTo(centerX, headY) + ..lineTo(centerX + wingOffset, wingY); + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant _SortDirectionArrowPainter oldDelegate) => + color != oldDelegate.color || upward != oldDelegate.upward; +} diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 8152c4436..dc3ecad96 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -37,6 +37,7 @@ import '../../../widgets/focusable_filter_chip.dart'; import '../../../widgets/loading_indicator_box.dart'; import '../../../widgets/media_grid_delegate.dart'; import '../../../widgets/media_card_list_layout.dart'; +import '../../../widgets/bottom_sheet_page_scaffold.dart'; import '../../../widgets/overlay_sheet.dart'; import '../../../mixins/library_tab_focus_mixin.dart'; import '../folder_tree_view.dart'; @@ -426,10 +427,22 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _buildBrowseOptionsSheet(sheetContext)); + } + /// Reset transient browse state before loading a different library. void _resetForFullReload() { _scrollActivityTimer?.cancel(); @@ -738,9 +751,50 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _showGroupingOptionsPage(controller), + ), + if (_isFiltersChipVisible) + FocusableListTile( + leading: const AppIcon(Symbols.filter_alt_rounded, fill: 1), + title: Text( + _selectedFilters.isEmpty + ? t.libraries.filters + : t.libraries.filtersWithCount(count: _selectedFilters.length), + ), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () => _showFiltersOptionsPage(controller), + ), + if (_isSortChipVisible) + FocusableListTile( + leading: const AppIcon(Symbols.sort_rounded, fill: 1), + title: Text(t.libraries.sort), + subtitle: _selectedSort == null ? null : Text(_selectedSort!.title), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () => _showSortOptionsPage(controller), + ), + ], + ), + ); + } + void _showGroupingBottomSheet() { SelectKeyUpSuppressor.suppressSelectUntilKeyUp(); - final options = _getGroupingOptions(); final controller = OverlaySheetController.of(context); controller .show( @@ -759,119 +813,164 @@ class _LibraryBrowseTabState extends BaseLibraryTabState controller.close(grouping), - ); - }).toList(), - ), + child: Column(mainAxisSize: .min, children: _buildGroupingTiles((value) => controller.close(value))), ), ), ], ), ) - .then((value) { - if (!mounted || value == null || value == _selectedGrouping) return; - setState(() { - _selectedGrouping = value; - }); - StorageService.getInstance().then((storage) { - storage.saveLibraryGrouping(widget.library.globalKey, value); - }); - _loadItems(); - _loadFirstCharacters(); - }); + .then(_handleGroupingSelection); } - void _showFiltersBottomSheet() { + void _showGroupingOptionsPage(OverlaySheetController controller) { SelectKeyUpSuppressor.suppressSelectUntilKeyUp(); - OverlaySheetController.of(context).show( - builder: (context) => FiltersBottomSheet( - filters: _filters, - selectedFilters: _selectedFilters, - serverId: widget.library.serverId!, - libraryKey: widget.library.globalKey, - // Pre-populated values arrive only from backends that bundle them - // 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); - }); + controller + .push( + builder: (_) => + _buildGroupingBottomSheet(onBack: () => controller.pop(), onSelected: (value) => controller.close(value)), + ) + .then(_handleGroupingSelection); + } - // Save filters to storage - final storage = await StorageService.getInstance(); - await storage.saveLibraryFilters(filters, sectionId: widget.library.globalKey); - - unawaited(_loadItems()); - unawaited(_loadFirstCharacters()); - }, + Widget _buildGroupingBottomSheet({required ValueChanged onSelected, VoidCallback? onBack}) { + return BottomSheetPageScaffold( + title: t.libraries.groupings.title, + icon: Symbols.category_rounded, + onBack: onBack, + shrinkWrap: true, + child: ListView( + primary: false, + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 8), + children: _buildGroupingTiles(onSelected), ), ); } + List _buildGroupingTiles(ValueChanged onSelected) { + final options = _getGroupingOptions(); + return options.map((grouping) { + final isSelected = _selectedGrouping == grouping; + return FocusableListTile( + key: ValueKey(grouping), + dense: true, + leading: AppIcon( + isSelected ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded, + fill: 1, + ), + title: Text(_getGroupingLabel(grouping)), + onTap: () => onSelected(grouping), + ); + }).toList(); + } + + void _handleGroupingSelection(String? value) { + if (!mounted || value == null || value == _selectedGrouping) return; + setState(() { + _selectedGrouping = value; + }); + StorageService.getInstance().then((storage) { + storage.saveLibraryGrouping(widget.library.globalKey, value); + }); + _loadItems(); + _loadFirstCharacters(); + } + + void _showFiltersBottomSheet() { + SelectKeyUpSuppressor.suppressSelectUntilKeyUp(); + OverlaySheetController.of(context).show(builder: (_) => _buildFiltersBottomSheet()); + } + + void _showFiltersOptionsPage(OverlaySheetController controller) { + SelectKeyUpSuppressor.suppressSelectUntilKeyUp(); + controller.push(builder: (_) => _buildFiltersBottomSheet(onBack: () => controller.pop())); + } + + Widget _buildFiltersBottomSheet({VoidCallback? onBack}) { + return FiltersBottomSheet( + filters: _filters, + selectedFilters: _selectedFilters, + serverId: widget.library.serverId!, + libraryKey: widget.library.globalKey, + onBack: onBack, + // Pre-populated values arrive only from backends that bundle them + // 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()); + }, + ); + } + void _showSortBottomSheet() { + final controller = OverlaySheetController.of(context); + _openSortBottomSheet((builder) => controller.show(builder: builder)); + } + + void _showSortOptionsPage(OverlaySheetController controller) { + _openSortBottomSheet((builder) => controller.push(builder: builder), onBack: () => controller.pop()); + } + + void _openSortBottomSheet(Future Function(WidgetBuilder builder) open, {VoidCallback? onBack}) { SelectKeyUpSuppressor.suppressSelectUntilKeyUp(); // Track pending state in local variables so the callbacks don't trigger // setState/_loadItems while the sheet is open (which would steal focus). MediaSort? pendingSort = _selectedSort; bool pendingDescending = _isSortDescending; bool pendingCleared = false; - OverlaySheetController.of(context) - .show( - builder: (context) => SortBottomSheet( - sortOptions: _sortOptions, - selectedSort: _selectedSort, - isSortDescending: _isSortDescending, - onSortChanged: (sort, descending) { - pendingSort = sort; - pendingDescending = descending; - pendingCleared = false; - }, - onClear: () { - pendingSort = null; - pendingDescending = false; - pendingCleared = true; - }, - ), - ) - .then((_) { - if (!mounted) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - if (pendingCleared) { - setState(() { - _selectedSort = null; - _isSortDescending = false; - }); - _loadItems(); - _loadFirstCharacters(); - } else if (pendingSort != null && - (pendingSort!.key != _selectedSort?.key || pendingDescending != _isSortDescending)) { - setState(() { - _selectedSort = pendingSort; - _isSortDescending = pendingDescending; - }); - StorageService.getInstance().then((storage) { - storage.saveLibrarySort(widget.library.globalKey, pendingSort!.key, descending: pendingDescending); - }); - _loadItems(); - _loadFirstCharacters(); - } + open( + (context) => SortBottomSheet( + sortOptions: _sortOptions, + selectedSort: _selectedSort, + isSortDescending: _isSortDescending, + onBack: onBack, + onSortChanged: (sort, descending) { + pendingSort = sort; + pendingDescending = descending; + pendingCleared = false; + }, + onClear: () { + pendingSort = null; + pendingDescending = false; + pendingCleared = true; + }, + ), + ).then((_) { + if (!mounted) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (pendingCleared) { + setState(() { + _selectedSort = null; + _isSortDescending = false; }); - }); + _loadItems(); + _loadFirstCharacters(); + } else if (pendingSort != null && + (pendingSort!.key != _selectedSort?.key || pendingDescending != _isSortDescending)) { + setState(() { + _selectedSort = pendingSort; + _isSortDescending = pendingDescending; + }); + StorageService.getInstance().then((storage) { + storage.saveLibrarySort(widget.library.globalKey, pendingSort!.key, descending: pendingDescending); + }); + _loadItems(); + _loadFirstCharacters(); + } + }); + }); } /// Navigate focus from chips down to the grid item. @@ -956,8 +1055,12 @@ class _LibraryBrowseTabState extends BaseLibraryTabState PlatformDetector.isPhone(context); + /// Mobile uses a top-bar options action instead of inline browse chips. + bool get _usesMobileBrowseOptions => PlatformDetector.isMobile(context); + /// The letter currently visible at the top of the grid, determined by /// how many items we've scrolled past relative to the API's cumulative /// firstCharacter counts. @@ -1094,8 +1200,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _chipsBarHeight + _effectiveTopPadding; + double get _contentStartScrollOffset => (_usesMobileBrowseOptions ? 0.0 : _chipsBarHeight) + _effectiveTopPadding; /// Handle a tap on the letter at [targetIndex] in the alpha bar. The /// active [LibraryAlphaBarStrategy] owns the per-backend behaviour and @@ -1150,7 +1256,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _buildChipsBar(), height: _chipsBarHeight), - ), + if (!_usesMobileBrowseOptions) + SliverPersistentHeader( + floating: true, + pinned: false, + delegate: _ChipsBarDelegate(builder: (_) => _buildChipsBar(), height: _chipsBarHeight), + ), ..._buildContentSlivers(), ], ), diff --git a/lib/widgets/bottom_sheet_page_scaffold.dart b/lib/widgets/bottom_sheet_page_scaffold.dart index 8dfc44d5e..3110083f2 100644 --- a/lib/widgets/bottom_sheet_page_scaffold.dart +++ b/lib/widgets/bottom_sheet_page_scaffold.dart @@ -19,6 +19,7 @@ class BottomSheetPageScaffold extends StatelessWidget { final bool showHeaderBorder; final bool showHeaderDivider; final FocusNode? closeFocusNode; + final bool shrinkWrap; const BottomSheetPageScaffold({ super.key, @@ -35,11 +36,13 @@ class BottomSheetPageScaffold extends StatelessWidget { this.showHeaderBorder = true, this.showHeaderDivider = false, this.closeFocusNode, + this.shrinkWrap = false, }); @override Widget build(BuildContext context) { Widget content = Column( + mainAxisSize: shrinkWrap ? MainAxisSize.min : MainAxisSize.max, children: [ BottomSheetHeader( title: title, @@ -55,7 +58,7 @@ class BottomSheetPageScaffold extends StatelessWidget { closeFocusNode: closeFocusNode, ), if (showHeaderDivider) Divider(color: Theme.of(context).dividerColor, height: 1), - Expanded(child: child), + if (shrinkWrap) child else Expanded(child: child), ], );