From 3e0f7b4f8682ad001b36333a1c3fc31b1cabf6a8 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 17 May 2026 22:36:15 +0200 Subject: [PATCH] fix(libraries): stabilize alpha and sort controls --- lib/screens/hub_detail_screen.dart | 56 ++++++--- lib/screens/libraries/alpha_jump_bar.dart | 8 +- lib/screens/libraries/alpha_jump_helper.dart | 5 +- .../libraries/alpha_scroll_handle.dart | 8 +- .../libraries/library_alpha_bar_strategy.dart | 7 +- lib/screens/libraries/sort_bottom_sheet.dart | 67 +++++----- .../libraries/tabs/library_browse_tab.dart | 92 +++++++++----- .../jellyfin_client/parts/browse.dart | 5 +- lib/services/library_query_translator.dart | 1 + lib/utils/scroll_utils.dart | 34 +++--- lib/widgets/overlay_sheet.dart | 9 +- .../libraries/alpha_jump_bar_test.dart | 33 +++++ .../libraries/alpha_jump_helper_test.dart | 35 ++++++ .../libraries/sort_bottom_sheet_test.dart | 115 ++++++++++++++++++ test/services/jellyfin_client_urls_test.dart | 29 +++++ .../library_query_translator_test.dart | 1 + 16 files changed, 393 insertions(+), 112 deletions(-) create mode 100644 test/screens/libraries/alpha_jump_helper_test.dart create mode 100644 test/screens/libraries/sort_bottom_sheet_test.dart diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index 62cd8b54..c1654d0e 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -210,27 +210,43 @@ class _HubDetailScreenState extends State void _showSortBottomSheet() { final overlayContext = _overlayChildKey.currentContext ?? context; - OverlaySheetController.of(overlayContext).show( - builder: (context) => SortBottomSheet( - sortOptions: _sortOptions, - selectedSort: _selectedSort, - isSortDescending: _isSortDescending, - onSortChanged: (sort, descending) { - setState(() { - _selectedSort = sort; - _isSortDescending = descending; + MediaSort? pendingSort = _selectedSort; + bool pendingDescending = _isSortDescending; + bool pendingCleared = false; + OverlaySheetController.of(overlayContext) + .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) { + _selectedSort = null; + _isSortDescending = false; + _applySort(); + } else if (pendingSort != null && + (pendingSort!.key != _selectedSort?.key || pendingDescending != _isSortDescending)) { + _selectedSort = pendingSort; + _isSortDescending = pendingDescending; + _applySort(); + } }); - _applySort(); - }, - onClear: () { - setState(() { - _selectedSort = null; - _isSortDescending = false; - }); - _applySort(); - }, - ), - ); + }); } Future _loadMoreItems() async { diff --git a/lib/screens/libraries/alpha_jump_bar.dart b/lib/screens/libraries/alpha_jump_bar.dart index 11e436bc..592fc916 100644 --- a/lib/screens/libraries/alpha_jump_bar.dart +++ b/lib/screens/libraries/alpha_jump_bar.dart @@ -17,6 +17,7 @@ import 'alpha_jump_helper.dart'; class AlphaJumpBar extends StatefulWidget { final List firstCharacters; final void Function(int targetIndex) onJump; + final bool descending; /// The letter currently visible at the top of the grid, derived from the /// actual item's sort title by the parent widget. @@ -30,6 +31,7 @@ class AlphaJumpBar extends StatefulWidget { required this.firstCharacters, required this.onJump, required this.currentLetter, + this.descending = false, this.focusNode, this.onNavigateLeft, this.onBack, @@ -63,15 +65,15 @@ class _AlphaJumpBarState extends State { @override void initState() { super.initState(); - _helper = AlphaJumpHelper(widget.firstCharacters); + _helper = AlphaJumpHelper(widget.firstCharacters, descending: widget.descending); _displayed = _helper.letters; } @override void didUpdateWidget(AlphaJumpBar oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.firstCharacters != widget.firstCharacters) { - _helper = AlphaJumpHelper(widget.firstCharacters); + if (oldWidget.firstCharacters != widget.firstCharacters || oldWidget.descending != widget.descending) { + _helper = AlphaJumpHelper(widget.firstCharacters, descending: widget.descending); _lastMaxLetters = -1; // force recompute in next layout _displayed = _helper.letters; _clampHighlight(); diff --git a/lib/screens/libraries/alpha_jump_helper.dart b/lib/screens/libraries/alpha_jump_helper.dart index c7f5a1fb..910ab5c0 100644 --- a/lib/screens/libraries/alpha_jump_helper.dart +++ b/lib/screens/libraries/alpha_jump_helper.dart @@ -28,7 +28,7 @@ class AlphaJumpHelper { AlphaJumpHelper._(this.letters, this.letterToIndex, this.letterSizes, this.totalItemCount); - factory AlphaJumpHelper(List firstCharacters) { + factory AlphaJumpHelper(List firstCharacters, {bool descending = false}) { // Collect characters with their sizes. final entries = <({String letter, int size})>[]; final letterSizes = {}; @@ -43,6 +43,9 @@ class AlphaJumpHelper { // Re-sort by DUCET collation to match the content endpoint's ICU sort order. entries.sort((a, b) => ducetCompare(a.letter, b.letter)); + if (descending) { + entries.setAll(0, entries.reversed.toList()); + } // Build cumulative index map in the corrected order. final letters = []; diff --git a/lib/screens/libraries/alpha_scroll_handle.dart b/lib/screens/libraries/alpha_scroll_handle.dart index cfcce3a2..794321fe 100644 --- a/lib/screens/libraries/alpha_scroll_handle.dart +++ b/lib/screens/libraries/alpha_scroll_handle.dart @@ -13,6 +13,7 @@ import 'alpha_jump_helper.dart'; class AlphaScrollHandle extends StatefulWidget { final List firstCharacters; final void Function(int targetIndex) onJump; + final bool descending; /// The letter currently visible at the top of the grid, derived from the /// actual item's sort title by the parent widget. @@ -26,6 +27,7 @@ class AlphaScrollHandle extends StatefulWidget { required this.firstCharacters, required this.onJump, required this.currentLetter, + this.descending = false, required this.isScrolling, }); @@ -65,7 +67,7 @@ class _AlphaScrollHandleState extends State with SingleTicker @override void initState() { super.initState(); - _helper = AlphaJumpHelper(widget.firstCharacters); + _helper = AlphaJumpHelper(widget.firstCharacters, descending: widget.descending); _opacityController = AnimationController(vsync: this, duration: _showDuration, reverseDuration: _hideDuration); } @@ -73,8 +75,8 @@ class _AlphaScrollHandleState extends State with SingleTicker void didUpdateWidget(AlphaScrollHandle oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.firstCharacters != widget.firstCharacters) { - _helper = AlphaJumpHelper(widget.firstCharacters); + if (oldWidget.firstCharacters != widget.firstCharacters || oldWidget.descending != widget.descending) { + _helper = AlphaJumpHelper(widget.firstCharacters, descending: widget.descending); } if (widget.isScrolling && !oldWidget.isScrolling) { diff --git a/lib/screens/libraries/library_alpha_bar_strategy.dart b/lib/screens/libraries/library_alpha_bar_strategy.dart index c21d3fb9..a6dbc30a 100644 --- a/lib/screens/libraries/library_alpha_bar_strategy.dart +++ b/lib/screens/libraries/library_alpha_bar_strategy.dart @@ -32,6 +32,7 @@ abstract class LibraryAlphaBarStrategy { Future<({List chars, AlphaJumpHelper helper})> loadCharacters({ required Map filters, required int? typeId, + required bool descending, }); /// Letter to highlight given the current scroll-derived index. Plex maps @@ -100,6 +101,7 @@ class PlexAlphaBarStrategy implements LibraryAlphaBarStrategy { Future<({List chars, AlphaJumpHelper helper})> loadCharacters({ required Map filters, required int? typeId, + required bool descending, }) async { if (isShared) { // Shared libraries don't support first-characters. @@ -109,7 +111,7 @@ class PlexAlphaBarStrategy implements LibraryAlphaBarStrategy { final params = Map.from(filters); params['includeCollections'] = '1'; final chars = await client.getFirstCharacters(libraryKey, type: typeId, filters: params.isNotEmpty ? params : null); - return (chars: chars, helper: AlphaJumpHelper(chars)); + return (chars: chars, helper: AlphaJumpHelper(chars, descending: descending)); } @override @@ -184,9 +186,10 @@ class JellyfinAlphaBarStrategy implements LibraryAlphaBarStrategy { Future<({List chars, AlphaJumpHelper helper})> loadCharacters({ required Map filters, required int? typeId, + required bool descending, }) async { final synthetic = [for (final l in _letters) LibraryFirstCharacter(key: l, title: l, size: 1)]; - return (chars: synthetic, helper: AlphaJumpHelper(synthetic)); + return (chars: synthetic, helper: AlphaJumpHelper(synthetic, descending: descending)); } @override diff --git a/lib/screens/libraries/sort_bottom_sheet.dart b/lib/screens/libraries/sort_bottom_sheet.dart index 4174f9cf..84734da2 100644 --- a/lib/screens/libraries/sort_bottom_sheet.dart +++ b/lib/screens/libraries/sort_bottom_sheet.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -54,16 +56,18 @@ class _SortBottomSheetState extends State { } WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - if (!InputModeTracker.isKeyboardMode(context)) return; - final ctx = _initialFocusNode.context; - if (ctx != null) { - Scrollable.ensureVisible(ctx, alignment: 0.5); - } - // Schedule after overlay's _autoFocus second callback so we override it. - WidgetsBinding.instance.addPostFrameCallback((_) { + Timer.run(() { if (!mounted) return; - _initialFocusNode.requestFocus(); + if (!InputModeTracker.isKeyboardMode(context)) return; + final ctx = _initialFocusNode.context; + if (ctx != null) { + Scrollable.ensureVisible(ctx, alignment: 0.5); + } + // Schedule after overlay's _autoFocus second callback so we override it. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _initialFocusNode.requestFocus(); + }); }); }); } @@ -85,18 +89,11 @@ class _SortBottomSheetState extends State { } void _handleDirectionChange(MediaSort sort, bool descending) { - setState(() { - _currentDescending = descending; - }); widget.onSortChanged(sort, descending); OverlaySheetController.of(context).close(); } void _handleClear() { - setState(() { - _currentSort = null; - _currentDescending = false; - }); widget.onClear?.call(); OverlaySheetController.of(context).close(); } @@ -154,25 +151,25 @@ class _SortBottomSheetState extends State { : null, title: Text(sort.title), value: sort, - secondary: isSelected - ? 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: (Set newSelection) { - _handleDirectionChange(sort, newSelection.first); - }, - ) - : null, + 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, + ), + ), ), ); }, diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index fa5e5497..ffc493d3 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -217,17 +217,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _firstCharacters = []; AlphaJumpHelper _alphaHelper = AlphaJumpHelper(const []); - late final LibraryAlphaBarStrategy _alphaStrategy = LibraryAlphaBarStrategy.forBackend( - widget.library.backend, - // Resolved on demand and only invoked by [PlexAlphaBarStrategy], which is - // only constructed when the library's backend is Plex — the bang is safe. - plexClientProvider: () { - final manager = context.read().serverManager; - return manager.getPlexClient(widget.library.serverId ?? '')!; - }, - libraryKey: widget.library.id, - isShared: widget.library.isShared, - ); + late LibraryAlphaBarStrategy _alphaStrategy = _createAlphaStrategy(); /// On Jellyfin libraries the alpha bar acts as a filter (matches the /// JF web client's UX). Holds the active letter (`#`, `A`–`Z`) or null @@ -274,6 +264,35 @@ class _LibraryBrowseTabState extends BaseLibraryTabState().serverManager; + return manager.getPlexClient(library.serverId ?? '')!; + }, + libraryKey: library.id, + isShared: library.isShared, + ); + } + + @override + void didUpdateWidget(covariant LibraryBrowseTab oldWidget) { + // BaseLibraryTabState reloads during super.didUpdateWidget; refresh this + // first so first-character requests target the new backend/library. + if (oldWidget.library.globalKey != widget.library.globalKey || + oldWidget.library.id != widget.library.id || + oldWidget.library.backend != widget.library.backend || + oldWidget.library.serverId != widget.library.serverId || + oldWidget.library.isShared != widget.library.isShared) { + _alphaStrategy = _createAlphaStrategy(); + } + super.didUpdateWidget(oldWidget); + } + bool get _isJellyfinLibrary => widget.library.backend == MediaBackend.jellyfin; int get _activeFetchSize => _isJellyfinLibrary ? _jellyfinFetchSize : _fetchSize; @@ -821,25 +840,28 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _alphaStrategy.shouldShow( totalItemCount: totalSize, loadedCharacterCount: _firstCharacters.length, @@ -970,6 +999,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState ? data['TotalRecordCount'] as int? : null) ?? items.length; + final rawTotal = data is Map ? data['TotalRecordCount'] : null; + final total = rawTotal is int + ? rawTotal + : _fallbackPageTotal(offset: query.offset, itemCount: items.length, requestedSize: query.limit); return LibraryPage(items: _mapItems(items), totalCount: total, offset: query.offset); } diff --git a/lib/services/library_query_translator.dart b/lib/services/library_query_translator.dart index 2905923d..162a6d8f 100644 --- a/lib/services/library_query_translator.dart +++ b/lib/services/library_query_translator.dart @@ -216,6 +216,7 @@ class JellyfinLibraryQueryTranslator implements LibraryQueryTranslator { 'Recursive': 'true', 'StartIndex': query.offset.toString(), 'Limit': query.limit.toString(), + 'EnableTotalRecordCount': 'true', 'IncludeItemTypes': _includeTypesFor(query.kind), 'Fields': fields, ...jellyfinImageQueryParameters, diff --git a/lib/utils/scroll_utils.dart b/lib/utils/scroll_utils.dart index 7cee80a4..016b4756 100644 --- a/lib/utils/scroll_utils.dart +++ b/lib/utils/scroll_utils.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/widgets.dart'; /// Scroll the nearest scrollable ancestor so [context] is centered. @@ -7,13 +9,15 @@ import 'package:flutter/widgets.dart'; void scrollContextToCenter(BuildContext? context) { if (context == null) return; WidgetsBinding.instance.addPostFrameCallback((_) { - if (!context.mounted) return; - Scrollable.ensureVisible( - context, - alignment: 0.5, - duration: const Duration(milliseconds: 200), - curve: Curves.easeOut, - ); + Timer.run(() { + if (!context.mounted) return; + Scrollable.ensureVisible( + context, + alignment: 0.5, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + }); }); } @@ -25,13 +29,15 @@ void scrollContextToCenter(BuildContext? context) { /// controller aren't ready yet. void scrollToCurrentItem(ScrollController controller, GlobalKey firstItemKey, int currentIndex) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (!controller.hasClients) return; - final itemHeight = (firstItemKey.currentContext?.findRenderObject() as RenderBox?)?.size.height; - if (itemHeight == null) return; - final maxExtent = controller.position.maxScrollExtent; - if (!maxExtent.isFinite) return; - final target = (currentIndex * itemHeight).clamp(0.0, maxExtent); - controller.jumpTo(target); + Timer.run(() { + if (!controller.hasClients) return; + final itemHeight = (firstItemKey.currentContext?.findRenderObject() as RenderBox?)?.size.height; + if (itemHeight == null) return; + final maxExtent = controller.position.maxScrollExtent; + if (!maxExtent.isFinite) return; + final target = (currentIndex * itemHeight).clamp(0.0, maxExtent); + controller.jumpTo(target); + }); }); } diff --git a/lib/widgets/overlay_sheet.dart b/lib/widgets/overlay_sheet.dart index aef4323f..9292bbba 100644 --- a/lib/widgets/overlay_sheet.dart +++ b/lib/widgets/overlay_sheet.dart @@ -531,7 +531,10 @@ class _OverlaySheetHostState extends State with SingleTickerPr BoxConstraints(maxWidth: isDesktop ? 700 : double.infinity, maxHeight: isDesktop ? 400 : size.height * 0.75); // Slide direction depends on alignment: bottom sheets slide up, top sheets slide down. - final slideBegin = isTop ? const Offset(0, -1) : const Offset(0, 1); + // Use a pixel transform instead of FractionalTranslation so mouse-tracker + // hit testing never depends on the sheet child's just-invalidated layout. + final slideDirection = isTop ? -1.0 : 1.0; + final slideDistance = size.height; final borderRadius = isTop ? const BorderRadius.vertical(bottom: Radius.circular(16)) : const BorderRadius.vertical(top: Radius.circular(16)); @@ -617,8 +620,8 @@ class _OverlaySheetHostState extends State with SingleTickerPr child: AnimatedBuilder( animation: _slideCurve, builder: (context, child) { - final slideOffset = Offset.lerp(slideBegin, Offset.zero, _slideCurve.value)!; - return FractionalTranslation(translation: slideOffset, child: child); + final dy = slideDirection * slideDistance * (1 - _slideCurve.value); + return Transform.translate(offset: Offset(0, dy), child: child); }, child: Transform.translate( offset: Offset(0, _dragOffset.clamp(0, double.infinity)), diff --git a/test/screens/libraries/alpha_jump_bar_test.dart b/test/screens/libraries/alpha_jump_bar_test.dart index 231f6c65..c4b305ab 100644 --- a/test/screens/libraries/alpha_jump_bar_test.dart +++ b/test/screens/libraries/alpha_jump_bar_test.dart @@ -36,4 +36,37 @@ void main() { expect(jumpedTo, 3); }); + + testWidgets('Enter jumps to the descending title offset', (tester) async { + final focusNode = FocusNode(debugLabel: 'test_alpha_jump_bar_desc'); + addTearDown(focusNode.dispose); + + int? jumpedTo; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + height: 300, + child: AlphaJumpBar( + firstCharacters: const [ + LibraryFirstCharacter(key: 'A', title: 'A', size: 3), + LibraryFirstCharacter(key: 'B', title: 'B', size: 4), + LibraryFirstCharacter(key: 'C', title: 'C', size: 2), + ], + currentLetter: 'B', + descending: true, + focusNode: focusNode, + onJump: (index) => jumpedTo = index, + ), + ), + ), + ), + ); + + focusNode.requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + + expect(jumpedTo, 2); + }); } diff --git a/test/screens/libraries/alpha_jump_helper_test.dart b/test/screens/libraries/alpha_jump_helper_test.dart new file mode 100644 index 00000000..beb17d96 --- /dev/null +++ b/test/screens/libraries/alpha_jump_helper_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/library_first_character.dart'; +import 'package:plezy/screens/libraries/alpha_jump_helper.dart'; + +void main() { + const characters = [ + LibraryFirstCharacter(key: 'A', title: 'A', size: 3), + LibraryFirstCharacter(key: 'B', title: 'B', size: 4), + LibraryFirstCharacter(key: 'C', title: 'C', size: 2), + ]; + + test('maps title-ascending letters to cumulative offsets', () { + final helper = AlphaJumpHelper(characters); + + expect(helper.letters, ['A', 'B', 'C']); + expect(helper.indexForLetter('A'), 0); + expect(helper.indexForLetter('B'), 3); + expect(helper.indexForLetter('C'), 7); + expect(helper.currentLetter(0), 'A'); + expect(helper.currentLetter(3), 'B'); + expect(helper.currentLetter(8), 'C'); + }); + + test('maps title-descending letters to reversed cumulative offsets', () { + final helper = AlphaJumpHelper(characters, descending: true); + + expect(helper.letters, ['C', 'B', 'A']); + expect(helper.indexForLetter('C'), 0); + expect(helper.indexForLetter('B'), 2); + expect(helper.indexForLetter('A'), 6); + expect(helper.currentLetter(0), 'C'); + expect(helper.currentLetter(2), 'B'); + expect(helper.currentLetter(8), 'A'); + }); +} diff --git a/test/screens/libraries/sort_bottom_sheet_test.dart b/test/screens/libraries/sort_bottom_sheet_test.dart new file mode 100644 index 00000000..4a39ca10 --- /dev/null +++ b/test/screens/libraries/sort_bottom_sheet_test.dart @@ -0,0 +1,115 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_sort.dart'; +import 'package:plezy/screens/libraries/sort_bottom_sheet.dart'; +import 'package:plezy/widgets/overlay_sheet.dart'; + +void main() { + testWidgets('tapping sort row and direction in overlay does not throw', (tester) async { + const sorts = [ + MediaSort(key: 'titleSort', title: 'Title', defaultDirection: 'asc'), + MediaSort(key: 'addedAt', title: 'Date Added', defaultDirection: 'desc'), + ]; + + MediaSort? selectedSort; + bool? selectedDescending; + + await tester.pumpWidget( + MaterialApp( + home: OverlaySheetHost( + child: Builder( + builder: (context) => ElevatedButton( + onPressed: () { + OverlaySheetController.of(context).show( + builder: (_) => SortBottomSheet( + sortOptions: sorts, + selectedSort: null, + isSortDescending: false, + onSortChanged: (sort, descending) { + selectedSort = sort; + selectedDescending = descending; + }, + ), + ); + }, + child: const Text('Open'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + await tester.tap(find.text('Date Added')); + await tester.pump(); + expect(tester.takeException(), isNull); + expect(selectedSort?.key, 'addedAt'); + expect(selectedDescending, isTrue); + + final directionControl = find.byType(SegmentedButton).hitTestable(); + expect(directionControl, findsOneWidget); + final controlRect = tester.getRect(directionControl); + await tester.tapAt(controlRect.centerLeft + const Offset(12, 0)); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(selectedSort?.key, 'addedAt'); + expect(selectedDescending, isFalse); + }); + + testWidgets('preselected scrolled sort sheet with active mouse does not throw', (tester) async { + const sorts = [ + MediaSort(key: 'titleSort', title: 'Title', defaultDirection: 'asc'), + MediaSort(key: 'addedAt', title: 'Date Added', defaultDirection: 'desc'), + MediaSort(key: 'year', title: 'Year', defaultDirection: 'desc'), + MediaSort(key: 'rating', title: 'Rating', defaultDirection: 'desc'), + MediaSort(key: 'runtime', title: 'Runtime', defaultDirection: 'asc'), + MediaSort(key: 'studio', title: 'Studio', defaultDirection: 'asc'), + MediaSort(key: 'criticRating', title: 'Critic Rating', defaultDirection: 'desc'), + MediaSort(key: 'viewCount', title: 'Play Count', defaultDirection: 'desc'), + MediaSort(key: 'airTime', title: 'Air Time', defaultDirection: 'asc'), + MediaSort(key: 'officialRating', title: 'Official Rating', defaultDirection: 'asc'), + MediaSort(key: 'startDate', title: 'Start Date', defaultDirection: 'desc'), + ]; + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: const Offset(300, 300)); + + await tester.pumpWidget( + MaterialApp( + home: OverlaySheetHost( + child: Builder( + builder: (context) => ElevatedButton( + onPressed: () { + OverlaySheetController.of(context).show( + builder: (_) => SortBottomSheet( + sortOptions: sorts, + selectedSort: sorts.last, + isSortDescending: true, + onSortChanged: (_, _) {}, + ), + ); + }, + child: const Text('Open'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pump(); + expect(tester.takeException(), isNull); + + await mouse.moveTo(tester.getCenter(find.byType(SortBottomSheet))); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('Start Date'), findsOneWidget); + }); +} diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index b7caf673..7792bbc2 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -789,12 +789,41 @@ void main() { expect(captured!.queryParameters['ParentId'], 'lib-1'); expect(captured!.queryParameters['StartIndex'], '50'); expect(captured!.queryParameters['Limit'], '25'); + expect(captured!.queryParameters['EnableTotalRecordCount'], 'true'); expect(captured!.queryParameters['IncludeItemTypes'], 'Movie'); expect(captured!.queryParameters['Fields'], isNot(contains('MediaSources'))); expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo'); expect(captured!.queryParameters['ImageTypeLimit'], '1'); }); + test('fetchLibraryContent uses sentinel total fallback when server omits total', () async { + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((req) async { + final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0'); + final limit = int.parse(req.url.queryParameters['Limit'] ?? '25'); + return http.Response( + jsonEncode({ + 'Items': [ + for (var i = start; i < start + limit; i++) {'Id': 'movie-$i', 'Type': 'Movie', 'Name': 'Movie $i'}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }), + ); + addTearDown(scoped.close); + + final page = await scoped.fetchLibraryContent( + 'lib-1', + const LibraryQuery(kind: MediaKind.movie, offset: 50, limit: 25), + ); + + expect(page.items.length, 25); + expect(page.totalCount, 76); + }); + test('fetchLibraryPagedContent uses library kind only when query kind is absent', () async { final captured = []; final scoped = JellyfinClient.forTesting( diff --git a/test/services/library_query_translator_test.dart b/test/services/library_query_translator_test.dart index 57a03d9c..a91622e8 100644 --- a/test/services/library_query_translator_test.dart +++ b/test/services/library_query_translator_test.dart @@ -79,6 +79,7 @@ void main() { expect(params['Recursive'], 'true'); expect(params['Fields'], 'UserData'); expect(params['IncludeItemTypes'], isNotEmpty); + expect(params['EnableTotalRecordCount'], 'true'); expect(params['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo'); expect(params['ImageTypeLimit'], '1'); });