part of 'queue_tab.dart'; /// A run of consecutive rows sharing one section label. class _LibrarySection { const _LibrarySection(this.label, this.start, this.end); final String label; final int start; /// Exclusive. final int end; int get length => end - start; } final _letterPattern = RegExp(r'[\p{L}]', unicode: true); final _monthYearFormats = {}; DateFormat _monthYearFormat(BuildContext context) { final locale = Localizations.localeOf(context).toLanguageTag(); return _monthYearFormats.putIfAbsent(locale, () => DateFormat.yMMMM(locale)); } /// First letter of [text] for an alphabetical index; digits and symbols /// share `#`. String _sectionInitial(String text) { final trimmed = text.trimLeft(); for (final character in trimmed.characters) { final upper = character.toUpperCase(); if (upper != character.toLowerCase()) return upper; if (_letterPattern.hasMatch(character)) return upper; return '#'; } return '#'; } const _alphabeticalSorts = {'a-z', 'z-a'}; const _artistSorts = {'artist-asc', 'artist-desc'}; const _albumSorts = {'album-asc', 'album-desc'}; const _dateSorts = {'latest', 'oldest'}; /// Sort choices offered by the Library sort sheet, in display order. const _librarySortModes = [ 'latest', 'oldest', 'a-z', 'z-a', 'artist-asc', 'artist-desc', 'album-asc', 'album-desc', 'release-newest', 'release-oldest', 'genre-asc', 'genre-desc', ]; extension _QueueTabSections on _QueueTabState { String _sortLabel(BuildContext context, String mode) { final l10n = context.l10n; return switch (mode) { 'oldest' => l10n.libraryFilterSortOldest, 'a-z' => l10n.searchSortTitleAZ, 'z-a' => l10n.searchSortTitleZA, 'artist-asc' => l10n.searchSortArtistAZ, 'artist-desc' => l10n.searchSortArtistZA, 'album-asc' => l10n.libraryFilterSortAlbumAsc, 'album-desc' => l10n.libraryFilterSortAlbumDesc, 'release-newest' => l10n.searchSortDateNewest, 'release-oldest' => l10n.searchSortDateOldest, 'genre-asc' => l10n.libraryFilterSortGenreAsc, 'genre-desc' => l10n.libraryFilterSortGenreDesc, _ => l10n.libraryFilterSortLatest, }; } /// Section label of [item] under the current sort, or null when the sort /// has no readable sections (genre and release dates are not loaded). String? _trackSectionLabel(BuildContext context, UnifiedLibraryItem item) { if (_alphabeticalSorts.contains(_sortMode)) { return _sectionInitial(item.trackName); } if (_artistSorts.contains(_sortMode)) { return _sectionInitial(item.artistName); } if (_albumSorts.contains(_sortMode)) { return _sectionInitial(item.albumName); } if (_dateSorts.contains(_sortMode)) { return _monthYearFormat(context).format(item.addedAt); } return null; } String? _albumSectionLabel( BuildContext context, { required String album, required String artist, required DateTime added, }) { if (_alphabeticalSorts.contains(_sortMode) || _albumSorts.contains(_sortMode)) { return _sectionInitial(album); } if (_artistSorts.contains(_sortMode)) return _sectionInitial(artist); if (_dateSorts.contains(_sortMode)) { return _monthYearFormat(context).format(added); } return null; } /// Splits [items] into runs that share a section label. List<_LibrarySection> _trackSections( BuildContext context, List items, ) { final sections = <_LibrarySection>[]; String? label; var start = 0; for (var i = 0; i < items.length; i++) { final next = _trackSectionLabel(context, items[i]) ?? ''; if (i == 0) { label = next; } else if (next != label) { sections.add(_LibrarySection(label!, start, i)); label = next; start = i; } } if (items.isNotEmpty) { sections.add(_LibrarySection(label!, start, items.length)); } return sections; } /// Track rows grouped under pinned section headers. Each section is its own /// connected run of rows. Widget _buildSectionedTrackSlivers({ required BuildContext context, required List items, required Widget Function(int index) rowBuilder, required int leadCount, required Widget Function(int index) leadBuilder, }) { final sections = _trackSections(context, items); final headed = sections.length > 1 || sections.firstOrNull?.label != ''; return SliverPadding( padding: EdgeInsets.symmetric(horizontal: wideListInset(context)), sliver: SliverMainAxisGroup( slivers: [ if (leadCount > 0) SliverList( delegate: SliverChildBuilderDelegate( (context, index) => leadBuilder(index), childCount: leadCount, ), ), for (final section in sections) SliverMainAxisGroup( slivers: [ if (headed && section.label.isNotEmpty) PinnedHeaderSliver( child: _LibrarySectionHeader(label: section.label), ), SliverList( delegate: SliverChildBuilderDelegate((context, index) { final itemIndex = section.start + index; return TrackCardSegment.at( key: ValueKey(items[itemIndex].id), index: index, count: section.length, child: rowBuilder(itemIndex), ); }, childCount: section.length), ), ], ), ], ), ); } /// Quick sort control beside a page's count. Widget _buildSortButton(BuildContext context) => ExpressiveButton( text: true, onPressed: () => _showSortSheet(context), icon: const AppIcon(Icons.sort, size: 18), style: TextButton.styleFrom(visualDensity: VisualDensity.compact), child: Text(_sortLabel(context, _sortMode)), ); /// Switches song pages between the list and the cover grid. Widget _buildViewModeButton(BuildContext context, String viewMode) { final grid = viewMode == 'grid'; return AppIconButton( tooltip: grid ? context.l10n.appearanceHistoryViewList : context.l10n.appearanceHistoryViewGrid, icon: AppIcon(grid ? Icons.view_list : Icons.grid_view), onPressed: () => ref .read(settingsProvider.notifier) .setHistoryViewMode(grid ? 'list' : 'grid'), ); } Future _showLibraryOptionsSheet( BuildContext context, { required ScrollableWidgetBuilder builder, }) => showAppModalBottomSheet( context: context, useRootNavigator: true, isScrollControlled: true, builder: (context) => AppScrollableSheet(builder: builder), ); Future _showSortSheet(BuildContext context) async { final selected = await _showLibraryOptionsSheet( context, builder: (sheetContext, scrollController) => AppBottomSheet( title: sheetContext.l10n.libraryFilterSort, child: SingleChildScrollView( controller: scrollController, padding: EdgeInsets.only(bottom: sheetContext.tokens.gapLg), child: SettingsGroup( children: [ for (final mode in _librarySortModes) if (sheetContext.isMornye) SettingsItem( title: _sortLabel(sheetContext, mode), selected: mode == _sortMode, trailing: mode == _sortMode ? AppIcon( Icons.check, color: Theme.of(sheetContext).colorScheme.primary, ) : const SizedBox.shrink(), showDivider: mode != _librarySortModes.last, onTap: () => Navigator.pop(sheetContext, mode), ) else ListTile( dense: true, minTileHeight: sheetContext.tokens.minTouchTarget, contentPadding: EdgeInsets.symmetric( horizontal: sheetContext.tokens.rowPaddingH, ), titleTextStyle: Theme.of(sheetContext).textTheme.bodyLarge, title: Text(_sortLabel(sheetContext, mode)), selected: mode == _sortMode, trailing: mode == _sortMode ? AppIcon( Icons.check, color: Theme.of(sheetContext).colorScheme.primary, ) : null, onTap: () => Navigator.pop(sheetContext, mode), ), ], ), ), ), ); if (selected == null || selected == _sortMode || !mounted) return; _setState(() { _sortMode = selected; _resetLibraryPaging(); }); } /// Albums | Singles switch at the top of the Albums page. Widget _buildAlbumsModeToggle(BuildContext context) { final l10n = context.l10n; final labels = [l10n.historyFilterAlbums, l10n.historyFilterSingles]; final selected = _albumsShowSingles ? 1 : 0; final Widget toggle; if (materialExpressiveEnabled(context)) { toggle = MaterialExpressiveScope( child: M3EButtonGroup( type: M3EButtonGroupType.connected, style: M3EButtonStyle.filled, // Connected segments get no inflated target; keep 48 dp. size: M3EButtonSize.sm.copyWith(height: 48), selectedIndex: selected, selectionRequired: true, onSelectedIndexChanged: (index) { if (index != null) _setAlbumsShowSingles(index == 1); }, actions: [ for (final label in labels) M3EButtonGroupAction(label: Text(label)), ], ), ); } else { toggle = SegmentedButton( showSelectedIcon: false, segments: [ for (final (index, label) in labels.indexed) ButtonSegment(value: index, label: Text(label)), ], selected: {selected}, onSelectionChanged: (value) => _setAlbumsShowSingles(value.first == 1), ); } return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: Align(alignment: Alignment.centerLeft, child: toggle), ); } /// Artists across downloaded and scanned music, alphabetically under /// pinned letter headers. Tapping one opens that artist's albums. Widget _buildArtistsPage(BuildContext context, ColorScheme colorScheme) { const request = ( artists: true, artist: null, search: '', sort: 'a-z', limit: 80, completeness: null, ); return Consumer( builder: (context, ref, _) { final state = ref.watch(libraryBrowseProvider(request)); final entries = state.value?.entries ?? const []; final sections = <_LibrarySection>[]; for (var i = 0; i < entries.length; i++) { final label = _sectionInitial(entries[i].artist); if (sections.isEmpty || sections.last.label != label) { if (sections.isNotEmpty) { final last = sections.removeLast(); sections.add(_LibrarySection(last.label, last.start, i)); } sections.add(_LibrarySection(label, i, entries.length)); } } void openArtist(String artist) => Navigator.of(context).push( MaterialPageRoute( builder: (_) => Scaffold( body: MornyeLibraryScreen( page: MornyeLibraryPage.albums, artist: artist, onOpenSection: (_) {}, ), ), ), ); final content = CustomScrollView( slivers: [ if (state.isLoading && entries.isEmpty) const SliverFillRemaining( hasScrollBody: false, child: Center(child: AppLoadingIndicator()), ) else if (entries.isEmpty) SliverFillRemaining( hasScrollBody: false, child: Center( child: Padding( padding: const EdgeInsets.all(32), child: Text( context.l10n.libraryArtistsEmpty, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyLarge?.copyWith( color: colorScheme.onSurfaceVariant, ), ), ), ), ) else SliverPadding( padding: EdgeInsets.fromLTRB( wideListInset(context), 8, wideListInset(context), 0, ), sliver: SliverMainAxisGroup( slivers: [ for (final section in sections) SliverMainAxisGroup( slivers: [ PinnedHeaderSliver( child: _LibrarySectionHeader(label: section.label), ), SliverList( delegate: SliverChildBuilderDelegate(( context, index, ) { final entry = entries[section.start + index]; return _ArtistRow( key: ValueKey('artist_${entry.key}'), entry: entry, first: index == 0, last: index == section.length - 1, onTap: () => openArtist(entry.artist), ); }, childCount: section.length), ), ], ), if (state.value?.isLoadingMore ?? false) const SliverToBoxAdapter( child: Padding( padding: EdgeInsets.all(16), child: Center(child: AppLoadingIndicator(size: 22)), ), ), ], ), ), const NavBarSliverSpacer(), ], ); return NotificationListener( onNotification: (notification) { if (notification.depth == 0 && notification.metrics.extentAfter < 800 && (state.value?.hasMore ?? false)) { ref.read(libraryBrowseProvider(request).notifier).loadMore(); } return false; }, child: LibraryFastScroller( topPadding: MediaQuery.paddingOf(context).top + kToolbarHeight, bottomPadding: context.navBarBottomInset, // Artist totals are unknown; the thumb spans the loaded rows. labelAt: (fraction) => entries.isEmpty ? null : _sectionInitial( entries[(fraction * (entries.length - 1)).round()].artist, ), child: content, ), ); }, ); } } /// Pinned label above a section of library rows. class _LibrarySectionHeader extends StatelessWidget { const _LibrarySectionHeader({required this.label}); final String label; @override Widget build(BuildContext context) { final theme = Theme.of(context); return ColoredBox( color: theme.scaffoldBackgroundColor, child: Padding( padding: const EdgeInsets.fromLTRB(20, 12, 20, 6), child: Semantics( header: true, child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w700, color: theme.colorScheme.primary, ), ), ), ), ); } } class _ArtistRow extends StatelessWidget { const _ArtistRow({ super.key, required this.entry, required this.first, required this.last, required this.onTap, }); final LibraryBrowseEntry entry; final bool first; final bool last; final VoidCallback onTap; @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; final cover = entry.cover; return Padding( padding: EdgeInsets.fromLTRB(16, first ? 0 : 1, 16, last ? 4 : 1), child: Material( color: settingsGroupColor(context), borderRadius: connectedRowRadius(context, first: first, last: last), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onTap, child: Padding( padding: const EdgeInsets.all(12), child: Row( children: [ AppShapeClip( kind: AppShapes.artist, child: SizedBox.square( dimension: 52, child: cover == null || cover.isEmpty ? ColoredBox( color: colors.surfaceContainerHighest, child: AppIcon( Icons.person, color: colors.onSurfaceVariant, ), ) : PlayerArtwork( artUri: cover, colorScheme: colors, cacheWidth: (52 * MediaQuery.devicePixelRatioOf(context)) .ceil(), iconSize: 24, ), ), ), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( entry.artist, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith( fontWeight: FontWeight.w600, ), ), Text( context.l10n.tracksCount(entry.trackCount), style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colors.onSurfaceVariant, ), ), ], ), ), AppIcon(Icons.chevron_right, color: colors.onSurfaceVariant), ], ), ), ), ), ); } }