mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 13:19:44 +02:00
478 lines
17 KiB
Dart
478 lines
17 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';
|
|
import 'package:spotiflac_android/widgets/library_search_results.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 searchingLibrary = _overview && _query.isNotEmpty;
|
|
final result = searchingLibrary
|
|
? const AsyncData<List<LibraryBrowseEntry>>([])
|
|
: 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 {
|
|
if (searchingLibrary) return;
|
|
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 (searchingLibrary)
|
|
LibrarySearchResults(
|
|
query: _query,
|
|
onOpenArtist: (artist) =>
|
|
_openPage(MornyeLibraryPage.albums, artist: artist),
|
|
),
|
|
if (!searchingLibrary) ...[
|
|
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);
|
|
}
|
|
}
|