mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 22:41:38 +02:00
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.
557 lines
19 KiB
Dart
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),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|