fix(libraries): stabilize alpha and sort controls

This commit is contained in:
edde746
2026-05-17 22:36:15 +02:00
parent c855dba3be
commit 3e0f7b4f86
16 changed files with 393 additions and 112 deletions
+36 -20
View File
@@ -210,27 +210,43 @@ class _HubDetailScreenState extends State<HubDetailScreen>
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<void> _loadMoreItems() async {
+5 -3
View File
@@ -17,6 +17,7 @@ import 'alpha_jump_helper.dart';
class AlphaJumpBar extends StatefulWidget {
final List<LibraryFirstCharacter> 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<AlphaJumpBar> {
@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();
+4 -1
View File
@@ -28,7 +28,7 @@ class AlphaJumpHelper {
AlphaJumpHelper._(this.letters, this.letterToIndex, this.letterSizes, this.totalItemCount);
factory AlphaJumpHelper(List<LibraryFirstCharacter> firstCharacters) {
factory AlphaJumpHelper(List<LibraryFirstCharacter> firstCharacters, {bool descending = false}) {
// Collect characters with their sizes.
final entries = <({String letter, int size})>[];
final letterSizes = <String, int>{};
@@ -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 = <String>[];
@@ -13,6 +13,7 @@ import 'alpha_jump_helper.dart';
class AlphaScrollHandle extends StatefulWidget {
final List<LibraryFirstCharacter> 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<AlphaScrollHandle> 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<AlphaScrollHandle> 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) {
@@ -32,6 +32,7 @@ abstract class LibraryAlphaBarStrategy {
Future<({List<LibraryFirstCharacter> chars, AlphaJumpHelper helper})> loadCharacters({
required Map<String, String> 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<LibraryFirstCharacter> chars, AlphaJumpHelper helper})> loadCharacters({
required Map<String, String> 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<String, String>.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<LibraryFirstCharacter> chars, AlphaJumpHelper helper})> loadCharacters({
required Map<String, String> 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
+32 -35
View File
@@ -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<SortBottomSheet> {
}
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<SortBottomSheet> {
}
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<SortBottomSheet> {
: null,
title: Text(sort.title),
value: sort,
secondary: isSelected
? 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: (Set<bool> newSelection) {
_handleDirectionChange(sort, newSelection.first);
},
)
: null,
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,
),
),
),
);
},
@@ -217,17 +217,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
// Alpha jump bar state
List<LibraryFirstCharacter> _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<MultiServerProvider>().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<MediaItem, LibraryBrows
bool _rangeLoadScheduled = false;
bool _topScrollResetScheduled = false;
LibraryAlphaBarStrategy _createAlphaStrategy() {
final library = widget.library;
return LibraryAlphaBarStrategy.forBackend(
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<MultiServerProvider>().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<MediaItem, LibraryBrows
)
.then((_) {
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();
}
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();
}
});
});
}
@@ -951,6 +973,13 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
/// Whether the alpha jump bar should be shown.
/// Only shown when sorting by title (titleSort) and not in folders mode.
bool get _isTitleSortDescending {
if (!_isSortDescending) return false;
final key = _selectedSort?.key.toLowerCase();
if (key == null || key.isEmpty) return false;
return key == 'title' || key == 'name' || key == 'sortname' || key.startsWith('titlesort');
}
bool get _shouldShowAlphaJumpBar => _alphaStrategy.shouldShow(
totalItemCount: totalSize,
loadedCharacterCount: _firstCharacters.length,
@@ -970,6 +999,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
final result = await _alphaStrategy.loadCharacters(
filters: filterParams,
typeId: typeId.isNotEmpty ? int.tryParse(typeId) : null,
descending: _isTitleSortDescending,
);
if (!mounted || currentRequestId != _firstCharactersRequestId) return;
setState(() {
@@ -1176,6 +1206,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
firstCharacters: _firstCharacters,
onJump: _jumpToIndex,
currentLetter: _alphaLetterFor(visibleIndex),
descending: _isTitleSortDescending,
isScrolling: scrolling,
),
),
@@ -1186,6 +1217,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
firstCharacters: _firstCharacters,
onJump: _jumpToIndex,
currentLetter: _alphaLetterFor(visibleIndex),
descending: _isTitleSortDescending,
focusNode: _alphaJumpBarFocusNode,
onNavigateLeft: _navigateToGridNearScroll,
onBack: _navigateToGridNearScroll,
@@ -119,7 +119,10 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
throwIfHttpError(response);
final data = response.data;
final items = _itemsArray(data);
final total = (data is Map<String, dynamic> ? data['TotalRecordCount'] as int? : null) ?? items.length;
final rawTotal = data is Map<String, dynamic> ? data['TotalRecordCount'] : null;
final total = rawTotal is int
? rawTotal
: _fallbackPageTotal(offset: query.offset, itemCount: items.length, requestedSize: query.limit);
return LibraryPage<MediaItem>(items: _mapItems(items), totalCount: total, offset: query.offset);
}
@@ -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,
+20 -14
View File
@@ -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);
});
});
}
+6 -3
View File
@@ -531,7 +531,10 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> 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<OverlaySheetHost> 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)),
@@ -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);
});
}
@@ -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');
});
}
@@ -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<bool>).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);
});
}
@@ -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 = <Uri>[];
final scoped = JellyfinClient.forTesting(
@@ -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');
});