feat(libraries): add mobile browse options sheet
This commit is contained in:
@@ -19,6 +19,7 @@ class FiltersBottomSheet extends StatefulWidget {
|
||||
final Function(Map<String, String>) 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<FiltersBottomSheet> {
|
||||
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,
|
||||
|
||||
@@ -313,6 +313,15 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
};
|
||||
}
|
||||
|
||||
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<LibrariesScreen>
|
||||
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<FocusableAction> 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<LibrariesScreen>
|
||||
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<LibrariesScreen>
|
||||
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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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<SortBottomSheet> {
|
||||
children: [
|
||||
BottomSheetHeader(
|
||||
title: t.libraries.sortBy,
|
||||
onBack: widget.onBack,
|
||||
action: widget.onClear != null
|
||||
? FocusableButton(
|
||||
onPressed: _handleClear,
|
||||
@@ -113,70 +116,126 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
|
||||
: null,
|
||||
),
|
||||
Flexible(
|
||||
child: RadioGroup<MediaSort>(
|
||||
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<MediaSort>(
|
||||
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<bool>(
|
||||
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<bool> 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<bool>(
|
||||
// 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<bool> 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;
|
||||
}
|
||||
|
||||
@@ -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<MediaItem, LibraryBrows
|
||||
/// Focus the chips bar (for navigating from tab bar to content).
|
||||
/// Called by libraries screen when pressing DOWN on tab bar.
|
||||
void focusChipsBar() {
|
||||
if (_usesMobileBrowseOptions) {
|
||||
focusFirstItem();
|
||||
return;
|
||||
}
|
||||
lastFocusedGridIndex = null;
|
||||
_groupingChipFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
/// Show the mobile browse options sheet from the parent app bar.
|
||||
void showBrowseOptionsSheet() {
|
||||
if (!mounted) return;
|
||||
SelectKeyUpSuppressor.suppressSelectUntilKeyUp();
|
||||
final controller = OverlaySheetController.of(context);
|
||||
controller.show(builder: (sheetContext) => _buildBrowseOptionsSheet(sheetContext));
|
||||
}
|
||||
|
||||
/// Reset transient browse state before loading a different library.
|
||||
void _resetForFullReload() {
|
||||
_scrollActivityTimer?.cancel();
|
||||
@@ -738,9 +751,50 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
return mapUnexpectedErrorToMessage(error, context: t.libraries.content);
|
||||
}
|
||||
|
||||
Widget _buildBrowseOptionsSheet(BuildContext sheetContext) {
|
||||
final controller = OverlaySheetController.of(sheetContext);
|
||||
return BottomSheetPageScaffold(
|
||||
title: t.libraries.libraryOptions,
|
||||
icon: Symbols.tune_rounded,
|
||||
shrinkWrap: true,
|
||||
child: ListView(
|
||||
primary: false,
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
children: [
|
||||
FocusableListTile(
|
||||
leading: const AppIcon(Symbols.category_rounded, fill: 1),
|
||||
title: Text(t.libraries.groupings.title),
|
||||
subtitle: Text(_getGroupingLabel(_selectedGrouping)),
|
||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
onTap: () => _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<String>(
|
||||
@@ -759,119 +813,164 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
children: 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: () => 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<String>(
|
||||
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<String> 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<Widget> _buildGroupingTiles(ValueChanged<String> 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<dynamic> 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<MediaItem, LibraryBrows
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate focus from grid up to the chips bar
|
||||
/// Navigate focus from grid up to the chips bar, or the tab bar on mobile.
|
||||
void _navigateToChips() {
|
||||
if (_usesMobileBrowseOptions) {
|
||||
widget.onBack?.call();
|
||||
return;
|
||||
}
|
||||
_groupingChipFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
@@ -974,6 +1077,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
/// Whether the device is a phone (not tablet/desktop/TV).
|
||||
bool _isPhone(BuildContext context) => 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<MediaItem, LibraryBrows
|
||||
}
|
||||
|
||||
/// Compute the first visible item index from a scroll offset.
|
||||
/// Chips bar is the first sliver (height = _chipsBarHeight) followed by the
|
||||
/// grid's own top padding before the first row.
|
||||
/// The inline chips bar, when present, is followed by the grid's own top
|
||||
/// padding before the first row.
|
||||
int _itemIndexFromScrollOffset(double offset) {
|
||||
return _scrollMetrics.itemIndexFromScrollOffset(
|
||||
offset,
|
||||
@@ -1104,7 +1210,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
);
|
||||
}
|
||||
|
||||
double get _contentStartScrollOffset => _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<MediaItem, LibraryBrows
|
||||
_loadItems();
|
||||
}
|
||||
|
||||
/// Scroll the current layout so that [index] is visible just below the chips bar
|
||||
/// Scroll the current layout so that [index] is visible just below the chrome.
|
||||
void _scrollToItemIndex(int index) {
|
||||
final pos = _innerPosition;
|
||||
if (!_scrollMetrics.isUsable || pos == null) {
|
||||
@@ -1158,8 +1264,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
return;
|
||||
}
|
||||
|
||||
// Position the target row at the top of the viewport. Chips and the grid's
|
||||
// top padding both precede the items in scroll coordinates.
|
||||
// Position the target row at the top of the viewport. Inline chips, when
|
||||
// present, and grid top padding both precede items in scroll coordinates.
|
||||
final offset = _scrollMetrics.scrollOffsetForItemIndex(index, contentStartOffset: _contentStartScrollOffset);
|
||||
|
||||
final gen = _jumpScrollGeneration;
|
||||
@@ -1189,12 +1295,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||
|
||||
// Chips are inline as a floating sliver in the inner scroll. The alpha
|
||||
// jump bar is the only overlay; we offset it by the typical app bar height
|
||||
// so it isn't obscured by the floating outer header. Using MediaQuery
|
||||
// (not the absorber handle) avoids rebuilding during layout — listening to
|
||||
// the handle from a builder fires notifyListeners during the build phase
|
||||
// and triggers a setState-in-build assertion.
|
||||
// The alpha jump bar is the only overlay; we offset it by the typical app
|
||||
// bar height so it isn't obscured by the floating outer header. Using
|
||||
// MediaQuery (not the absorber handle) avoids rebuilding during layout —
|
||||
// listening to the handle from a builder fires notifyListeners during the
|
||||
// build phase and triggers a setState-in-build assertion.
|
||||
final media = MediaQuery.of(context);
|
||||
final overlayTopPadding = media.padding.top + kToolbarHeight;
|
||||
|
||||
@@ -1237,7 +1342,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the scrollable content with chips, then either folder tree or grid/list.
|
||||
/// Builds the scrollable content with optional chips, then folder tree or grid/list.
|
||||
Widget _buildScrollableContent() {
|
||||
final isFolders = _selectedGrouping == 'folders';
|
||||
|
||||
@@ -1282,11 +1387,12 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
// Floating chips: scroll off with content but snap back into view
|
||||
// on upward direction reversal, matching the outer floating
|
||||
// SliverAppBar's behavior.
|
||||
SliverPersistentHeader(
|
||||
floating: true,
|
||||
pinned: false,
|
||||
delegate: _ChipsBarDelegate(builder: (_) => _buildChipsBar(), height: _chipsBarHeight),
|
||||
),
|
||||
if (!_usesMobileBrowseOptions)
|
||||
SliverPersistentHeader(
|
||||
floating: true,
|
||||
pinned: false,
|
||||
delegate: _ChipsBarDelegate(builder: (_) => _buildChipsBar(), height: _chipsBarHeight),
|
||||
),
|
||||
..._buildContentSlivers(),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user