import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/material.dart'; import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:spotiflac_android/widgets/app_choice_chip.dart'; import 'package:spotiflac_android/widgets/app_content_card.dart'; import 'package:spotiflac_android/services/local_track_batch_actions.dart'; import 'package:spotiflac_android/services/shell_navigation_service.dart'; import 'package:spotiflac_android/widgets/error_card.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/swipe_delete_dismissible.dart'; import 'package:spotiflac_android/widgets/track_card.dart'; import 'package:spotiflac_android/theme/app_motion.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; import 'package:spotiflac_android/theme/material_expressive.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/widgets/app_icon.dart'; import 'package:spotiflac_android/widgets/app_icon_button.dart'; import 'package:spotiflac_android/widgets/app_loading_indicator.dart'; import 'package:spotiflac_android/widgets/app_progress_indicator.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; import 'package:spotiflac_android/widgets/expressive_button.dart'; import 'package:spotiflac_android/widgets/app_search_field.dart'; import 'package:spotiflac_android/widgets/app_snack_bar.dart'; import 'package:spotiflac_android/widgets/library_search_results.dart'; import 'package:spotiflac_android/widgets/library_track_selection_bar.dart'; import 'package:spotiflac_android/widgets/library_selection_playback_actions.dart'; import 'package:spotiflac_android/widgets/local_library_artwork_builder.dart'; import 'package:spotiflac_android/widgets/library_playlist_drag_source.dart'; import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart' show DateFormat; import 'package:material_3_expressive/material_3_expressive.dart' show M3EButtonGroup, M3EButtonGroupAction, M3EButtonGroupType, M3EButtonSize, M3EButtonStyle; import 'package:spotiflac_android/providers/library_browse_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/widgets/app_shape.dart'; import 'package:spotiflac_android/widgets/library_fast_scroller.dart'; import 'package:spotiflac_android/widgets/player_artwork.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/audio_quality_badge_policy.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/library_format_filters.dart'; import 'package:spotiflac_android/utils/ordered_range_selection.dart'; import 'package:spotiflac_android/utils/saf_download_tree.dart'; import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/models/unified_library_item.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; import 'package:spotiflac_android/services/music_player_service.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/album_completeness.dart'; import 'package:spotiflac_android/services/local_track_redownload_service.dart'; import 'package:spotiflac_android/services/batch_track_actions.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/listening_rewind_screen.dart'; import 'package:spotiflac_android/screens/favorite_artists_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart'; import 'package:spotiflac_android/screens/local_album_screen.dart'; import 'package:spotiflac_android/screens/mornye_library_screen.dart'; import 'package:spotiflac_android/controllers/completion_bridge_tracker.dart'; import 'package:spotiflac_android/controllers/queue_library_pager.dart'; import 'package:spotiflac_android/controllers/queue_library_refresh_policy.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/animated_library_sliver_grid.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; import 'package:spotiflac_android/widgets/smoothed_progress.dart'; import 'package:spotiflac_android/widgets/scroll_edge_fade.dart'; import 'package:spotiflac_android/widgets/two_finger_pinch_listener.dart'; part 'queue_tab_helpers.dart'; part 'queue_tab_widgets.dart'; part 'queue_tab_selection.dart'; part 'queue_tab_navigation.dart'; part 'queue_tab_collection_items.dart'; part 'queue_tab_filter_widgets.dart'; part 'queue_tab_sections.dart'; part 'queue_tab_batch_actions.dart'; part 'queue_tab_item_widgets.dart'; part 'queue_tab_download_labels.dart'; part 'queue_tab_download_dialogs.dart'; part 'queue_tab_filter_sheet.dart'; const _historyItemForCompletionBridge = CompletionBridgeTracker.historyItemFor; class QueueTab extends ConsumerStatefulWidget { final String? librarySection; final bool isTabActive; const QueueTab({super.key, this.librarySection, this.isTabActive = true}); @override ConsumerState createState() => _QueueTabState(); } class _QueueTabState extends ConsumerState { static const int _libraryPageSize = 300; final LibraryFileAvailabilityCache _fileExistsCache = LibraryFileAvailabilityCache(); late final CompletionBridgePlayableProbeCache _completionBridgePlayableProbe; static const double _libraryGridMinExtent = 92; static const double _libraryGridDefaultExtent = 126; static const double _libraryGridMaxExtent = 190; bool _embeddedCoverRefreshScheduled = false; // Version counter to trigger targeted cover image rebuilds // without rebuilding the entire widget tree via setState. final ValueNotifier _embeddedCoverVersion = ValueNotifier(0); bool _isSelectionMode = false; bool _isDraggingLibraryTrack = false; final Set _selectedIds = {}; String? _selectionAnchorId; final SelectionOverlayController _selectionOverlay = SelectionOverlayController(); List _selectionOverlayItems = const []; double _selectionOverlayBottomPadding = 0; /// Keeps the selection overlays hidden while a modal launched from the /// selection toolbar is open, so they don't reappear over its animation. bool _suppressSelectionOverlay = false; bool _isPlaylistSelectionMode = false; final Set _selectedPlaylistIds = {}; final SelectionOverlayController _playlistSelectionOverlay = SelectionOverlayController(); List _playlistSelectionOverlayItems = const []; double _playlistSelectionOverlayBottomPadding = 0; PageController? _filterPageController; /// Pages of the Library pager: Songs, Albums, Artists and Playlists. /// Singles share the Albums page behind [_albumsShowSingles]. final List _filterModes = ['all', 'albums', 'artists', 'playlists']; bool _albumsShowSingles = false; int _pageIndexFor(String mode) => _filterModes.indexOf(mode == 'singles' ? 'albums' : mode); /// Data mode of the page at [index], resolving Albums to Singles when the /// Albums page shows singles. String _modeForPage(int index) { final mode = _filterModes[index]; return mode == 'albums' && _albumsShowSingles ? 'singles' : mode; } bool _isPageControllerInitialized = false; bool _wasTabVisible = false; final TextEditingController _searchController = TextEditingController(); final FocusNode _searchFocusNode = FocusNode(); String _searchQuery = ''; Timer? _searchDebounce; final CompletionBridgeTracker _completionBridgeTracker = CompletionBridgeTracker(); Map get _completionBridge => _completionBridgeTracker.items; Map get _completionBridgeAt => _completionBridgeTracker.addedAt; Set get _bridgePrecacheStarted => _completionBridgeTracker.precacheStarted; String? _filterSource; String? _filterQuality; String? _filterFormat; String? _filterMetadata; String _sortMode = 'latest'; String _libraryQualityLabelMode = AppSettings.libraryQualityLabelBitDepthOnly; double _libraryGridExtent = _libraryGridDefaultExtent; double? _libraryGridPinchStartExtent; final QueueLibraryPager< _QueueLibraryCountsRequest, _QueueLibraryPageRequest, _QueueLibraryPageData > _pager = QueueLibraryPager( pageSize: _libraryPageSize, combinePages: _QueueLibraryPageData.combine, ); double _queueCoverSize() { final shortestSide = MediaQuery.sizeOf(context).shortestSide; final scale = (shortestSide / 390).clamp(0.82, 1.0); final textScale = effectiveTextScale(context); return (56 * scale * (1 + ((textScale - 1) * 0.12))).clamp(46.0, 56.0); } double get _libraryAlbumGridExtent => (_libraryGridExtent * 1.45).clamp(150.0, 300.0); /// setState is @protected, so the extension part files route rebuilds /// through this forwarder. void _setState(VoidCallback fn) => setState(fn); void _handleLibraryGridPinchStart() { _libraryGridPinchStartExtent = _libraryGridExtent; } void _handleLibraryGridPinchUpdate(double scale) { final startExtent = _libraryGridPinchStartExtent; if (startExtent == null) return; final nextExtent = (startExtent * scale).clamp( _libraryGridMinExtent, _libraryGridMaxExtent, ); if ((nextExtent - _libraryGridExtent).abs() < 0.5) return; setState(() => _libraryGridExtent = nextExtent); } void _handleLibraryGridPinchEnd() { _libraryGridPinchStartExtent = null; } @override void initState() { super.initState(); _completionBridgePlayableProbe = CompletionBridgePlayableProbeCache( onPlayable: _fileExistsCache.markExists, ); } void _initializePageController() { if (_isPageControllerInitialized) return; _isPageControllerInitialized = true; final settings = ref.read(settingsProvider); final initialFilter = widget.librarySection ?? (settings.defaultLibraryView == 'last' ? settings.historyFilterMode : settings.defaultLibraryView); final initialPage = _pageIndexFor( initialFilter, ).clamp(0, _filterModes.length - 1); _albumsShowSingles = initialFilter == 'singles'; final initialMode = _modeForPage(initialPage); if (widget.librarySection == null && settings.historyFilterMode != initialMode) { Future.microtask(() { if (!mounted) return; ref.read(settingsProvider.notifier).setHistoryFilterMode(initialMode); }); } _filterPageController = PageController(initialPage: initialPage); } /// When the shell switches back to this tab and a fixed default view is /// configured, jump the filter pager to it. void _applyDefaultLibraryViewOnTabVisible() { if (widget.librarySection != null) return; // Route transitions also disable TickerMode. Only a shell tab change // should reset the user's current album/single view. final isVisible = widget.isTabActive; final becameVisible = isVisible && !_wasTabVisible; _wasTabVisible = isVisible; if (!becameVisible) return; final defaultView = ref.read(settingsProvider).defaultLibraryView; if (defaultView == 'last') return; final index = _pageIndexFor(defaultView); if (index < 0) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; if (_filterModes[index] == 'albums') { _setState(() => _albumsShowSingles = defaultView == 'singles'); } if (ref.read(settingsProvider).historyFilterMode != defaultView) { ref.read(settingsProvider.notifier).setHistoryFilterMode(defaultView); } final controller = _filterPageController; if (controller != null && controller.hasClients && controller.page?.round() != index) { controller.jumpToPage(index); } }); } @override void dispose() { _hideSelectionOverlay(); _hidePlaylistSelectionOverlay(); _fileExistsCache.dispose(); _completionBridgePlayableProbe.dispose(); _embeddedCoverVersion.dispose(); _filterPageController?.dispose(); _searchController.dispose(); _searchFocusNode.dispose(); _searchDebounce?.cancel(); super.dispose(); } void _onSearchChanged(String value) { _searchDebounce?.cancel(); final normalized = value.trim().toLowerCase(); _searchDebounce = Timer(const Duration(milliseconds: 350), () { if (!mounted || _searchQuery == normalized) return; setState(() { _searchQuery = normalized; _resetLibraryPaging(); }); }); } void _clearSearch() { _searchDebounce?.cancel(); if (_searchQuery.isEmpty) return; setState(() { _searchQuery = ''; _resetLibraryPaging(); }); } void _resetLibraryPaging() => _pager.resetPaging(); void _scheduleBlankLibraryRepair({ required bool hasQueueItems, required bool hasLibraryContent, required bool hasAnyLibraryItems, required bool isLibraryPageLoading, }) { _pager.scheduleBlankLibraryRepair( hasQueueItems: hasQueueItems, hasLibraryContent: hasLibraryContent, hasAnyLibraryItems: hasAnyLibraryItems, isLibraryPageLoading: isLibraryPageLoading, isMounted: () => mounted, repair: () { ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); if (ref.read(settingsProvider).localLibraryEnabled) { ref.read(localLibraryProvider.notifier).reloadFromStorage(); } setState(() {}); }, ); } bool _handleLibraryScrollNotification({ required ScrollNotification notification, required String filterMode, required bool hasMoreLibrary, required bool isPageLoading, }) { return _pager.handleScrollNotification( notification: notification, filterMode: filterMode, hasMoreLibrary: hasMoreLibrary, isPageLoading: isPageLoading, setState: setState, ); } void _onFilterPageChanged(int index) { if (widget.librarySection != null) return; HapticFeedback.selectionClick(); ref .read(settingsProvider.notifier) .setHistoryFilterMode(_modeForPage(index)); } void _animateToFilterPage(int index) { if (index >= 0 && index < _filterModes.length) { final filterMode = _modeForPage(index); if (ref.read(settingsProvider).historyFilterMode != filterMode) { ref.read(settingsProvider.notifier).setHistoryFilterMode(filterMode); } } final motion = AppMotion.spatialSlow( context, const AppMotionSpec(Duration(milliseconds: 300), Curves.easeOutCubic), ); _filterPageController?.animateToPage( index, duration: motion.duration, curve: motion.curve, ); } /// Switches the Albums page between albums and singles. void _setAlbumsShowSingles(bool singles) { if (_albumsShowSingles == singles) return; HapticFeedback.selectionClick(); setState(() => _albumsShowSingles = singles); ref .read(settingsProvider.notifier) .setHistoryFilterMode(singles ? 'singles' : 'albums'); } void _onEmbeddedCoverChanged() { if (!mounted || _embeddedCoverRefreshScheduled) return; _embeddedCoverRefreshScheduled = true; WidgetsBinding.instance.addPostFrameCallback((_) { _embeddedCoverRefreshScheduled = false; if (mounted) { _embeddedCoverVersion.value++; } }); } Future _scheduleDownloadedEmbeddedCoverRefreshForPath( String? filePath, { int? beforeModTime, bool force = false, }) async { await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath( filePath, beforeModTime: beforeModTime, force: force, onChanged: _onEmbeddedCoverChanged, ); } String? _resolveDownloadedEmbeddedCoverPath(String? filePath) { return DownloadedEmbeddedCoverResolver.resolve( filePath, onChanged: _onEmbeddedCoverChanged, ); } ValueListenable _fileExistsListenable(String? filePath) { return _fileExistsCache.listenable(filePath); } void _refreshCompletionBridgePath(String? filePath) { _fileExistsCache.refreshForPath(filePath); _completionBridgePlayableProbe.refreshForPath(filePath); } int get _activeFilterCount { int count = 0; if (_filterSource != null) count++; if (_filterQuality != null) count++; if (_filterFormat != null) count++; if (_filterMetadata != null) count++; return count; } void _resetFilters() { setState(() { _filterSource = null; _filterQuality = null; _filterFormat = null; _filterMetadata = null; _sortMode = 'latest'; _resetLibraryPaging(); }); } @override Widget build(BuildContext context) { if (context.isMornye && widget.librarySection == null) { return MornyeLibraryScreen( onOpenSection: (section) => Navigator.of(context).push( MaterialPageRoute( builder: (_) => Scaffold(body: QueueTab(librarySection: section)), ), ), ); } _initializePageController(); _applyDefaultLibraryViewOnTabVisible(); final localLibraryEnabled = ref.watch( settingsProvider.select((s) => s.localLibraryEnabled), ); ref.listen(downloadQueueLookupProvider, (previous, next) { _completionBridgeTracker.onQueueLookupChanged( previous, next, setState: setState, refreshPath: _refreshCompletionBridgePath, ); }); ref.listen( downloadHistoryProvider.select((state) => state.loadedIndexVersion), (previous, next) { if (previous == null || previous == next) return; final historyItems = ref.read(downloadHistoryProvider).items; _completionBridgeTracker.refreshPaths( historyItems, refreshPath: _refreshCompletionBridgePath, ); // The family provider already reruns for the new revision. Retain its // last successful page while SQLite is loading so metadata backfills // and download completions cannot flash the Library as empty. _pager.resetOffsets(); if (mounted) setState(() {}); }, ); if (localLibraryEnabled) { ref.listen( localLibraryProvider.select((state) => state.loadedIndexVersion), (previous, next) { if (previous == null || previous == next) return; // Keep stale rows visible until the refreshed query replaces them. _pager.resetOffsets(); if (mounted) setState(() {}); }, ); } if (widget.librarySection == 'downloads') { final ids = ref.watch( downloadQueueLookupProvider.select((s) => s.notCompletedItemIds), ); final colors = Theme.of(context).colorScheme; return CustomScrollView( slivers: [ AppSliverHeader.page(title: context.l10n.libraryDownloads), _buildQueueHeaderSliver(context, colors), SliverList.builder( itemCount: ids.length, itemBuilder: (context, index) => TrackCardSegment.at( key: ValueKey(ids[index]), index: index, count: ids.length, child: _QueueItemSliverRow( itemId: ids[index], colorScheme: colors, itemBuilder: _buildQueueItem, ), ), ), if (ids.isEmpty) SliverFillRemaining( hasScrollBody: false, child: Center(child: Text(context.l10n.libraryNoActiveDownloads)), ), const NavBarSliverSpacer(), ], ); } final hasQueueItems = ref.watch( downloadQueueLookupProvider.select((lookup) => lookup.itemIds.isNotEmpty), ); final historyTotalCount = ref.watch( downloadHistoryProvider.select((state) => state.totalCount), ); // Completion-bridge cells need the finalized in-memory history record // immediately, before the database-backed Library page refresh lands. final inMemoryHistoryItems = ref.watch( downloadHistoryProvider.select((state) => state.items), ); final localLibraryTotalCount = localLibraryEnabled ? ref.watch(localLibraryProvider.select((state) => state.totalCount)) : 0; // Watch with selector on key fields to reduce unnecessary rebuilds. // LibraryCollectionsState doesn't implement == so watching without // selector rebuilds on every provider notification. ref.watch( libraryCollectionsProvider.select( (s) => ( s.wishlistCount, s.lovedCount, s.favoriteArtistCount, s.playlistCount, s.hasPlaylistTracks, s.isLoaded, ), ), ); final collectionState = ref.read(libraryCollectionsProvider); final historyViewMode = ref.watch( settingsProvider.select((s) => s.historyViewMode), ); final String historyFilterMode = widget.librarySection ?? ref.watch(settingsProvider.select((s) => s.historyFilterMode)); // Keep this mode out of the page-provider request: changing only badge // text must not re-query the database or reset Library pagination. _libraryQualityLabelMode = ref.watch( settingsProvider.select((s) => s.libraryQualityLabelMode), ); final colorScheme = Theme.of(context).colorScheme; final countsRequest = _QueueLibraryCountsRequest( searchQuery: _searchQuery, filterSource: _filterSource, filterQuality: _filterQuality, filterFormat: _filterFormat, filterMetadata: _filterMetadata, localLibraryEnabled: localLibraryEnabled, ); final searchingLibrary = _searchQuery.isNotEmpty && !isAlbumCompletenessFilter(_filterMetadata) && !_isSelectionMode && !_isPlaylistSelectionMode; final countsValue = searchingLibrary ? const AsyncData( QueueLibraryCounts( allTrackCount: 0, albumCount: 0, singleTrackCount: 0, ), ) : ref.watch(_queueLibraryCountsProvider(countsRequest)); final historySnapshotFallbackEnabled = hasQueueItems && inMemoryHistoryItems.isNotEmpty && countsRequest.allowsInMemoryHistoryFallback; final historySnapshotCounts = historySnapshotFallbackEnabled ? _historySnapshotCounts( inMemoryHistoryItems, persistedTotalCount: historyTotalCount, ) : null; final queueCounts = _pager.resolveCounts( countsValue, countsRequest, nonEmptyFallback: historySnapshotCounts, ); _QueueLibraryPageRequest pageRequest(String filterMode) { final offset = _pager.pageOffsetFor(filterMode); final baseRequest = _QueueLibraryPageRequest( filterMode: filterMode, limit: _libraryPageSize, offset: offset, searchQuery: _searchQuery, filterSource: _filterSource, filterQuality: _filterQuality, filterFormat: _filterFormat, filterMetadata: _filterMetadata, sortMode: _sortMode, localLibraryEnabled: localLibraryEnabled, ); if (offset == 0) return baseRequest; final previousPage = _pager.cachedPageAt( baseRequest, offset - _libraryPageSize, ); return _QueueLibraryPageRequest( filterMode: baseRequest.filterMode, limit: baseRequest.limit, offset: baseRequest.offset, searchQuery: baseRequest.searchQuery, filterSource: baseRequest.filterSource, filterQuality: baseRequest.filterQuality, filterFormat: baseRequest.filterFormat, filterMetadata: baseRequest.filterMetadata, sortMode: baseRequest.sortMode, localLibraryEnabled: baseRequest.localLibraryEnabled, cursor: previousPage?.nextCursor, ); } final activePageRequest = pageRequest(historyFilterMode); // The Artists page browses its own query. final activePageValue = searchingLibrary || historyFilterMode == 'artists' ? const AsyncData(_QueueLibraryPageData()) : ref.watch(_queueLibraryPageProvider(activePageRequest)); // The in-memory snapshot regroups every history item. It is only needed // when a page is still empty, so build it on demand and at most once per // mode for this build's (immutable) history list. final historySnapshots = <(String, int), _QueueLibraryPageData>{}; _QueueLibraryPageData pageData(String filterMode) { final request = filterMode == historyFilterMode ? activePageRequest : pageRequest(filterMode); final historySnapshotAvailable = hasQueueItems && inMemoryHistoryItems.isNotEmpty && request.allowsInMemoryHistoryFallback; return _pager.resolvePageData( filterMode == historyFilterMode ? activePageValue : null, request, nonEmptyFallback: historySnapshotAvailable ? () => historySnapshots.putIfAbsent( (request.filterMode, request.limit), () => _QueueLibraryPageData.fromHistorySnapshot( inMemoryHistoryItems, filterMode: request.filterMode, limit: request.limit, ), ) : null, ); } _FilterContentData getFilterData(String filterMode) { return pageData(filterMode).toFilterContentData( collectionState, totalTrackCount: switch (filterMode) { 'singles' => queueCounts.singleTrackCount, 'albums' || 'playlists' => 0, _ => queueCounts.allTrackCount, }, totalAlbumCount: filterMode == 'albums' ? queueCounts.albumCount : null, ); } final currentPageData = pageData(historyFilterMode); final currentLoadedCount = historyFilterMode == 'albums' ? currentPageData.groupedAlbums.length + currentPageData.groupedLocalAlbums.length : currentPageData.items.length; final currentTotalCount = switch (historyFilterMode) { 'albums' => queueCounts.albumCount, 'singles' => queueCounts.singleTrackCount, 'playlists' || 'artists' => 0, _ => queueCounts.allTrackCount, }; final hasMoreLibrary = currentLoadedCount < currentTotalCount; final isLibraryPageLoading = countsValue.isLoading || activePageValue.isLoading; final hasAnyLibraryItems = queueCounts.allTrackCount > 0 || queueCounts.albumCount > 0; final hasLibraryContent = historyTotalCount > 0 || inMemoryHistoryItems.isNotEmpty || (localLibraryEnabled && localLibraryTotalCount > 0); final hasActiveSearch = _searchQuery.isNotEmpty || _searchController.text.trim().isNotEmpty; final shouldShowLibraryControls = hasLibraryContent || hasAnyLibraryItems || hasActiveSearch; final listInset = wideListInset(context); if (!searchingLibrary) { _scheduleBlankLibraryRepair( hasQueueItems: hasQueueItems, hasLibraryContent: hasLibraryContent, hasAnyLibraryItems: hasAnyLibraryItems, isLibraryPageLoading: isLibraryPageLoading, ); } final selectionItems = getFilterData( historyFilterMode, ).filteredUnifiedItems; if (_isSelectionMode || _isPlaylistSelectionMode) { final bottomPadding = MediaQuery.paddingOf(context).bottom; WidgetsBinding.instance.addPostFrameCallback((_) { if (_isSelectionMode) { _syncSelectionOverlay( items: selectionItems, bottomPadding: bottomPadding, ); } if (_isPlaylistSelectionMode) { _syncPlaylistSelectionOverlay( playlists: collectionState.playlists, bottomPadding: bottomPadding, ); } }); } return PopScope( canPop: !_isSelectionMode && !_isPlaylistSelectionMode, onPopInvokedWithResult: (didPop, result) { if (!didPop) { if (_isPlaylistSelectionMode) { _exitPlaylistSelectionMode(); } else if (_isSelectionMode) { _exitSelectionMode(); } } }, child: Stack( children: [ // ScrollConfiguration disables stretch overscroll to fix _StretchController exception // This is a known Flutter issue with NestedScrollView + Material 3 stretch indicator ScrollConfiguration( behavior: ScrollConfiguration.of( context, ).copyWith(overscroll: false), child: NestedScrollView( headerSliverBuilder: (context, innerBoxIsScrolled) => [ if (widget.librarySection == null) AppSliverHeader.tabRoot( title: context.l10n.navLibrary, alignToContent: true, ) else AppSliverHeader.page( title: widget.librarySection == 'playlists' ? context.l10n.searchPlaylists : context.l10n.searchSongs, ), if (shouldShowLibraryControls || hasQueueItems) SliverToBoxAdapter( child: Padding( padding: EdgeInsets.fromLTRB( 16 + listInset, 8, 16 + listInset, 0, ), child: GestureDetector( onTap: () {}, child: AppSearchField( controller: _searchController, focusNode: _searchFocusNode, hintText: context.l10n.librarySearchHint, clearTooltip: context.l10n.dialogClear, onChanged: _onSearchChanged, onClear: () { _clearSearch(); FocusScope.of(context).unfocus(); }, ), ), ), ), if (!searchingLibrary && shouldShowLibraryControls && widget.librarySection == null) SliverToBoxAdapter( child: Padding( padding: EdgeInsets.fromLTRB( 16 + listInset, 12, 16 + listInset, 4, ), child: LayoutBuilder( builder: (context, constraints) { final activePage = _pageIndexFor(historyFilterMode); Widget tab( int index, IconData icon, String label, int? count, ) => _FilterChip( label: label, icon: icon, count: count, isSelected: activePage == index, onTap: () => _animateToFilterPage(index), ); return ScrollEdgeFade( axis: Axis.horizontal, size: 48, child: SingleChildScrollView( scrollDirection: Axis.horizontal, padding: context.isMornye ? const EdgeInsets.symmetric(vertical: 4) : EdgeInsets.zero, child: ConstrainedBox( constraints: BoxConstraints( minWidth: constraints.maxWidth, ), // Mornye spreads its glass pills across the // search field; Material chips sit 8dp apart // from the start like other filter rows. child: Row( mainAxisAlignment: context.isMornye ? MainAxisAlignment.spaceBetween : MainAxisAlignment.start, children: [ tab( 0, Icons.music_note_outlined, context.l10n.searchSongs, queueCounts.allTrackCount, ), const SizedBox(width: 8), tab( 1, Icons.album_outlined, context.l10n.historyFilterAlbums, queueCounts.albumCount, ), const SizedBox(width: 8), // Artist totals need a full scan; the // tab shows none rather than a guess. tab( 2, Icons.person_outline, context.l10n.searchArtists, null, ), const SizedBox(width: 8), tab( 3, Icons.queue_music_outlined, context.l10n.searchPlaylists, _getVisibleCollectionEntries( collectionState, ).length, ), ], ), ), ), ); }, ), ), ), ], body: searchingLibrary ? CustomScrollView( key: const ValueKey('library-search-results'), slivers: [ LibrarySearchResults( query: _searchQuery, onOpenArtist: (artist) => Navigator.of(context).push( MaterialPageRoute( builder: (_) => Scaffold( body: MornyeLibraryScreen( page: MornyeLibraryPage.albums, artist: artist, onOpenSection: (_) {}, ), ), ), ), ), const NavBarSliverSpacer(), ], ) : widget.librarySection != null ? _buildFilterContent( context: context, colorScheme: colorScheme, filterMode: historyFilterMode, historyViewMode: 'list', hasQueueItems: false, filterData: getFilterData(historyFilterMode), collectionState: collectionState, hasMoreLibrary: hasMoreLibrary, isPageLoading: isLibraryPageLoading, inMemoryHistoryItems: inMemoryHistoryItems, ) : PageView.builder( controller: _filterPageController!, physics: const ClampingScrollPhysics(), onPageChanged: _onFilterPageChanged, itemCount: _filterModes.length, itemBuilder: (context, index) { if (_filterModes[index] == 'artists') { return _buildArtistsPage(context, colorScheme); } final filterMode = _modeForPage(index); final filterData = getFilterData(filterMode); return _buildFilterContent( context: context, colorScheme: colorScheme, filterMode: filterMode, historyViewMode: historyViewMode, hasQueueItems: hasQueueItems, filterData: filterData, collectionState: collectionState, hasMoreLibrary: filterMode == historyFilterMode ? hasMoreLibrary : false, isPageLoading: isLibraryPageLoading, inMemoryHistoryItems: inMemoryHistoryItems, ); }, ), ), ), // ScrollConfiguration ], ), ); } Widget _buildQueueHeaderSliver( BuildContext context, ColorScheme colorScheme, ) { return Consumer( builder: (context, ref, child) { final queueCount = ref.watch( downloadQueueLookupProvider.select( (lookup) => lookup.notCompletedItemIds.length, ), ); final failedCount = ref.watch( downloadQueueProvider.select((state) => state.failedCount), ); final isProcessing = ref.watch( downloadQueueProvider.select((state) => state.isProcessing), ); final isPaused = ref.watch( downloadQueueProvider.select((state) => state.isPaused), ); return SliverToBoxAdapter( child: AnimatedSwitcher( duration: const Duration(milliseconds: 260), switchInCurve: Curves.easeOut, switchOutCurve: Curves.easeIn, transitionBuilder: (child, animation) => SizeTransition( sizeFactor: animation, alignment: Alignment.topCenter, child: FadeTransition(opacity: animation, child: child), ), child: queueCount == 0 ? const SizedBox( width: double.infinity, key: ValueKey('dl_header_empty'), ) : Padding( key: const ValueKey('dl_header'), padding: EdgeInsets.fromLTRB( 16 + wideListInset(context), 4, 4 + wideListInset(context), 4, ), child: Row( children: [ AppIcon( Icons.downloading_rounded, size: 16, color: colorScheme.primary, ), const SizedBox(width: 8), Expanded( child: Text( context.l10n.queueDownloadingCount(queueCount), maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.labelLarge ?.copyWith( color: colorScheme.onSurfaceVariant, fontWeight: FontWeight.w600, ), ), ), if (failedCount > 0 && !isProcessing) AppIconButton( onPressed: () => ref .read(downloadQueueProvider.notifier) .retryAllFailed(), icon: const Icon(Icons.replay_rounded, size: 20), tooltip: context.l10n.queueRetryAllFailed( failedCount, ), color: colorScheme.primary, visualDensity: VisualDensity.compact, ), AppIconButton( onPressed: () => ref .read(downloadQueueProvider.notifier) .togglePause(), icon: Icon( isPaused ? Icons.play_arrow_rounded : Icons.pause_rounded, size: 20, ), tooltip: isPaused ? context.l10n.actionResume : context.l10n.actionPause, color: colorScheme.onSurfaceVariant, visualDensity: VisualDensity.compact, ), AppIconButton( onPressed: () => _showClearAllDialog(context, ref, colorScheme), icon: const Icon(Icons.clear_all_rounded, size: 20), tooltip: context.l10n.queueClearAll, color: colorScheme.error, visualDensity: VisualDensity.compact, ), ], ), ), ), ); }, ); } }