mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 13:19:44 +02:00
Add category browsing, a two-column Recently Added grid, and a dedicated download queue. Keep compact navigation icons and their animations aligned with the active tab. Keep profile avatars circular and report lyric enrichment outcomes accurately, preserving existing lyrics and supporting instrumental markers.
463 lines
16 KiB
Dart
463 lines
16 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
|
import 'package:spotiflac_android/providers/library_browse_provider.dart';
|
|
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
|
|
import 'package:spotiflac_android/screens/local_album_screen.dart';
|
|
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
|
import 'package:spotiflac_android/services/library_database.dart';
|
|
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
|
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
|
|
import 'package:spotiflac_android/widgets/app_search_field.dart';
|
|
import 'package:spotiflac_android/widgets/app_sliver_header.dart';
|
|
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
|
|
|
enum MornyeLibraryPage { overview, albums, artists }
|
|
|
|
class MornyeLibraryScreen extends ConsumerStatefulWidget {
|
|
const MornyeLibraryScreen({
|
|
super.key,
|
|
required this.onOpenSection,
|
|
this.page = MornyeLibraryPage.overview,
|
|
this.artist,
|
|
});
|
|
|
|
final ValueChanged<String> onOpenSection;
|
|
final MornyeLibraryPage page;
|
|
final String? artist;
|
|
|
|
@override
|
|
ConsumerState<MornyeLibraryScreen> createState() =>
|
|
_MornyeLibraryScreenState();
|
|
}
|
|
|
|
class _MornyeLibraryScreenState extends ConsumerState<MornyeLibraryScreen> {
|
|
final _scroll = ScrollController();
|
|
final _search = TextEditingController();
|
|
Timer? _debounce;
|
|
bool _showSearch = false;
|
|
String _query = '';
|
|
late String _sort;
|
|
int _limit = 40;
|
|
List<LibraryBrowseEntry> _rows = const [];
|
|
bool _loading = true;
|
|
|
|
bool get _overview => widget.page == MornyeLibraryPage.overview;
|
|
bool get _artists => widget.page == MornyeLibraryPage.artists;
|
|
LibraryBrowseRequest get _request => (
|
|
artists: _artists,
|
|
artist: widget.artist,
|
|
search: _query,
|
|
sort: _sort,
|
|
limit: _limit,
|
|
);
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_sort = _overview ? 'latest' : 'a-z';
|
|
_scroll.addListener(() {
|
|
if (_scroll.position.extentAfter < 500 &&
|
|
!_loading &&
|
|
_rows.length >= _limit) {
|
|
setState(() => _limit += 40);
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_debounce?.cancel();
|
|
_scroll.dispose();
|
|
_search.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _searchChanged(String value) {
|
|
_debounce?.cancel();
|
|
_debounce = Timer(const Duration(milliseconds: 300), () {
|
|
if (!mounted || _query == value.trim()) return;
|
|
setState(() {
|
|
_query = value.trim();
|
|
_limit = 40;
|
|
_rows = const [];
|
|
});
|
|
});
|
|
}
|
|
|
|
void _openPage(MornyeLibraryPage page, {String? artist}) {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => Scaffold(
|
|
body: MornyeLibraryScreen(
|
|
page: page,
|
|
artist: artist,
|
|
onOpenSection: widget.onOpenSection,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _openAlbum(LibraryBrowseEntry entry) async {
|
|
if (entry.source != 'local') {
|
|
await Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => DownloadedAlbumScreen(
|
|
albumName: entry.name,
|
|
artistName: entry.artist,
|
|
coverUrl: entry.cover,
|
|
),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
final rows = await LibraryDatabase.instance.getQueueLocalAlbumTracksByKey(
|
|
entry.key,
|
|
);
|
|
if (!mounted) return;
|
|
await Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => LocalAlbumScreen(
|
|
albumName: entry.name,
|
|
artistName: entry.artist,
|
|
coverPath: entry.cover,
|
|
tracks: rows.map(LocalLibraryItem.fromJson).toList(growable: false),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _chooseSort() async {
|
|
final selected = await showAppBottomSheet<String>(
|
|
context: context,
|
|
builder: (context) => Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
for (final option in [
|
|
('a-z', context.l10n.searchSortTitleAZ),
|
|
('latest', context.l10n.libraryRecentlyAdded),
|
|
])
|
|
ListTile(
|
|
title: Text(option.$2),
|
|
trailing: _sort == option.$1 ? const Icon(Icons.check) : null,
|
|
onTap: () => Navigator.pop(context, option.$1),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (!mounted || selected == null || selected == _sort) return;
|
|
setState(() {
|
|
_sort = selected;
|
|
_limit = 40;
|
|
_rows = const [];
|
|
});
|
|
_scroll.jumpTo(0);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = Theme.of(context).colorScheme;
|
|
final result = ref.watch(libraryBrowseProvider(_request));
|
|
_loading = result.isLoading;
|
|
if (result.hasValue) _rows = result.requireValue;
|
|
final title =
|
|
widget.artist ??
|
|
(_overview
|
|
? context.l10n.navLibrary
|
|
: _artists
|
|
? context.l10n.searchArtists
|
|
: context.l10n.searchAlbums);
|
|
final actions = <Widget>[
|
|
IconButton(
|
|
tooltip: context.l10n.librarySearchHint,
|
|
icon: const Icon(Icons.search),
|
|
onPressed: () => setState(() => _showSearch = !_showSearch),
|
|
),
|
|
if (_overview)
|
|
Consumer(
|
|
builder: (context, ref, _) {
|
|
final count = ref.watch(
|
|
downloadQueueLookupProvider.select(
|
|
(s) => s.notCompletedItemIds.length,
|
|
),
|
|
);
|
|
return TextButton.icon(
|
|
onPressed: () => widget.onOpenSection('downloads'),
|
|
icon: Badge(
|
|
isLabelVisible: count > 0,
|
|
label: Text('$count'),
|
|
child: const Icon(Icons.downloading_outlined),
|
|
),
|
|
label: Text(context.l10n.libraryDownloads),
|
|
);
|
|
},
|
|
)
|
|
else if (!_artists)
|
|
IconButton(
|
|
tooltip: context.l10n.searchSortTitle,
|
|
icon: const Icon(Icons.sort),
|
|
onPressed: _chooseSort,
|
|
),
|
|
];
|
|
return RefreshIndicator(
|
|
onRefresh: () async {
|
|
ref.invalidate(libraryBrowseProvider(_request));
|
|
await ref.read(libraryBrowseProvider(_request).future);
|
|
},
|
|
child: CustomScrollView(
|
|
controller: _scroll,
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
slivers: [
|
|
if (_overview)
|
|
AppSliverHeader.tabRoot(title: title, actions: actions)
|
|
else
|
|
AppSliverHeader.page(title: title, actions: actions),
|
|
if (_showSearch)
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(24, 8, 24, 16),
|
|
child: AppSearchField(
|
|
controller: _search,
|
|
autofocus: true,
|
|
hintText: context.l10n.librarySearchHint,
|
|
clearTooltip: context.l10n.dialogClear,
|
|
onChanged: _searchChanged,
|
|
onClear: () {
|
|
_search.clear();
|
|
_searchChanged('');
|
|
},
|
|
),
|
|
),
|
|
),
|
|
if (_overview && _query.isEmpty)
|
|
SliverPadding(
|
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 20),
|
|
sliver: SliverList.list(
|
|
children: [
|
|
for (final category in [
|
|
(
|
|
Icons.queue_music,
|
|
context.l10n.searchPlaylists,
|
|
() => widget.onOpenSection('playlists'),
|
|
),
|
|
(
|
|
Icons.mic_none,
|
|
context.l10n.searchArtists,
|
|
() => _openPage(MornyeLibraryPage.artists),
|
|
),
|
|
(
|
|
Icons.album_outlined,
|
|
context.l10n.searchAlbums,
|
|
() => _openPage(MornyeLibraryPage.albums),
|
|
),
|
|
(
|
|
Icons.music_note_outlined,
|
|
context.l10n.searchSongs,
|
|
() => widget.onOpenSection('all'),
|
|
),
|
|
]) ...[
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
minVerticalPadding: 12,
|
|
leading: Icon(
|
|
category.$1,
|
|
color: colors.primary,
|
|
size: 28,
|
|
),
|
|
title: Text(
|
|
category.$2,
|
|
style: const TextStyle(fontSize: 22),
|
|
),
|
|
trailing: Icon(
|
|
Icons.chevron_right,
|
|
color: colors.onSurfaceVariant,
|
|
),
|
|
onTap: category.$3,
|
|
),
|
|
const Divider(height: 1, indent: 48),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
if (_overview)
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(24, 8, 24, 16),
|
|
child: Text(
|
|
_query.isEmpty
|
|
? context.l10n.libraryRecentlyAdded
|
|
: context.l10n.searchAlbums,
|
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (_rows.isNotEmpty)
|
|
if (_artists)
|
|
SliverList.builder(
|
|
itemCount: _rows.length,
|
|
itemBuilder: (context, index) {
|
|
final entry = _rows[index];
|
|
return Column(
|
|
children: [
|
|
ListTile(
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 24,
|
|
vertical: 8,
|
|
),
|
|
leading: ClipOval(
|
|
child: SizedBox.square(
|
|
dimension: 56,
|
|
child: _LibraryBrowseArtwork(entry: entry),
|
|
),
|
|
),
|
|
title: Text(entry.artist),
|
|
subtitle: Text(
|
|
context.l10n.queueTrackCount(entry.trackCount),
|
|
),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: () => _openPage(
|
|
MornyeLibraryPage.albums,
|
|
artist: entry.artist,
|
|
),
|
|
),
|
|
const Divider(height: 1, indent: 96, endIndent: 24),
|
|
],
|
|
);
|
|
},
|
|
)
|
|
else
|
|
SliverPadding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
sliver: SliverLayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final columns = (constraints.crossAxisExtent / 200)
|
|
.floor()
|
|
.clamp(2, 6);
|
|
final width =
|
|
(constraints.crossAxisExtent - 20 * (columns - 1)) /
|
|
columns;
|
|
final textHeight =
|
|
MediaQuery.textScalerOf(context).scale(16) * 3.2 + 24;
|
|
return SliverGrid.builder(
|
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: columns,
|
|
crossAxisSpacing: 20,
|
|
mainAxisSpacing: 16,
|
|
mainAxisExtent: width + textHeight,
|
|
),
|
|
itemCount: _rows.length,
|
|
itemBuilder: (context, index) {
|
|
final entry = _rows[index];
|
|
return Semantics(
|
|
button: true,
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: () => _openAlbum(entry),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
AspectRatio(
|
|
aspectRatio: 1,
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: _LibraryBrowseArtwork(entry: entry),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
entry.name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(fontSize: 16),
|
|
),
|
|
Text(
|
|
entry.artist,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: colors.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
if (result.hasError)
|
|
SliverToBoxAdapter(
|
|
child: Center(
|
|
child: TextButton.icon(
|
|
onPressed: () =>
|
|
ref.invalidate(libraryBrowseProvider(_request)),
|
|
icon: const Icon(Icons.refresh),
|
|
label: Text(context.l10n.dialogRetry),
|
|
),
|
|
),
|
|
)
|
|
else if (_loading)
|
|
const SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(24),
|
|
child: Center(child: CircularProgressIndicator.adaptive()),
|
|
),
|
|
)
|
|
else if (_rows.isEmpty)
|
|
SliverFillRemaining(
|
|
hasScrollBody: false,
|
|
child: Center(child: Text(context.l10n.libraryEmptyCollection)),
|
|
),
|
|
const NavBarSliverSpacer(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _LibraryBrowseArtwork extends StatefulWidget {
|
|
const _LibraryBrowseArtwork({required this.entry});
|
|
final LibraryBrowseEntry entry;
|
|
|
|
@override
|
|
State<_LibraryBrowseArtwork> createState() => _LibraryBrowseArtworkState();
|
|
}
|
|
|
|
class _LibraryBrowseArtworkState extends State<_LibraryBrowseArtwork> {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final entry = widget.entry;
|
|
final embedded = entry.cover?.isNotEmpty == true
|
|
? null
|
|
: DownloadedEmbeddedCoverResolver.resolve(
|
|
entry.samplePath,
|
|
onChanged: () {
|
|
if (mounted) setState(() {});
|
|
},
|
|
);
|
|
Widget fallback(BuildContext context) => ColoredBox(
|
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
child: Center(
|
|
child: Icon(
|
|
Icons.album_outlined,
|
|
size: 40,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
);
|
|
final cover = entry.cover?.isNotEmpty == true ? entry.cover! : embedded;
|
|
return cover == null
|
|
? fallback(context)
|
|
: LocalOrNetworkCoverImage(url: cover, placeholder: fallback);
|
|
}
|
|
}
|