Files
SpotiFLAC-Mobile/lib/screens/queue_tab_sections.dart
T
zarzet acbc614f42 perf(library): build DateFormat and RegExp once instead of per row
The month-year DateFormat was rebuilt for every loaded row and every
fast-scroller drag label; it is now cached per locale tag. Constant-pattern
RegExp objects built per call (or per word) are now static or top-level.
2026-10-18 21:56:46 +07:00

557 lines
19 KiB
Dart

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 = <String, DateFormat>{};
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<UnifiedLibraryItem> 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<UnifiedLibraryItem> 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<T?> _showLibraryOptionsSheet<T>(
BuildContext context, {
required ScrollableWidgetBuilder builder,
}) => showAppModalBottomSheet<T>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (context) => AppScrollableSheet(builder: builder),
);
Future<void> _showSortSheet(BuildContext context) async {
final selected = await _showLibraryOptionsSheet<String>(
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<int>(
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 <LibraryBrowseEntry>[];
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<void>(
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<ScrollNotification>(
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),
],
),
),
),
),
);
}
}