diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index 42299fc1..c11104b2 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -13,6 +13,7 @@ import '../media/media_sort.dart'; import '../services/settings_service.dart'; import '../widgets/settings_builder.dart'; import '../utils/app_logger.dart'; +import '../utils/continuation_pagination_coordinator.dart'; import '../utils/grid_size_calculator.dart'; import '../utils/platform_detector.dart'; import '../utils/plex_library_section_utils.dart'; @@ -64,12 +65,16 @@ class _HubDetailScreenState extends State MediaSort? _selectedSort; bool _isSortDescending = false; bool _isLoading = false; - bool _isLoadingMore = false; String? _errorMessage; - String? _continuationErrorMessage; - int? _continuationOffset; - int? _continuationTotal; - int _loadGeneration = 0; + bool _replaceContinuationItems = false; + + late final ContinuationPaginationCoordinator _continuation = ContinuationPaginationCoordinator( + loadPage: _fetchContinuationPage, + onPage: _applyContinuationPage, + onStateChanged: _handleContinuationStateChanged, + onError: (error, stackTrace) => + appLogger.w('Failed to finish loading hub content', error: error, stackTrace: stackTrace), + ); /// Key for getting a context below OverlaySheetHost final GlobalKey _overlayChildKey = GlobalKey(); @@ -116,6 +121,7 @@ class _HubDetailScreenState extends State @override void dispose() { + _continuation.dispose(); disposeFocusResources(); super.dispose(); } @@ -263,7 +269,6 @@ class _HubDetailScreenState extends State Future _loadMoreItems() async { if (_isLoading) return; - final generation = ++_loadGeneration; final serverId = widget.hub.serverId; if (widget.loadItems == null && serverId == null) { @@ -273,46 +278,49 @@ class _HubDetailScreenState extends State setState(() { _isLoading = true; - _isLoadingMore = false; _errorMessage = null; - _continuationErrorMessage = null; - _continuationOffset = null; - _continuationTotal = null; }); try { - final loader = widget.loadItems; - final client = serverId == null ? null : context.tryGetMediaClientForServer(ServerId(serverId)); - final List items; - int totalCount; - int loadedCount; - if (loader == null) { - final page = client == null - ? const LibraryPage(items: [], totalCount: 0) - : await client.fetchMoreHubItemsPage(widget.hub.id, start: 0, size: _pageSize); - items = _applySectionFilter(page.items); - totalCount = page.totalCount; - loadedCount = page.items.length; - } else { - items = _applySectionFilter(await loader()); - totalCount = items.length; - loadedCount = items.length; - } + List items = const []; + var totalCount = 0; + var loadedCount = 0; + var usesCustomLoader = false; + MediaServerClient? client; + final applied = await _continuation.runNewGeneration(() async { + final loader = widget.loadItems; + usesCustomLoader = loader != null; + client = serverId == null ? null : context.tryGetMediaClientForServer(ServerId(serverId)); + if (loader == null) { + final page = client == null + ? const LibraryPage(items: [], totalCount: 0) + : await client!.fetchMoreHubItemsPage(widget.hub.id, start: 0, size: _pageSize); + items = _applySectionFilter(page.items); + totalCount = page.totalCount; + loadedCount = page.items.length; + } else { + items = _applySectionFilter(await loader()); + totalCount = items.length; + loadedCount = items.length; + } + }); - if (!mounted || generation != _loadGeneration) return; + if (!mounted || !applied) return; setState(() { - _items = items; - _filteredItems = items; + _items = List.of(items); + _filteredItems = List.of(items); _isLoading = false; }); _applySort(); - if (loader == null && client != null && loadedCount < totalCount) { - if (client.backend == MediaBackend.plex) { - unawaited(_loadFullHubContent(client, generation)); + if (!usesCustomLoader && client != null && loadedCount < totalCount) { + _replaceContinuationItems = client!.backend == MediaBackend.plex; + if (_replaceContinuationItems) { + _continuation.setContinuation(startIndex: 0, totalCount: 1); } else { - unawaited(_loadRemainingHubPages(client, generation, loadedCount, totalCount)); + _continuation.setContinuation(startIndex: loadedCount, totalCount: totalCount); } + unawaited(_continuation.loadRemaining()); } appLogger.d('Loaded ${items.length} items for hub: ${widget.hub.title}'); @@ -326,99 +334,47 @@ class _HubDetailScreenState extends State } } - Future _loadFullHubContent(MediaServerClient client, int generation) async { - if (mounted && generation == _loadGeneration) { - setState(() { - _isLoadingMore = true; - _continuationErrorMessage = null; - }); - } + Future> _fetchContinuationPage(int startIndex) async { + final serverId = widget.hub.serverId; + final client = serverId == null ? null : context.tryGetMediaClientForServer(ServerId(serverId)); + if (client == null) throw StateError('No media client available for hub continuation'); - try { + if (_replaceContinuationItems) { final items = _applySectionFilter(await client.fetchMoreHubItems(widget.hub.id)); - if (!mounted || generation != _loadGeneration) return; if (items.isEmpty && _items.isNotEmpty) { throw StateError('Hub continuation returned no items'); } - setState(() { - _items = items; - _filteredItems = items; - _isLoadingMore = false; - _continuationErrorMessage = null; - _continuationOffset = null; - _continuationTotal = null; - }); - _applySort(); - } catch (e, st) { - appLogger.w('Failed to finish loading hub content', error: e, stackTrace: st); - if (!mounted || generation != _loadGeneration) return; - setState(() { - _isLoadingMore = false; - _continuationErrorMessage = t.messages.errorLoading(error: e.toString()); - }); + return ContinuationPage(items: items, totalCount: 1, consumedCount: 1); } + + final page = await client.fetchMoreHubItemsPage(widget.hub.id, start: startIndex, size: _pageSize); + return ContinuationPage( + items: _applySectionFilter(page.items), + totalCount: page.totalCount, + consumedCount: page.items.length, + ); } - Future _loadRemainingHubPages(MediaServerClient client, int generation, int startOffset, int totalCount) async { - var offset = startOffset; - var total = totalCount; - if (mounted && generation == _loadGeneration) { - setState(() { - _isLoadingMore = true; - _continuationErrorMessage = null; - _continuationOffset = offset; - _continuationTotal = total; - }); - } - try { - while (offset < total) { - final page = await client.fetchMoreHubItemsPage(widget.hub.id, start: offset, size: _pageSize); - if (!mounted || generation != _loadGeneration) return; - if (page.items.isEmpty) break; - final items = _applySectionFilter(page.items); - setState(() { - _items.addAll(items); - _filteredItems = List.of(_items); - }); - _applySort(); - offset += page.items.length; - total = page.totalCount; - _continuationOffset = offset; - _continuationTotal = total; + void _applyContinuationPage(ContinuationPage page) { + if (!mounted) return; + setState(() { + if (_replaceContinuationItems) { + _items = List.of(page.items); + } else { + _items = List.of(_items)..addAll(page.items); } - if (!mounted || generation != _loadGeneration) return; - setState(() { - _isLoadingMore = false; - _continuationErrorMessage = null; - _continuationOffset = null; - _continuationTotal = null; - }); - } catch (e, st) { - appLogger.w('Failed to finish loading hub content', error: e, stackTrace: st); - if (!mounted || generation != _loadGeneration) return; - setState(() { - _isLoadingMore = false; - _continuationErrorMessage = t.messages.errorLoading(error: e.toString()); - _continuationOffset = offset; - _continuationTotal = total; - }); + _filteredItems = List.of(_items); + }); + _applySort(); + } + + void _handleContinuationStateChanged() { + if (mounted) { + setState(() {}); } } - void _retryHubContinuation() { - final serverId = widget.hub.serverId; - final client = serverId == null ? null : context.tryGetMediaClientForServer(ServerId(serverId)); - if (client == null || _isLoadingMore) return; - final generation = _loadGeneration; - if (client.backend == MediaBackend.plex) { - unawaited(_loadFullHubContent(client, generation)); - return; - } - final offset = _continuationOffset; - final total = _continuationTotal; - if (offset == null || total == null) return; - unawaited(_loadRemainingHubPages(client, generation, offset, total)); - } + void _retryHubContinuation() => unawaited(_continuation.retry()); List _applySectionFilter(List items) { final sectionFilter = int.tryParse(widget.hub.libraryId ?? ''); @@ -459,7 +415,8 @@ class _HubDetailScreenState extends State } Widget _buildContinuationStatusSliver() { - final error = _continuationErrorMessage; + final exception = _continuation.error; + final error = exception == null ? null : t.messages.errorLoading(error: exception.toString()); return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(24), @@ -631,7 +588,7 @@ class _HubDetailScreenState extends State ); }, ), - if (_filteredItems.isNotEmpty && (_isLoadingMore || _continuationErrorMessage != null)) + if (_filteredItems.isNotEmpty && (_continuation.isLoading || _continuation.error != null)) _buildContinuationStatusSliver(), ], ), diff --git a/lib/screens/playlist/playlist_detail_screen.dart b/lib/screens/playlist/playlist_detail_screen.dart index 3d9b0519..f2ed9deb 100644 --- a/lib/screens/playlist/playlist_detail_screen.dart +++ b/lib/screens/playlist/playlist_detail_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../focus/focusable_action_bar.dart'; +import '../../media/library_query.dart'; import '../../media/media_item.dart'; import '../../media/media_kind.dart'; import '../../media/media_playlist.dart'; @@ -12,6 +13,7 @@ import '../../services/media_list_playback_launcher.dart'; import '../../services/music/music_playback_service.dart'; import '../../services/playlist_items_loader.dart'; import '../../utils/app_logger.dart'; +import '../../utils/continuation_pagination_coordinator.dart'; import '../../utils/music_navigation.dart'; import '../../widgets/app_icon.dart'; import '../../widgets/desktop_app_bar.dart'; @@ -193,12 +195,16 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen? _originalOrder; - int? _playlistTotalSize; - int _playlistLoadGeneration = 0; - bool _isLoadingFullPlaylist = false; - String? _playlistContinuationErrorMessage; - bool get _isPlaylistFullyLoaded => _playlistTotalSize != null && items.length >= _playlistTotalSize!; + late final ContinuationPaginationCoordinator _continuation = ContinuationPaginationCoordinator( + loadPage: _fetchPlaylistContinuationPage, + onPage: _applyPlaylistContinuationPage, + onStateChanged: _handleContinuationStateChanged, + onError: (error, stackTrace) => + appLogger.w('Failed to finish loading playlist items', error: error, stackTrace: stackTrace), + ); + + bool get _isPlaylistFullyLoaded => _continuation.totalCount != null && items.length >= _continuation.totalCount!; bool get _canEditPlaylist => !_isReadOnly && _isPlaylistFullyLoaded; @@ -207,6 +213,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen loadItems() async { - final generation = ++_playlistLoadGeneration; if (mounted) { setState(() { isLoading = true; errorMessage = null; items = []; - _playlistTotalSize = null; - _isLoadingFullPlaylist = false; - _playlistContinuationErrorMessage = null; _focusedIndex = 0; _focusedColumn = 0; _movingIndex = null; @@ -237,87 +240,57 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen? firstPage; + final applied = await _continuation.runNewGeneration(() async { + firstPage = await mediaClient.fetchPlaylistPage(widget.playlist.id, start: 0, size: _pageSize); + }); + if (!mounted || !applied) return; + final page = firstPage!; + + _continuation.setContinuation(startIndex: page.items.length, totalCount: page.totalCount); setState(() { - items = List.of(firstPage.items); - _playlistTotalSize = firstPage.totalCount; + items = List.of(page.items); isLoading = false; - _isLoadingFullPlaylist = firstPage.items.length < firstPage.totalCount; }); - appLogger.d( - 'Loaded ${firstPage.items.length} of ${firstPage.totalCount} items for playlist: ${widget.playlist.title}', - ); + appLogger.d('Loaded ${page.items.length} of ${page.totalCount} items for playlist: ${widget.playlist.title}'); _autoFocusAfterLoad(); - if (firstPage.items.length < firstPage.totalCount) { - unawaited(_loadRemainingPlaylistPages(generation, firstPage.items.length, firstPage.totalCount)); - } + if (_continuation.hasMore) unawaited(_continuation.loadRemaining()); } catch (e) { appLogger.e('Failed to load playlist items', error: e); - if (!mounted || generation != _playlistLoadGeneration) return; + if (!mounted) return; setState(() { errorMessage = getLoadErrorMessage(e); isLoading = false; - _isLoadingFullPlaylist = false; }); } } - Future _loadRemainingPlaylistPages(int generation, int startOffset, int totalCount) async { - var offset = startOffset; - var total = totalCount; - if (mounted && generation == _playlistLoadGeneration) { - setState(() { - _isLoadingFullPlaylist = true; - _playlistContinuationErrorMessage = null; - }); - } - try { - while (offset < total) { - final page = await mediaClient.fetchPlaylistPage(widget.playlist.id, start: offset, size: _pageSize); - if (!mounted || generation != _playlistLoadGeneration) return; - if (page.items.isEmpty) break; - setState(() { - items = List.of(items)..addAll(page.items); - total = page.totalCount; - _playlistTotalSize = page.totalCount; - }); - offset += page.items.length; - } - appLogger.d( - 'Loaded ${items.length} of ${_playlistTotalSize ?? items.length} items for playlist: ${widget.playlist.title}', - ); - if (mounted && generation == _playlistLoadGeneration) { - setState(() { - _playlistContinuationErrorMessage = null; - }); - } - } catch (e, st) { - appLogger.w('Failed to finish loading playlist items', error: e, stackTrace: st); - if (mounted && generation == _playlistLoadGeneration) { - setState(() { - _playlistContinuationErrorMessage = t.messages.errorLoading(error: e.toString()); - }); - } - } finally { - if (mounted && generation == _playlistLoadGeneration) { - setState(() { - _isLoadingFullPlaylist = false; - if (_focusedColumn != 0 && !_canEditPlaylist) _focusedColumn = 0; - }); - } - } + Future> _fetchPlaylistContinuationPage(int startIndex) async { + final page = await mediaClient.fetchPlaylistPage(widget.playlist.id, start: startIndex, size: _pageSize); + return ContinuationPage(items: page.items, totalCount: page.totalCount, consumedCount: page.items.length); } - void _retryPlaylistContinuation() { - final total = _playlistTotalSize; - if (_isLoadingFullPlaylist || total == null || items.length >= total) return; - unawaited(_loadRemainingPlaylistPages(_playlistLoadGeneration, items.length, total)); + void _applyPlaylistContinuationPage(ContinuationPage page) { + if (!mounted) return; + setState(() { + items = List.of(items)..addAll(page.items); + }); } + void _handleContinuationStateChanged() { + if (!mounted) return; + setState(() { + if (!_continuation.isLoading && _focusedColumn != 0 && !_canEditPlaylist) { + _focusedColumn = 0; + } + }); + } + + void _retryPlaylistContinuation() => unawaited(_continuation.retry()); + void _autoFocusAfterLoad() { if (mounted && items.isNotEmpty) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -785,8 +758,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen { + const ContinuationPage({required this.items, required this.totalCount, required this.consumedCount}); + + final List items; + final int totalCount; + final int consumedCount; +} + +enum ContinuationLoadStatus { completed, failed, stale, idle } + +typedef ContinuationPageLoader = Future> Function(int startIndex); +typedef ContinuationPageHandler = void Function(ContinuationPage page); + +/// Coordinates an eager, indexed continuation without depending on Flutter. +/// +/// Screens retain ownership of item mapping and presentation through [onPage]. +/// This class owns cursor advancement, generation invalidation, retry state, +/// stale-result rejection, and request coalescing. +class ContinuationPaginationCoordinator { + ContinuationPaginationCoordinator({required this.loadPage, required this.onPage, this.onStateChanged, this.onError}); + + final ContinuationPageLoader loadPage; + final ContinuationPageHandler onPage; + final void Function()? onStateChanged; + final void Function(Object error, StackTrace stackTrace)? onError; + + int _generation = 0; + int? _nextStartIndex; + int? _totalCount; + Future? _inFlight; + int? _inFlightGeneration; + bool _disposed = false; + bool _isLoading = false; + Object? _error; + StackTrace? _errorStackTrace; + + int? get nextStartIndex => _nextStartIndex; + int? get totalCount => _totalCount; + bool get hasMore => _nextStartIndex != null; + bool get isLoading => _isLoading; + Object? get error => _error; + StackTrace? get errorStackTrace => _errorStackTrace; + + /// Invalidates all prior work, runs [request], and reports whether its result + /// still belongs to the current generation. + Future runNewGeneration(Future Function() request) async { + final generation = _beginGeneration(); + try { + await request(); + } catch (_) { + if (!_isCurrent(generation)) return false; + rethrow; + } + return _isCurrent(generation); + } + + /// Seeds the continuation cursor from an accepted initial response. + void setContinuation({required int startIndex, required int totalCount}) { + if (_disposed) return; + _totalCount = totalCount; + _nextStartIndex = startIndex < totalCount ? startIndex : null; + _error = null; + _errorStackTrace = null; + onStateChanged?.call(); + } + + /// Loads all remaining pages. Calls made while this generation is loading + /// share the same operation and do not issue duplicate backend requests. + Future loadRemaining() { + final existing = _inFlight; + if (existing != null) return existing; + if (_disposed || _nextStartIndex == null) { + return Future.value(ContinuationLoadStatus.idle); + } + + final generation = _generation; + _inFlightGeneration = generation; + final operation = _loadRemaining(generation); + _inFlight = operation; + return operation; + } + + /// Retries from the first cursor that did not complete successfully. + Future retry() => loadRemaining(); + + /// Invalidates pending results and suppresses future callbacks. + void dispose() { + if (_disposed) return; + _disposed = true; + _generation++; + _nextStartIndex = null; + _totalCount = null; + _inFlight = null; + _inFlightGeneration = null; + _isLoading = false; + _error = null; + _errorStackTrace = null; + } + + int _beginGeneration() { + _generation++; + _nextStartIndex = null; + _totalCount = null; + _inFlight = null; + _inFlightGeneration = null; + _isLoading = false; + _error = null; + _errorStackTrace = null; + if (!_disposed) onStateChanged?.call(); + return _generation; + } + + bool _isCurrent(int generation) => !_disposed && generation == _generation; + + Future _loadRemaining(int generation) async { + _isLoading = true; + _error = null; + _errorStackTrace = null; + onStateChanged?.call(); + + try { + while (_isCurrent(generation)) { + final startIndex = _nextStartIndex; + if (startIndex == null) return ContinuationLoadStatus.completed; + + final page = await loadPage(startIndex); + if (!_isCurrent(generation)) return ContinuationLoadStatus.stale; + + if (page.consumedCount <= 0) { + _totalCount = page.totalCount; + _nextStartIndex = null; + onStateChanged?.call(); + return ContinuationLoadStatus.completed; + } + + onPage(page); + _totalCount = page.totalCount; + final nextStartIndex = startIndex + page.consumedCount; + _nextStartIndex = nextStartIndex < page.totalCount ? nextStartIndex : null; + onStateChanged?.call(); + } + return ContinuationLoadStatus.stale; + } catch (exception, stackTrace) { + if (!_isCurrent(generation)) return ContinuationLoadStatus.stale; + _error = exception; + _errorStackTrace = stackTrace; + onError?.call(exception, stackTrace); + return ContinuationLoadStatus.failed; + } finally { + if (_isCurrent(generation)) { + _isLoading = false; + onStateChanged?.call(); + } + if (_inFlightGeneration == generation) { + _inFlight = null; + _inFlightGeneration = null; + } + } + } +} diff --git a/test/screens/hub_detail_screen_test.dart b/test/screens/hub_detail_screen_test.dart new file mode 100644 index 00000000..71e2092a --- /dev/null +++ b/test/screens/hub_detail_screen_test.dart @@ -0,0 +1,181 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/media/ids.dart'; +import 'package:plezy/media/library_query.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_hub.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/media/media_server_client.dart'; +import 'package:plezy/media/server_capabilities.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/screens/hub_detail_screen.dart'; +import 'package:plezy/services/data_aggregation_service.dart'; +import 'package:plezy/services/multi_server_manager.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/utils/media_server_http_client.dart'; +import 'package:provider/provider.dart'; + +import '../test_helpers/prefs.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + LocaleSettings.setLocaleSync(AppLocale.en); + }); + + testWidgets('Jellyfin hub advances by raw page size after screen filtering', (tester) async { + final items = List.generate( + 205, + (index) => _item(index, libraryId: index.isEven ? '7' : '8', backend: MediaBackend.jellyfin), + ); + final harness = await _createHarness(items, backend: MediaBackend.jellyfin); + + await tester.pumpWidget( + harness.wrap( + HubDetailScreen( + hub: MediaHub( + id: 'home.recent', + title: 'Recent', + type: 'movie', + items: items.take(5).toList(), + size: items.length, + more: true, + libraryId: '7', + serverId: 'server_1', + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(harness.client.requestedStarts, [0, 200]); + expect(harness.client.fullHubRequests, 0); + expect(find.text(t.common.retry), findsNothing); + + await tester.drag(find.byType(CustomScrollView), const Offset(0, -30000)); + await tester.pumpAndSettle(); + expect(find.text('Item 204'), findsOneWidget); + expect(find.text('Item 203'), findsNothing); + }); + + testWidgets('Plex hub replaces its preview with the full-hub response', (tester) async { + final items = List.generate(205, (index) => _item(index, backend: MediaBackend.plex)); + final harness = await _createHarness(items, backend: MediaBackend.plex); + + await tester.pumpWidget( + harness.wrap( + HubDetailScreen( + hub: MediaHub( + id: '/hubs/home/recentlyAdded', + title: 'Recently Added', + type: 'movie', + items: items.take(5).toList(), + size: items.length, + more: true, + serverId: 'server_1', + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(harness.client.requestedStarts, [0]); + expect(harness.client.fullHubRequests, 1); + expect(find.text(t.common.retry), findsNothing); + + await tester.drag(find.byType(CustomScrollView), const Offset(0, -30000)); + await tester.pumpAndSettle(); + expect(find.text('Item 204'), findsOneWidget); + }); +} + +MediaItem _item(int index, {required MediaBackend backend, String? libraryId}) => MediaItem( + id: 'item_$index', + backend: backend, + kind: MediaKind.movie, + title: 'Item $index', + libraryId: libraryId, + serverId: 'server_1', + serverName: 'Server', +); + +Future<_HubHarness> _createHarness(List items, {required MediaBackend backend}) async { + await SettingsService.getInstance(); + final client = _PagedHubClient(items, backend: backend); + final manager = MultiServerManager()..debugRegisterClientForTesting(client); + final provider = MultiServerProvider(manager, DataAggregationService(manager)); + addTearDown(provider.dispose); + return _HubHarness(client: client, provider: provider); +} + +class _HubHarness { + const _HubHarness({required this.client, required this.provider}); + + final _PagedHubClient client; + final MultiServerProvider provider; + + Widget wrap(Widget child) => TranslationProvider( + child: ChangeNotifierProvider.value( + value: provider, + child: MaterialApp( + theme: monoTheme(dark: true), + home: SizedBox(width: 1280, height: 720, child: child), + ), + ), + ); +} + +class _PagedHubClient implements MediaServerClient { + _PagedHubClient(this.items, {required this.backend}); + + final List items; + final List requestedStarts = []; + int fullHubRequests = 0; + + @override + final MediaBackend backend; + + @override + ServerId get serverId => ServerId('server_1'); + + @override + String? get serverName => 'Server'; + + @override + ServerCapabilities get capabilities => + backend == MediaBackend.plex ? ServerCapabilities.plex : ServerCapabilities.jellyfin; + + @override + Future> fetchMoreHubItemsPage( + String hubId, { + int? start, + int? size, + AbortController? abort, + }) async { + requestedStarts.add(start); + final offset = start ?? 0; + return LibraryPage( + items: items.skip(offset).take(size ?? items.length).toList(growable: false), + totalCount: items.length, + offset: offset, + ); + } + + @override + Future> fetchMoreHubItems(String hubId, {int? limit}) async { + fullHubRequests++; + return List.unmodifiable(items); + } + + @override + void close() {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/screens/playlist_detail_screen_test.dart b/test/screens/playlist_detail_screen_test.dart index cae18da9..636f42b9 100644 --- a/test/screens/playlist_detail_screen_test.dart +++ b/test/screens/playlist_detail_screen_test.dart @@ -72,6 +72,41 @@ void main() { expect(find.text(t.common.retry), findsNothing); }); + testWidgets('keeps partial playlist pages and retries from the failed offset', (tester) async { + final items = _mediaItems(playlistItemsPageSize * 2 + 5); + final harness = await _createHarness(items, failOnceAt: playlistItemsPageSize); + + await tester.pumpWidget( + harness.wrap(const SizedBox(width: 1280, height: 720, child: PlaylistDetailScreen(playlist: _playlist))), + ); + + for (var i = 0; i < 10 && find.text(t.common.retry).evaluate().isEmpty; i++) { + await tester.pump(const Duration(milliseconds: 10)); + } + + await tester.drag(find.byType(CustomScrollView), const Offset(0, -30000)); + await tester.pumpAndSettle(); + + expect(harness.client.requestedStarts, [0, playlistItemsPageSize]); + expect(find.text(t.common.retry), findsOneWidget); + expect(find.text('Item ${playlistItemsPageSize - 1}'), findsOneWidget); + + await tester.tap(find.text(t.common.retry)); + await tester.pumpAndSettle(); + + expect(harness.client.requestedStarts, [ + 0, + playlistItemsPageSize, + playlistItemsPageSize, + playlistItemsPageSize * 2, + ]); + expect(find.text(t.common.retry), findsNothing); + + await tester.drag(find.byType(CustomScrollView), const Offset(0, -50000)); + await tester.pumpAndSettle(); + expect(find.text('Item ${playlistItemsPageSize * 2 + 4}'), findsOneWidget); + }); + testWidgets('iOS top safe-area tap scrolls long playlists to top', (tester) async { final items = _mediaItems(playlistItemsPageSize + 5); final harness = await _createHarness(items); @@ -152,7 +187,7 @@ List _mediaItems(int count) { ); } -Future<_PlaylistHarness> _createHarness(List items) async { +Future<_PlaylistHarness> _createHarness(List items, {int? failOnceAt}) async { await SettingsService.getInstance(); final db = AppDatabase.forTesting(NativeDatabase.memory()); @@ -168,7 +203,7 @@ Future<_PlaylistHarness> _createHarness(List items) async { final downloadProvider = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); await downloadProvider.ensureInitialized(); - final client = _PagedPlaylistClient(items); + final client = _PagedPlaylistClient(items, failOnceAt: failOnceAt); final manager = MultiServerManager()..debugRegisterClientForTesting(client); final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); @@ -207,10 +242,12 @@ class _PlaylistHarness { class _PagedPlaylistClient implements MediaServerClient { final List items; + final int? failOnceAt; final List requestedStarts = []; final List requestedSizes = []; + bool _hasFailed = false; - _PagedPlaylistClient(this.items); + _PagedPlaylistClient(this.items, {this.failOnceAt}); @override ServerId get serverId => ServerId('server_1'); @@ -230,6 +267,10 @@ class _PagedPlaylistClient implements MediaServerClient { requestedSizes.add(size); final offset = start ?? 0; + if (!_hasFailed && offset == failOnceAt) { + _hasFailed = true; + throw StateError('temporary continuation failure'); + } final limit = size ?? items.length; return LibraryPage(items: items.skip(offset).take(limit).toList(), totalCount: items.length, offset: offset); } diff --git a/test/utils/continuation_pagination_coordinator_test.dart b/test/utils/continuation_pagination_coordinator_test.dart new file mode 100644 index 00000000..5567c8a0 --- /dev/null +++ b/test/utils/continuation_pagination_coordinator_test.dart @@ -0,0 +1,139 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/utils/continuation_pagination_coordinator.dart'; + +void main() { + group('ContinuationPaginationCoordinator', () { + test('loads multiple pages and advances start indexes', () async { + final starts = []; + final loaded = []; + final coordinator = ContinuationPaginationCoordinator( + loadPage: (start) async { + starts.add(start); + final items = start == 2 ? [2, 3] : [4]; + return ContinuationPage(items: items, totalCount: 5, consumedCount: items.length); + }, + onPage: (page) => loaded.addAll(page.items), + ); + + coordinator.setContinuation(startIndex: 2, totalCount: 5); + final status = await coordinator.loadRemaining(); + + expect(status, ContinuationLoadStatus.completed); + expect(starts, [2, 4]); + expect(loaded, [2, 3, 4]); + expect(coordinator.nextStartIndex, isNull); + expect(coordinator.totalCount, 5); + }); + + test('rejects a page from a stale generation', () async { + final pendingPage = Completer>(); + final loaded = []; + final coordinator = ContinuationPaginationCoordinator( + loadPage: (_) => pendingPage.future, + onPage: (page) => loaded.addAll(page.items), + ); + + coordinator.setContinuation(startIndex: 1, totalCount: 2); + final staleLoad = coordinator.loadRemaining(); + final freshGeneration = coordinator.runNewGeneration(() async {}); + pendingPage.complete(const ContinuationPage(items: [1], totalCount: 2, consumedCount: 1)); + + expect(await staleLoad, ContinuationLoadStatus.stale); + expect(await freshGeneration, isTrue); + expect(loaded, isEmpty); + expect(coordinator.totalCount, isNull); + }); + + test('retry resumes at the failed start index', () async { + var attempts = 0; + final loaded = []; + final coordinator = ContinuationPaginationCoordinator( + loadPage: (start) async { + attempts++; + if (attempts == 1) throw StateError('temporary'); + return ContinuationPage(items: [start], totalCount: 2, consumedCount: 1); + }, + onPage: (page) => loaded.addAll(page.items), + ); + + coordinator.setContinuation(startIndex: 1, totalCount: 2); + expect(await coordinator.loadRemaining(), ContinuationLoadStatus.failed); + expect(coordinator.error, isA()); + expect(coordinator.nextStartIndex, 1); + + expect(await coordinator.retry(), ContinuationLoadStatus.completed); + expect(attempts, 2); + expect(loaded, [1]); + expect(coordinator.error, isNull); + }); + + test('empty page terminates an incomplete continuation', () async { + var requests = 0; + final coordinator = ContinuationPaginationCoordinator( + loadPage: (_) async { + requests++; + return const ContinuationPage(items: [], totalCount: 10, consumedCount: 0); + }, + onPage: (_) => fail('An empty page must not be applied'), + ); + + coordinator.setContinuation(startIndex: 3, totalCount: 10); + + expect(await coordinator.loadRemaining(), ContinuationLoadStatus.completed); + expect(requests, 1); + expect(coordinator.hasMore, isFalse); + expect(coordinator.error, isNull); + }); + + test('duplicate load requests share one in-flight operation', () async { + final pendingPage = Completer>(); + var requests = 0; + final coordinator = ContinuationPaginationCoordinator( + loadPage: (_) { + requests++; + return pendingPage.future; + }, + onPage: (_) {}, + ); + + coordinator.setContinuation(startIndex: 0, totalCount: 1); + final first = coordinator.loadRemaining(); + final duplicate = coordinator.loadRemaining(); + + expect(identical(first, duplicate), isTrue); + expect(requests, 1); + + pendingPage.complete(const ContinuationPage(items: [0], totalCount: 1, consumedCount: 1)); + expect(await first, ContinuationLoadStatus.completed); + expect(await duplicate, ContinuationLoadStatus.completed); + }); + + test('partial failure keeps applied pages and retry continues after them', () async { + final starts = []; + final loaded = []; + var failSecondPage = true; + final coordinator = ContinuationPaginationCoordinator( + loadPage: (start) async { + starts.add(start); + if (start == 2 && failSecondPage) { + failSecondPage = false; + throw StateError('second page failed'); + } + return ContinuationPage(items: [start], totalCount: 3, consumedCount: 1); + }, + onPage: (page) => loaded.addAll(page.items), + ); + + coordinator.setContinuation(startIndex: 1, totalCount: 3); + expect(await coordinator.loadRemaining(), ContinuationLoadStatus.failed); + expect(loaded, [1]); + expect(coordinator.nextStartIndex, 2); + + expect(await coordinator.retry(), ContinuationLoadStatus.completed); + expect(starts, [1, 2, 2]); + expect(loaded, [1, 2]); + }); + }); +}