mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 21:02:09 +02:00
feat(library): add Mornye browsing and contextual compact navigation
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.
This commit is contained in:
@@ -71,6 +71,11 @@
|
||||
"@historyFilterSingles": {
|
||||
"description": "Filter chip - show singles only"
|
||||
},
|
||||
"libraryRecentlyAdded": "Recently Added",
|
||||
"libraryDownloads": "Downloads",
|
||||
"libraryNoActiveDownloads": "No active downloads",
|
||||
"librarySearchHint": "Search your library",
|
||||
"libraryEmptyCollection": "Your music will appear here",
|
||||
"historySearchHint": "Search history...",
|
||||
"@historySearchHint": {
|
||||
"description": "Search bar placeholder in history"
|
||||
@@ -3372,6 +3377,10 @@
|
||||
"description": "Progress message while preparing manual batch metadata changes"
|
||||
},
|
||||
"trackReEnrichSuccess": "Metadata re-enriched successfully",
|
||||
"trackReEnrichMetadataSaved": "Metadata saved",
|
||||
"trackReEnrichLyricsUpdated": "Lyrics updated",
|
||||
"trackReEnrichLyricsPreserved": "No new lyrics found; existing lyrics kept",
|
||||
"trackReEnrichLyricsDisabled": "Lyrics not updated: lyric downloading is disabled in Settings",
|
||||
"@trackReEnrichSuccess": {
|
||||
"description": "Snackbar after successful re-enrichment"
|
||||
},
|
||||
|
||||
@@ -2069,6 +2069,11 @@
|
||||
"@filenameFormat": {
|
||||
"description": "Setting title - filename pattern"
|
||||
},
|
||||
"libraryRecentlyAdded": "Baru Ditambahkan",
|
||||
"libraryDownloads": "Unduhan",
|
||||
"libraryNoActiveDownloads": "Tidak ada unduhan aktif",
|
||||
"librarySearchHint": "Cari di koleksi musik",
|
||||
"libraryEmptyCollection": "Musikmu akan muncul di sini",
|
||||
"historySearchHint": "Cari riwayat...",
|
||||
"@historySearchHint": {
|
||||
"description": "Search bar placeholder in history"
|
||||
@@ -2345,6 +2350,10 @@
|
||||
"description": "Filter section title"
|
||||
},
|
||||
"trackReEnrichSuccess": "Metadata re-enriched successfully",
|
||||
"trackReEnrichMetadataSaved": "Metadata disimpan",
|
||||
"trackReEnrichLyricsUpdated": "Lirik diperbarui",
|
||||
"trackReEnrichLyricsPreserved": "Lirik baru tidak ditemukan; lirik lama dipertahankan",
|
||||
"trackReEnrichLyricsDisabled": "Lirik tidak diperbarui: unduhan lirik dinonaktifkan di Pengaturan",
|
||||
"@trackReEnrichSuccess": {
|
||||
"description": "Snackbar after successful re-enrichment"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/download_history_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
|
||||
typedef LibraryBrowseRequest = ({
|
||||
bool artists,
|
||||
String? artist,
|
||||
String search,
|
||||
String sort,
|
||||
int limit,
|
||||
});
|
||||
|
||||
class LibraryBrowseEntry {
|
||||
final String source;
|
||||
final String key;
|
||||
final String name;
|
||||
final String artist;
|
||||
final String? cover;
|
||||
final String samplePath;
|
||||
final int trackCount;
|
||||
|
||||
const LibraryBrowseEntry({
|
||||
required this.source,
|
||||
required this.key,
|
||||
required this.name,
|
||||
required this.artist,
|
||||
this.cover,
|
||||
required this.samplePath,
|
||||
required this.trackCount,
|
||||
});
|
||||
|
||||
factory LibraryBrowseEntry.fromRow(
|
||||
Map<String, dynamic> row, {
|
||||
required bool isArtist,
|
||||
}) {
|
||||
final artist = row['artist_name'] as String? ?? '';
|
||||
final localCover = row['cover_path'] as String?;
|
||||
return LibraryBrowseEntry(
|
||||
source: row['queue_source'] as String? ?? '',
|
||||
key: (row[isArtist ? 'artist_key' : 'album_key'] as String?) ?? '',
|
||||
name: isArtist ? artist : row['album_name'] as String? ?? '',
|
||||
artist: artist,
|
||||
cover: localCover?.isNotEmpty == true
|
||||
? localCover
|
||||
: row['cover_url'] as String?,
|
||||
samplePath: row['sample_file_path'] as String? ?? '',
|
||||
trackCount: (row['track_count'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final libraryBrowseProvider = FutureProvider.autoDispose
|
||||
.family<List<LibraryBrowseEntry>, LibraryBrowseRequest>((
|
||||
ref,
|
||||
request,
|
||||
) async {
|
||||
ref.watch(downloadHistoryProvider.select((s) => s.loadedIndexVersion));
|
||||
ref.watch(localLibraryProvider.select((s) => s.loadedIndexVersion));
|
||||
final includeLocal = ref.watch(
|
||||
settingsProvider.select((s) => s.localLibraryEnabled),
|
||||
);
|
||||
final query = QueueLibraryDbQuery(
|
||||
limit: request.limit,
|
||||
includeSingleTrackAlbums: true,
|
||||
albumArtist: request.artist,
|
||||
searchQuery: request.search,
|
||||
sortMode: request.sort,
|
||||
includeLocal: includeLocal,
|
||||
);
|
||||
final rows = request.artists
|
||||
? await LibraryDatabase.instance.getQueueArtistPage(query)
|
||||
: await LibraryDatabase.instance.getQueueAlbumPage(query);
|
||||
return rows
|
||||
.map(
|
||||
(row) => LibraryBrowseEntry.fromRow(row, isArtist: request.artists),
|
||||
)
|
||||
.toList(growable: false);
|
||||
});
|
||||
@@ -0,0 +1,462 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+101
-34
@@ -50,6 +50,7 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart';
|
||||
import 'package:spotiflac_android/screens/local_album_screen.dart';
|
||||
import 'package:spotiflac_android/screens/mornye_library_screen.dart';
|
||||
import 'package:spotiflac_android/screens/queue_library_refresh_policy.dart';
|
||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
@@ -207,12 +208,14 @@ DownloadHistoryItem? _historyItemForCompletionBridge(
|
||||
}
|
||||
|
||||
class QueueTab extends ConsumerStatefulWidget {
|
||||
final String? librarySection;
|
||||
final PageController? parentPageController;
|
||||
final int parentPageIndex;
|
||||
final int? nextPageIndex;
|
||||
|
||||
const QueueTab({
|
||||
super.key,
|
||||
this.librarySection,
|
||||
this.parentPageController,
|
||||
this.parentPageIndex = 1,
|
||||
this.nextPageIndex,
|
||||
@@ -349,13 +352,16 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
if (_isPageControllerInitialized) return;
|
||||
_isPageControllerInitialized = true;
|
||||
final settings = ref.read(settingsProvider);
|
||||
final initialFilter = settings.defaultLibraryView == 'last'
|
||||
? settings.historyFilterMode
|
||||
: settings.defaultLibraryView;
|
||||
final initialFilter =
|
||||
widget.librarySection ??
|
||||
(settings.defaultLibraryView == 'last'
|
||||
? settings.historyFilterMode
|
||||
: settings.defaultLibraryView);
|
||||
final initialPage = _filterModes
|
||||
.indexOf(initialFilter)
|
||||
.clamp(0, _filterModes.length - 1);
|
||||
if (settings.historyFilterMode != _filterModes[initialPage]) {
|
||||
if (widget.librarySection == null &&
|
||||
settings.historyFilterMode != _filterModes[initialPage]) {
|
||||
Future.microtask(() {
|
||||
if (!mounted) return;
|
||||
ref
|
||||
@@ -369,6 +375,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
/// When the shell switches back to this tab and a fixed default view is
|
||||
/// configured, jump the filter pager to it.
|
||||
void _applyDefaultLibraryViewOnTabVisible() {
|
||||
if (widget.librarySection != null) return;
|
||||
final isVisible = TickerMode.valuesOf(context).enabled;
|
||||
final becameVisible = isVisible && !_wasTabVisible;
|
||||
_wasTabVisible = isVisible;
|
||||
@@ -651,6 +658,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
|
||||
void _onFilterPageChanged(int index) {
|
||||
if (widget.librarySection != null) return;
|
||||
HapticFeedback.selectionClick();
|
||||
final filterMode = _filterModes[index];
|
||||
ref.read(settingsProvider.notifier).setHistoryFilterMode(filterMode);
|
||||
@@ -1220,6 +1228,15 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (context.isMornye && widget.librarySection == null) {
|
||||
return MornyeLibraryScreen(
|
||||
onOpenSection: (section) => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => Scaffold(body: QueueTab(librarySection: section)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
_initializePageController();
|
||||
_applyDefaultLibraryViewOnTabVisible();
|
||||
|
||||
@@ -1278,6 +1295,34 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
},
|
||||
);
|
||||
|
||||
if (widget.librarySection == 'downloads') {
|
||||
final ids = ref.watch(
|
||||
downloadQueueLookupProvider.select((s) => s.notCompletedItemIds),
|
||||
);
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
AppSliverHeader.page(title: context.l10n.libraryDownloads),
|
||||
_buildQueueHeaderSliver(context, colors),
|
||||
SliverList.builder(
|
||||
itemCount: ids.length,
|
||||
itemBuilder: (context, index) => _QueueItemSliverRow(
|
||||
key: ValueKey(ids[index]),
|
||||
itemId: ids[index],
|
||||
colorScheme: colors,
|
||||
itemBuilder: _buildQueueItem,
|
||||
),
|
||||
),
|
||||
if (ids.isEmpty)
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(child: Text(context.l10n.libraryNoActiveDownloads)),
|
||||
),
|
||||
const NavBarSliverSpacer(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final hasQueueItems = ref.watch(
|
||||
downloadQueueLookupProvider.select((lookup) => lookup.itemIds.isNotEmpty),
|
||||
);
|
||||
@@ -1314,9 +1359,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
final historyViewMode = ref.watch(
|
||||
settingsProvider.select((s) => s.historyViewMode),
|
||||
);
|
||||
final historyFilterMode = ref.watch(
|
||||
settingsProvider.select((s) => s.historyFilterMode),
|
||||
);
|
||||
final String historyFilterMode =
|
||||
widget.librarySection ??
|
||||
ref.watch<String>(settingsProvider.select((s) => s.historyFilterMode));
|
||||
// Keep this mode out of the page-provider request: changing only badge
|
||||
// text must not re-query the database or reset Library pagination.
|
||||
_libraryQualityLabelMode = ref.watch(
|
||||
@@ -1493,7 +1538,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
).copyWith(overscroll: false),
|
||||
child: NestedScrollView(
|
||||
headerSliverBuilder: (context, innerBoxIsScrolled) => [
|
||||
AppSliverHeader.tabRoot(title: context.l10n.navLibrary),
|
||||
if (widget.librarySection == null)
|
||||
AppSliverHeader.tabRoot(title: context.l10n.navLibrary)
|
||||
else
|
||||
AppSliverHeader.page(
|
||||
title: widget.librarySection == 'playlists'
|
||||
? context.l10n.searchPlaylists
|
||||
: context.l10n.searchSongs,
|
||||
),
|
||||
|
||||
if (shouldShowLibraryControls || hasQueueItems)
|
||||
SliverToBoxAdapter(
|
||||
@@ -1504,7 +1556,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
child: AppSearchField(
|
||||
controller: _searchController,
|
||||
focusNode: _searchFocusNode,
|
||||
hintText: context.l10n.historySearchHint,
|
||||
hintText: widget.librarySection == null
|
||||
? context.l10n.historySearchHint
|
||||
: context.l10n.librarySearchHint,
|
||||
clearTooltip: context.l10n.dialogClear,
|
||||
onChanged: _onSearchChanged,
|
||||
onClear: () {
|
||||
@@ -1516,7 +1570,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
),
|
||||
|
||||
if (shouldShowLibraryControls)
|
||||
if (shouldShowLibraryControls && widget.librarySection == null)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
@@ -1585,30 +1639,43 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
),
|
||||
],
|
||||
body: PageView.builder(
|
||||
controller: _filterPageController!,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
onPageChanged: _onFilterPageChanged,
|
||||
itemCount: _filterModes.length,
|
||||
itemBuilder: (context, index) {
|
||||
final filterMode = _filterModes[index];
|
||||
final filterData = getFilterData(filterMode);
|
||||
return _buildFilterContent(
|
||||
context: context,
|
||||
colorScheme: colorScheme,
|
||||
filterMode: filterMode,
|
||||
historyViewMode: historyViewMode,
|
||||
hasQueueItems: hasQueueItems,
|
||||
filterData: filterData,
|
||||
collectionState: collectionState,
|
||||
hasMoreLibrary: filterMode == historyFilterMode
|
||||
? hasMoreLibrary
|
||||
: false,
|
||||
isPageLoading: isLibraryPageLoading,
|
||||
inMemoryHistoryItems: inMemoryHistoryItems,
|
||||
);
|
||||
},
|
||||
),
|
||||
body: widget.librarySection != null
|
||||
? _buildFilterContent(
|
||||
context: context,
|
||||
colorScheme: colorScheme,
|
||||
filterMode: historyFilterMode,
|
||||
historyViewMode: 'list',
|
||||
hasQueueItems: false,
|
||||
filterData: getFilterData(historyFilterMode),
|
||||
collectionState: collectionState,
|
||||
hasMoreLibrary: hasMoreLibrary,
|
||||
isPageLoading: isLibraryPageLoading,
|
||||
inMemoryHistoryItems: inMemoryHistoryItems,
|
||||
)
|
||||
: PageView.builder(
|
||||
controller: _filterPageController!,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
onPageChanged: _onFilterPageChanged,
|
||||
itemCount: _filterModes.length,
|
||||
itemBuilder: (context, index) {
|
||||
final filterMode = _filterModes[index];
|
||||
final filterData = getFilterData(filterMode);
|
||||
return _buildFilterContent(
|
||||
context: context,
|
||||
colorScheme: colorScheme,
|
||||
filterMode: filterMode,
|
||||
historyViewMode: historyViewMode,
|
||||
hasQueueItems: hasQueueItems,
|
||||
filterData: filterData,
|
||||
collectionState: collectionState,
|
||||
hasMoreLibrary: filterMode == historyFilterMode
|
||||
? hasMoreLibrary
|
||||
: false,
|
||||
isPageLoading: isLibraryPageLoading,
|
||||
inMemoryHistoryItems: inMemoryHistoryItems,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
), // ScrollConfiguration
|
||||
],
|
||||
|
||||
@@ -52,7 +52,7 @@ extension _QueueTabFilterWidgets on _QueueTabState {
|
||||
final totalTrackCount = filterData.totalTrackCount;
|
||||
final totalAlbumCount = filterData.totalAlbumCount;
|
||||
|
||||
final activeDownloadIds = filterMode == 'albums'
|
||||
final activeDownloadIds = !hasQueueItems || filterMode == 'albums'
|
||||
? const <String>[]
|
||||
: ref
|
||||
.watch(
|
||||
@@ -74,7 +74,9 @@ extension _QueueTabFilterWidgets on _QueueTabState {
|
||||
),
|
||||
};
|
||||
List<String> bridgeIds = const [];
|
||||
if (filterMode != 'albums' && _completionBridge.isNotEmpty) {
|
||||
if (hasQueueItems &&
|
||||
filterMode != 'albums' &&
|
||||
_completionBridge.isNotEmpty) {
|
||||
final now = DateTime.now();
|
||||
final stale = <String>[];
|
||||
final pending = <String>[];
|
||||
@@ -394,7 +396,8 @@ extension _QueueTabFilterWidgets on _QueueTabState {
|
||||
),
|
||||
),
|
||||
|
||||
if (filterMode == 'all') _buildQueueHeaderSliver(context, colorScheme),
|
||||
if (filterMode == 'all' && hasQueueItems)
|
||||
_buildQueueHeaderSliver(context, colorScheme),
|
||||
|
||||
if (filterMode == 'albums' &&
|
||||
(filteredGroupedAlbums.isNotEmpty ||
|
||||
|
||||
@@ -1067,9 +1067,12 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
|
||||
(result['lyrics'] ?? result['lyrics_lrc'])?.toString() ?? '',
|
||||
),
|
||||
);
|
||||
await _checkEmbeddedLyrics();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.trackReEnrichSuccess)),
|
||||
SnackBar(
|
||||
content: Text(reEnrichCompletionMessage(context.l10n, result)),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (method == 'ffmpeg') {
|
||||
@@ -1168,9 +1171,14 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
|
||||
(result['lyrics'] ?? result['lyrics_lrc'])?.toString() ?? '',
|
||||
),
|
||||
);
|
||||
await _checkEmbeddedLyrics();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.trackReEnrichSuccess)),
|
||||
SnackBar(
|
||||
content: Text(
|
||||
reEnrichCompletionMessage(context.l10n, result),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (mounted) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import 'package:path/path.dart' as p;
|
||||
import 'package:spotiflac_android/services/conversion_library_service.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/re_enrich_result.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
@@ -1604,6 +1604,55 @@ class LibraryDatabase {
|
||||
);
|
||||
}
|
||||
|
||||
/// Album artists across downloaded and scanned music, without loading tracks
|
||||
/// into Dart. Scanned paths already represented by downloads are excluded.
|
||||
Future<List<Map<String, dynamic>>> getQueueArtistPage(
|
||||
QueueLibraryDbQuery request,
|
||||
) async {
|
||||
final db = await database;
|
||||
await _ensureHistoryAttached(db);
|
||||
final parts = <String>[
|
||||
'''
|
||||
SELECT sort_album_artist AS artist_key,
|
||||
COALESCE(NULLIF(album_artist, ''), artist_name) AS artist_name,
|
||||
cover_url, NULL AS cover_path, file_path AS sample_file_path
|
||||
FROM history_db.history
|
||||
''',
|
||||
if (request.includeLocal)
|
||||
'''
|
||||
SELECT album_artist_norm AS artist_key,
|
||||
COALESCE(NULLIF(album_artist, ''), artist_name) AS artist_name,
|
||||
NULL AS cover_url, cover_path, file_path AS sample_file_path
|
||||
FROM $visibleLibraryView l
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM library_path_keys lpk
|
||||
JOIN history_db.history_path_keys hpk ON hpk.path_key = lpk.path_key
|
||||
WHERE lpk.item_id = l.id
|
||||
)
|
||||
''',
|
||||
];
|
||||
final search = normalizeLookupText(request.searchQuery);
|
||||
return db.rawQuery(
|
||||
'''
|
||||
SELECT artist_key, MIN(artist_name) AS artist_name,
|
||||
MAX(NULLIF(cover_url, '')) AS cover_url,
|
||||
MAX(NULLIF(cover_path, '')) AS cover_path,
|
||||
MAX(sample_file_path) AS sample_file_path,
|
||||
COUNT(*) AS track_count
|
||||
FROM (${parts.join(' UNION ALL ')})
|
||||
WHERE artist_key != '' ${search.isEmpty ? '' : "AND artist_key LIKE ? ESCAPE '\\'"}
|
||||
GROUP BY artist_key
|
||||
ORDER BY artist_key
|
||||
LIMIT ? OFFSET ?
|
||||
''',
|
||||
[
|
||||
if (search.isNotEmpty) '%${_escapeLikePattern(search)}%',
|
||||
request.limit,
|
||||
request.offset,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getQueueLocalAlbumTracks(
|
||||
String albumName,
|
||||
String artistName,
|
||||
|
||||
@@ -298,6 +298,8 @@ class IsrcDuplicateGroup {
|
||||
}
|
||||
|
||||
class QueueLibraryDbQuery {
|
||||
final bool includeSingleTrackAlbums;
|
||||
final String? albumArtist;
|
||||
final int limit;
|
||||
final int offset;
|
||||
final String filterMode;
|
||||
@@ -311,6 +313,8 @@ class QueueLibraryDbQuery {
|
||||
final QueueLibraryDbCursor? cursor;
|
||||
|
||||
const QueueLibraryDbQuery({
|
||||
this.includeSingleTrackAlbums = false,
|
||||
this.albumArtist,
|
||||
this.limit = 100,
|
||||
this.offset = 0,
|
||||
this.filterMode = 'all',
|
||||
|
||||
@@ -266,7 +266,7 @@ extension _LibraryDbQueueSql on LibraryDatabase {
|
||||
MAX(COALESCE(sort_added, 0)) AS latest_added
|
||||
FROM history_db.history
|
||||
GROUP BY album_key
|
||||
HAVING COUNT(*) > 1
|
||||
HAVING COUNT(*) > ${request.includeSingleTrackAlbums ? 0 : 1}
|
||||
) c
|
||||
ON c.album_key = h.album_key
|
||||
${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'}
|
||||
@@ -325,7 +325,7 @@ extension _LibraryDbQueueSql on LibraryDatabase {
|
||||
WHERE lpk.item_id = candidate.id
|
||||
)
|
||||
GROUP BY album_key
|
||||
HAVING COUNT(*) > 1
|
||||
HAVING COUNT(*) > ${request.includeSingleTrackAlbums ? 0 : 1}
|
||||
) c ON c.album_key = l.album_key
|
||||
${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'}
|
||||
GROUP BY c.album_key
|
||||
@@ -368,6 +368,10 @@ extension _LibraryDbQueueSql on LibraryDatabase {
|
||||
List<Object?> args,
|
||||
QueueLibraryDbQuery request,
|
||||
) {
|
||||
if (request.albumArtist != null) {
|
||||
where.add('h.sort_album_artist = ?');
|
||||
args.add(LibraryDatabase.normalizeLookupText(request.albumArtist));
|
||||
}
|
||||
final query = LibraryDatabase.normalizeLookupText(request.searchQuery);
|
||||
if (query.isNotEmpty) {
|
||||
final ftsQuery = sqlite.ftsPhraseSearchQuery(query);
|
||||
@@ -412,6 +416,10 @@ extension _LibraryDbQueueSql on LibraryDatabase {
|
||||
List<Object?> args,
|
||||
QueueLibraryDbQuery request,
|
||||
) {
|
||||
if (request.albumArtist != null) {
|
||||
where.add('l.album_artist_norm = ?');
|
||||
args.add(LibraryDatabase.normalizeLookupText(request.albumArtist));
|
||||
}
|
||||
final query = LibraryDatabase.normalizeLookupText(request.searchQuery);
|
||||
if (query.isNotEmpty) {
|
||||
final ftsQuery = sqlite.ftsPhraseSearchQuery(query);
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/re_enrich_result.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
@@ -228,8 +229,10 @@ Future<void> reEnrichLocalTracks(
|
||||
},
|
||||
);
|
||||
|
||||
final lyricsSummary = ReEnrichLyricsSummary();
|
||||
final successCount = await runner.apply(
|
||||
previews,
|
||||
onResult: lyricsSummary.add,
|
||||
shouldStop: () => cancelled || !context.mounted || !isActive(),
|
||||
onProgress: (index, item) => BatchProgressDialog.update(
|
||||
current: index + 1,
|
||||
@@ -254,14 +257,7 @@ Future<void> reEnrichLocalTracks(
|
||||
onComplete();
|
||||
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
final failedCount = total - successCount;
|
||||
final summary = failedCount <= 0
|
||||
? '${context.l10n.trackReEnrichSuccess} ($successCount/$total)'
|
||||
: context.l10n.trackReEnrichSuccessWithFailures(
|
||||
successCount,
|
||||
total,
|
||||
failedCount,
|
||||
);
|
||||
final summary = lyricsSummary.message(context.l10n, successCount, total);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(summary)));
|
||||
}
|
||||
|
||||
@@ -345,6 +341,7 @@ class BatchReEnrichRunner {
|
||||
List<BatchReEnrichPreview> previews, {
|
||||
required bool Function() shouldStop,
|
||||
required void Function(int, LocalLibraryItem) onProgress,
|
||||
void Function(Map<String, dynamic>)? onResult,
|
||||
}) async {
|
||||
if (shouldStop()) return 0;
|
||||
// Settings may have changed while the review sheet was open.
|
||||
@@ -370,6 +367,7 @@ class BatchReEnrichRunner {
|
||||
reEnrichResult: result,
|
||||
);
|
||||
successes++;
|
||||
onResult?.call(result);
|
||||
case 'ffmpeg':
|
||||
if (await applyFfmpeg(
|
||||
item: preview.item,
|
||||
@@ -377,6 +375,7 @@ class BatchReEnrichRunner {
|
||||
artistTagMode: settings.artistTagMode,
|
||||
)) {
|
||||
successes++;
|
||||
onResult?.call(result);
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
|
||||
String? reEnrichLyricsMessage(AppLocalizations l10n, String? status) =>
|
||||
switch (status) {
|
||||
'updated' => l10n.trackReEnrichLyricsUpdated,
|
||||
'instrumental' => l10n.trackInstrumental,
|
||||
'not_found' => l10n.trackLyricsNotAvailable,
|
||||
'preserved' => l10n.trackReEnrichLyricsPreserved,
|
||||
'disabled' => l10n.trackReEnrichLyricsDisabled,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
String reEnrichCompletionMessage(
|
||||
AppLocalizations l10n,
|
||||
Map<String, dynamic> result,
|
||||
) {
|
||||
final lyrics = reEnrichLyricsMessage(
|
||||
l10n,
|
||||
result['lyrics_status'] as String?,
|
||||
);
|
||||
return lyrics == null
|
||||
? l10n.trackReEnrichSuccess
|
||||
: '${l10n.trackReEnrichMetadataSaved}\n$lyrics';
|
||||
}
|
||||
|
||||
/// Count lyric outcomes only after the metadata and sidecar writes finish.
|
||||
/// Metadata saved is independent of whether new lyrics could be found.
|
||||
class ReEnrichLyricsSummary {
|
||||
final _counts = <String, int>{};
|
||||
|
||||
void add(Map<String, dynamic> result) {
|
||||
final status = result['lyrics_status'] as String?;
|
||||
if (status == null || status == 'not_requested') return;
|
||||
_counts.update(status, (count) => count + 1, ifAbsent: () => 1);
|
||||
}
|
||||
|
||||
String message(AppLocalizations l10n, int saved, int total) {
|
||||
final failed = total - saved;
|
||||
final lines = [
|
||||
failed > 0
|
||||
? l10n.trackReEnrichSuccessWithFailures(saved, total, failed)
|
||||
: _counts.isEmpty
|
||||
? '${l10n.trackReEnrichSuccess} ($saved/$total)'
|
||||
: '${l10n.trackReEnrichMetadataSaved} ($saved/$total)',
|
||||
for (final entry in _counts.entries)
|
||||
if (reEnrichLyricsMessage(l10n, entry.key) case final label?)
|
||||
'$label (${entry.value})',
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
}
|
||||
@@ -98,10 +98,11 @@ class MornyeBottomBar extends ConsumerWidget {
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
);
|
||||
final homeSurface = sideSurface();
|
||||
final leadingSurface = sideSurface();
|
||||
final searchSurface = sideSurface(radius: 32);
|
||||
final mainDestinations = destinations.sublist(0, destinations.length - 1);
|
||||
final searchSelected = selectedIndex == destinations.length - 1;
|
||||
final leadingIndex = searchSelected ? 0 : selectedIndex;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final inactiveIconColor = Color.lerp(
|
||||
scheme.onSurfaceVariant,
|
||||
@@ -109,7 +110,7 @@ class MornyeBottomBar extends ConsumerWidget {
|
||||
scheme.brightness == Brightness.dark ? 0.5 : 0.4,
|
||||
);
|
||||
final player = MiniPlayer(compact: collapsed, bottomPadding: 0);
|
||||
Widget tabs({required bool hideHomeIcon}) => TickerMode(
|
||||
Widget tabs({required bool hideLeadingIcon}) => TickerMode(
|
||||
enabled: !collapsed,
|
||||
child: RepaintBoundary(
|
||||
child: MornyeTabBar(
|
||||
@@ -117,15 +118,15 @@ class MornyeBottomBar extends ConsumerWidget {
|
||||
selectedIndex: searchSelected ? -1 : selectedIndex,
|
||||
onSelected: onSelected,
|
||||
blurEnabled: blurEnabled,
|
||||
hideHomeIcon: hideHomeIcon,
|
||||
hiddenIconIndex: hideLeadingIcon ? leadingIndex : null,
|
||||
),
|
||||
),
|
||||
);
|
||||
// At rest the glass bar must paint its own Home icon: its selected layer
|
||||
// At rest the glass bar must paint its own active icon: its selected layer
|
||||
// follows a dragged pill before the destination is committed. Hand them
|
||||
// to the moving overlays only while folding, at the same coordinates.
|
||||
final fullTabs = tabs(hideHomeIcon: false);
|
||||
final foldingTabs = tabs(hideHomeIcon: true);
|
||||
final fullTabs = tabs(hideLeadingIcon: false);
|
||||
final foldingTabs = tabs(hideLeadingIcon: true);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Match the tab's actual label height, including accessibility scaling.
|
||||
@@ -149,8 +150,8 @@ class MornyeBottomBar extends ConsumerWidget {
|
||||
final fullIconStart =
|
||||
tabInset +
|
||||
(constraints.maxWidth - 76 - tabInset * 2) /
|
||||
mainDestinations.length /
|
||||
2;
|
||||
mainDestinations.length *
|
||||
(leadingIndex + 0.5);
|
||||
final fullSearchBottom = glassTabs
|
||||
? 8.0
|
||||
: (math.max(64.0, 49 + labelHeight) - 64) / 2;
|
||||
@@ -161,28 +162,31 @@ class MornyeBottomBar extends ConsumerWidget {
|
||||
: const Duration(milliseconds: 380),
|
||||
curve: Curves.easeInOutCubic,
|
||||
builder: (context, amount, _) {
|
||||
Widget movingIcon({required bool home, required Widget surface}) {
|
||||
final index = home ? 0 : destinations.length - 1;
|
||||
Widget movingIcon({
|
||||
required bool leading,
|
||||
required Widget surface,
|
||||
}) {
|
||||
final index = leading ? leadingIndex : destinations.length - 1;
|
||||
final offset = (fullIconStart - 26) * (1 - amount);
|
||||
final size = home ? 52.0 : 64 - 12 * amount;
|
||||
final size = leading ? 52.0 : 64 - 12 * amount;
|
||||
return PositionedDirectional(
|
||||
start: home ? offset : null,
|
||||
end: home ? null : 0,
|
||||
bottom: home
|
||||
start: leading ? offset : null,
|
||||
end: leading ? null : 0,
|
||||
bottom: leading
|
||||
? fullIconBottom * (1 - amount) + 34 * amount - 26
|
||||
: fullSearchBottom * (1 - amount) + 8 * amount,
|
||||
width: size,
|
||||
height: size,
|
||||
child: IgnorePointer(
|
||||
ignoring: home && amount < 0.5,
|
||||
ignoring: leading && amount < 0.5,
|
||||
child: ExcludeSemantics(
|
||||
excluding: home && amount < 0.5,
|
||||
excluding: leading && amount < 0.5,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Opacity(
|
||||
opacity: home ? amount : 1,
|
||||
opacity: leading ? amount : 1,
|
||||
child: surface,
|
||||
),
|
||||
),
|
||||
@@ -190,12 +194,12 @@ class MornyeBottomBar extends ConsumerWidget {
|
||||
color: Colors.transparent,
|
||||
child: IconButton(
|
||||
key: ValueKey(
|
||||
home
|
||||
? 'mornye-compact-home'
|
||||
leading
|
||||
? 'mornye-compact-leading'
|
||||
: 'mornye-compact-search',
|
||||
),
|
||||
tooltip: home
|
||||
? context.l10n.navHome
|
||||
tooltip: leading
|
||||
? destinations[leadingIndex].label
|
||||
: context.l10n.mornyeSearch,
|
||||
iconSize: 25,
|
||||
constraints: BoxConstraints.tightFor(
|
||||
@@ -210,10 +214,14 @@ class MornyeBottomBar extends ConsumerWidget {
|
||||
amount,
|
||||
),
|
||||
icon: Opacity(
|
||||
opacity: home && amount == 0 ? 0 : 1,
|
||||
opacity: leading && amount == 0 ? 0 : 1,
|
||||
child: destinations[index].icon,
|
||||
),
|
||||
onPressed: home ? onHome : onSearch,
|
||||
onPressed: leading
|
||||
? leadingIndex == 0
|
||||
? onHome
|
||||
: () => onSelected(leadingIndex)
|
||||
: onSearch,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -279,8 +287,8 @@ class MornyeBottomBar extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
movingIcon(home: true, surface: homeSurface),
|
||||
movingIcon(home: false, surface: searchSurface),
|
||||
movingIcon(leading: true, surface: leadingSurface),
|
||||
movingIcon(leading: false, surface: searchSurface),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -437,15 +437,15 @@ class MornyeTabBar extends StatelessWidget {
|
||||
required this.selectedIndex,
|
||||
required this.onSelected,
|
||||
required this.blurEnabled,
|
||||
this.hideHomeIcon = false,
|
||||
this.hiddenIconIndex,
|
||||
});
|
||||
|
||||
final List<NavigationDestination> destinations;
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onSelected;
|
||||
final bool blurEnabled;
|
||||
// Home moves independently while the full tab capsule folds away.
|
||||
final bool hideHomeIcon;
|
||||
// The active icon moves independently while the full capsule folds away.
|
||||
final int? hiddenIconIndex;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -535,7 +535,7 @@ class MornyeTabBar extends StatelessWidget {
|
||||
: inactiveIconColor,
|
||||
),
|
||||
child: Opacity(
|
||||
opacity: hideHomeIcon && index == 0 ? 0 : 1,
|
||||
opacity: hiddenIconIndex == index ? 0 : 1,
|
||||
child: destination.icon,
|
||||
),
|
||||
),
|
||||
@@ -635,7 +635,7 @@ class MornyeTabBar extends StatelessWidget {
|
||||
// Keep tab selection quiet, as in Mornye. Badges
|
||||
// stay live without the Material bounce/spin.
|
||||
child: Opacity(
|
||||
opacity: hideHomeIcon && index == 0 ? 0 : 1,
|
||||
opacity: hiddenIconIndex == index ? 0 : 1,
|
||||
child: destinations[index].icon,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -23,6 +23,21 @@ class ProfileAvatar extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// ListTile can constrain height more than width. Keep the clipped
|
||||
// image square even when the surrounding slot is rectangular.
|
||||
final diameter = constraints.biggest.shortestSide.clamp(0.0, size);
|
||||
return Align(
|
||||
widthFactor: 1,
|
||||
heightFactor: 1,
|
||||
child: _buildAvatar(context, diameter),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatar(BuildContext context, double diameter) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final fallback = ColoredBox(
|
||||
color: scheme.primaryContainer,
|
||||
@@ -30,13 +45,13 @@ class ProfileAvatar extends StatelessWidget {
|
||||
child: name.trim().isEmpty
|
||||
? Icon(
|
||||
Icons.person,
|
||||
size: size * 0.55,
|
||||
size: diameter * 0.55,
|
||||
color: scheme.onPrimaryContainer,
|
||||
)
|
||||
: Text(
|
||||
name.trim().characters.first.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: size * 0.42,
|
||||
fontSize: diameter * 0.42,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: scheme.onPrimaryContainer,
|
||||
),
|
||||
@@ -46,7 +61,7 @@ class ProfileAvatar extends StatelessWidget {
|
||||
return ExcludeSemantics(
|
||||
child: ClipOval(
|
||||
child: SizedBox.square(
|
||||
dimension: size,
|
||||
dimension: diameter,
|
||||
child: photo != null
|
||||
? Image.memory(
|
||||
photo!,
|
||||
@@ -57,8 +72,9 @@ class ProfileAvatar extends StatelessWidget {
|
||||
? Image.file(
|
||||
File(photoPath!),
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: (size * MediaQuery.devicePixelRatioOf(context))
|
||||
.ceil(),
|
||||
cacheWidth:
|
||||
(diameter * MediaQuery.devicePixelRatioOf(context))
|
||||
.ceil(),
|
||||
errorBuilder: (_, _, _) => fallback,
|
||||
)
|
||||
: fallback,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use super::*;
|
||||
use crate::environment::ExtensionEnvironment;
|
||||
use crate::{RuntimeLimits, backend::MetadataOptions};
|
||||
use crate::{
|
||||
RuntimeLimits,
|
||||
backend::{LyricsRequest, MetadataOptions},
|
||||
};
|
||||
use spotiflac_network::{NetworkOptions, NetworkService};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
@@ -494,19 +497,36 @@ fn catalog_search_gates_required_tracks_then_overlaps_optional_categories() {
|
||||
|
||||
#[test]
|
||||
fn reenrich_cover_and_lyrics_overlap_preserve_best_effort_results_and_cancel() {
|
||||
for mode in ["success", "cover_failure", "lyrics_failure", "cancel"] {
|
||||
for mode in [
|
||||
"success",
|
||||
"cover_failure",
|
||||
"lyrics_failure",
|
||||
"missing",
|
||||
"instrumental",
|
||||
"instrumental_native",
|
||||
"instrumental_with_lyrics",
|
||||
"cancel",
|
||||
] {
|
||||
let (network, config) = network();
|
||||
let mut server = GatedServer::new(
|
||||
config,
|
||||
&[
|
||||
(
|
||||
"/cover",
|
||||
if mode == "cover_failure" { 404 } else { 200 },
|
||||
if matches!(mode, "cover_failure" | "instrumental_native") {
|
||||
404
|
||||
} else {
|
||||
200
|
||||
},
|
||||
"artwork",
|
||||
),
|
||||
(
|
||||
"/lyrics",
|
||||
if mode == "lyrics_failure" { 404 } else { 200 },
|
||||
if matches!(mode, "lyrics_failure" | "missing") {
|
||||
404
|
||||
} else {
|
||||
200
|
||||
},
|
||||
"New lyrics",
|
||||
),
|
||||
],
|
||||
@@ -524,8 +544,13 @@ fn reenrich_cover_and_lyrics_overlap_preserve_best_effort_results_and_cancel() {
|
||||
"description":"Generic lyrics fixture","type":["lyrics_provider"],"permissions":{"network":["127.0.0.1"]}});
|
||||
let source = format!(
|
||||
r#"registerExtension({{fetchLyrics(){{const response=http.get({});
|
||||
return response.statusCode===200 ? {{plainLyrics:response.body}} : {{}};}}}});"#,
|
||||
json!(format!("{}/lyrics", server.url))
|
||||
return response.statusCode===200 ? {} : {{}};}}}});"#,
|
||||
json!(format!("{}/lyrics", server.url)),
|
||||
if mode.starts_with("instrumental") {
|
||||
"{instrumental:true}"
|
||||
} else {
|
||||
"{plainLyrics:response.body}"
|
||||
},
|
||||
);
|
||||
for (name, body) in [
|
||||
("manifest.json", manifest.to_string()),
|
||||
@@ -542,11 +567,24 @@ fn reenrich_cover_and_lyrics_overlap_preserve_best_effort_results_and_cancel() {
|
||||
backend
|
||||
.set_lyrics_providers_json(r#"["extension:example.lyrics"]"#)
|
||||
.unwrap();
|
||||
let audio = directory.path().join("data/track.mp3");
|
||||
let native = mode == "instrumental_native";
|
||||
let file_name = if native { "track.flac" } else { "track.mp3" };
|
||||
let audio = directory.path().join("data").join(file_name);
|
||||
let sidecar = directory.path().join("data/track.lrc");
|
||||
std::fs::write(&audio, "original audio").unwrap();
|
||||
std::fs::write(&sidecar, "[00:01.00]Old lyrics").unwrap();
|
||||
let request = json!({"file_path":"track.mp3","track_name":"Track","artist_name":"Artist",
|
||||
if native {
|
||||
let mut flac = b"fLaC\x80\x00\x00\x22".to_vec();
|
||||
flac.extend_from_slice(&[0; 34]);
|
||||
flac.extend_from_slice(&[0xff, 0xf8]);
|
||||
flac.extend_from_slice(&[0; 32]);
|
||||
std::fs::write(&audio, flac).unwrap();
|
||||
} else {
|
||||
std::fs::write(&audio, "original audio").unwrap();
|
||||
}
|
||||
let has_existing = !matches!(mode, "missing" | "instrumental" | "instrumental_native");
|
||||
if has_existing {
|
||||
std::fs::write(&sidecar, "[00:01.00]Old lyrics").unwrap();
|
||||
}
|
||||
let request = json!({"file_path":file_name,"track_name":"Track","artist_name":"Artist",
|
||||
"cover_url":format!("{}/cover", server.url),"embed_lyrics":true,"lyrics_mode":"both",
|
||||
"update_fields":["cover","lyrics"]})
|
||||
.to_string();
|
||||
@@ -588,25 +626,54 @@ fn reenrich_cover_and_lyrics_overlap_preserve_best_effort_results_and_cancel() {
|
||||
second.1.send(()).unwrap();
|
||||
} else {
|
||||
let result: Value = serde_json::from_str(&result.unwrap()).unwrap();
|
||||
assert_eq!(result["method"], "ffmpeg");
|
||||
assert_eq!(result["write_external_lrc"], true);
|
||||
assert_eq!(result["method"], if native { "native" } else { "ffmpeg" });
|
||||
assert_eq!(result["write_external_lrc"], mode != "missing");
|
||||
let lyrics = result["lyrics"].as_str().unwrap();
|
||||
assert!(lyrics.contains(if mode == "lyrics_failure" {
|
||||
"Old lyrics"
|
||||
let (status, expected) = match mode {
|
||||
"lyrics_failure" | "instrumental_with_lyrics" => ("preserved", "Old lyrics"),
|
||||
"missing" => ("not_found", ""),
|
||||
"instrumental" | "instrumental_native" => ("instrumental", "[instrumental:true]"),
|
||||
_ => ("updated", "New lyrics"),
|
||||
};
|
||||
assert_eq!(result["lyrics_status"], status);
|
||||
if mode == "missing" {
|
||||
assert!(lyrics.is_empty());
|
||||
assert!(result["metadata"].get("LYRICS").is_none());
|
||||
} else {
|
||||
"New lyrics"
|
||||
}));
|
||||
assert_eq!(result["metadata"]["LYRICS"], lyrics);
|
||||
let cover = result["cover_path"].as_str().unwrap();
|
||||
if mode == "cover_failure" {
|
||||
assert!(cover.is_empty());
|
||||
assert!(lyrics.contains(expected));
|
||||
if !native {
|
||||
assert_eq!(result["metadata"]["LYRICS"], lyrics);
|
||||
}
|
||||
}
|
||||
if !native {
|
||||
let cover = result["cover_path"].as_str().unwrap();
|
||||
if mode == "cover_failure" {
|
||||
assert!(cover.is_empty());
|
||||
} else {
|
||||
assert_eq!(std::fs::read(cover).unwrap(), b"artwork");
|
||||
std::fs::remove_file(cover).unwrap();
|
||||
}
|
||||
} else {
|
||||
assert_eq!(std::fs::read(cover).unwrap(), b"artwork");
|
||||
std::fs::remove_file(cover).unwrap();
|
||||
let embedded = backend
|
||||
.get_lyrics_lrc(
|
||||
&LyricsRequest {
|
||||
file_path: file_name.into(),
|
||||
..Default::default()
|
||||
},
|
||||
&|| Ok(()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(embedded, "[instrumental:true]");
|
||||
}
|
||||
}
|
||||
assert_eq!(std::fs::read(&audio).unwrap(), b"original audio");
|
||||
assert_eq!(std::fs::read(&sidecar).unwrap(), b"[00:01.00]Old lyrics");
|
||||
if !native {
|
||||
assert_eq!(std::fs::read(&audio).unwrap(), b"original audio");
|
||||
}
|
||||
if has_existing {
|
||||
assert_eq!(std::fs::read(&sidecar).unwrap(), b"[00:01.00]Old lyrics");
|
||||
} else {
|
||||
assert!(!sidecar.exists());
|
||||
}
|
||||
assert!(
|
||||
std::fs::read_dir(directory.path().join("data"))
|
||||
.unwrap()
|
||||
|
||||
@@ -67,10 +67,11 @@ impl Backend {
|
||||
None
|
||||
}
|
||||
};
|
||||
let fetch_lyrics = || -> Result<String, String> {
|
||||
let fetch_lyrics = || -> Result<(String, &str), String> {
|
||||
// Fetches and existing lyrics are best-effort; cancellation is not.
|
||||
check()?;
|
||||
let mut lyrics = String::new();
|
||||
let mut status = "not_requested";
|
||||
if request.selected("lyrics", "lyrics") {
|
||||
if let Ok(existing) = file::extract(
|
||||
&request.file_path,
|
||||
@@ -81,11 +82,17 @@ impl Backend {
|
||||
.map_err(|error| error.to_string())
|
||||
},
|
||||
&check,
|
||||
) {
|
||||
) && lrc::has_usable_content(&existing)
|
||||
{
|
||||
lyrics = existing;
|
||||
}
|
||||
check()?;
|
||||
if request.embed_lyrics {
|
||||
status = if lyrics.is_empty() {
|
||||
"not_found"
|
||||
} else {
|
||||
"preserved"
|
||||
};
|
||||
let query = LyricsRequest {
|
||||
spotify_id: request.spotify_id.clone(),
|
||||
track: request.track_name.clone(),
|
||||
@@ -93,17 +100,31 @@ impl Backend {
|
||||
duration_ms: request.duration_ms,
|
||||
..LyricsRequest::default()
|
||||
};
|
||||
if let Ok(response) = self.fetch_lyrics(&query, &check)
|
||||
&& !response.instrumental
|
||||
{
|
||||
lyrics = lrc::with_metadata(&response, &query.track, &query.artist);
|
||||
if let Ok(response) = self.fetch_lyrics(&query, &check) {
|
||||
if response.instrumental {
|
||||
// Preserve real existing lyrics if a provider disagrees.
|
||||
// Otherwise persist the marker used by the offline player.
|
||||
if lyrics.is_empty() || lrc::is_instrumental_marker(&lyrics) {
|
||||
lyrics = "[instrumental:true]".into();
|
||||
status = "instrumental";
|
||||
}
|
||||
} else {
|
||||
let fetched =
|
||||
lrc::with_metadata(&response, &query.track, &query.artist);
|
||||
if lrc::has_usable_content(&fetched) {
|
||||
lyrics = fetched;
|
||||
status = "updated";
|
||||
}
|
||||
}
|
||||
}
|
||||
check()?;
|
||||
} else {
|
||||
status = "disabled";
|
||||
}
|
||||
}
|
||||
Ok(lyrics)
|
||||
Ok((lyrics, status))
|
||||
};
|
||||
let (cover, lyrics) = std::thread::scope(|scope| {
|
||||
let (cover, (lyrics, lyrics_status)) = std::thread::scope(|scope| {
|
||||
let worker = (wants_cover && request.selected("lyrics", "lyrics")).then(|| {
|
||||
std::thread::Builder::new()
|
||||
.name("reenrich-cover".into())
|
||||
@@ -163,7 +184,7 @@ impl Backend {
|
||||
})?;
|
||||
return Ok(
|
||||
json!({"method":"native","success":true,"enriched_metadata":enriched,
|
||||
"lyrics":lyrics,"write_external_lrc":external})
|
||||
"lyrics":lyrics,"lyrics_status":lyrics_status,"write_external_lrc":external})
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
@@ -194,7 +215,7 @@ impl Backend {
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let result = json!({"method":"ffmpeg","cover_path":cover_path,"lyrics":lyrics,
|
||||
let result = json!({"method":"ffmpeg","cover_path":cover_path,"lyrics":lyrics,"lyrics_status":lyrics_status,
|
||||
"enriched_metadata":enriched,"metadata":metadata,"write_external_lrc":external})
|
||||
.to_string();
|
||||
check()?;
|
||||
|
||||
@@ -56,6 +56,7 @@ void main() {
|
||||
final requests = <Map<String, dynamic>>[];
|
||||
final sidecars = <String>[];
|
||||
final ffmpegModes = <String>[];
|
||||
final reported = <String>[];
|
||||
final runner = BatchReEnrichRunner(
|
||||
beginPhase: () async {
|
||||
phaseCount++;
|
||||
@@ -107,8 +108,10 @@ void main() {
|
||||
previews,
|
||||
shouldStop: running,
|
||||
onProgress: progress,
|
||||
onResult: (result) => reported.add(result['method'] as String),
|
||||
);
|
||||
expect(successes, 2);
|
||||
expect(reported, ['native', 'ffmpeg']);
|
||||
expect(phaseCount, 2);
|
||||
expect(sidecars, ['/music/1.flac']);
|
||||
expect(ffmpegModes, ['split_vorbis']);
|
||||
|
||||
@@ -108,8 +108,12 @@ void main() {
|
||||
selectedIndex: activeTab?.value ?? 0,
|
||||
onSelected: (index) {
|
||||
activeTab?.value = index;
|
||||
chrome.expand();
|
||||
},
|
||||
onHome: () {
|
||||
activeTab?.value = 0;
|
||||
chrome.expand();
|
||||
},
|
||||
onHome: chrome.expand,
|
||||
onSearch: () {
|
||||
searches++;
|
||||
activeTab?.value = 3;
|
||||
@@ -144,6 +148,7 @@ void main() {
|
||||
|
||||
for (final (index, label) in [
|
||||
(1, 'Library'),
|
||||
(2, 'Repo'),
|
||||
(3, 'Search'),
|
||||
(0, 'Home'),
|
||||
]) {
|
||||
@@ -180,15 +185,55 @@ void main() {
|
||||
}
|
||||
}
|
||||
expect(
|
||||
iconColor('mornye-compact-home', Icons.home),
|
||||
index == 0 ? primary : isNot(primary),
|
||||
iconColor('mornye-compact-leading', switch (index) {
|
||||
1 => Icons.music_note,
|
||||
2 => Icons.grid_view,
|
||||
_ => Icons.home,
|
||||
}),
|
||||
index == 3 ? isNot(primary) : primary,
|
||||
);
|
||||
expect(
|
||||
iconColor('mornye-compact-search', Icons.search),
|
||||
index == 3 ? primary : isNot(primary),
|
||||
);
|
||||
final movingIcon = switch (index) {
|
||||
1 => Icons.music_note,
|
||||
2 => Icons.grid_view,
|
||||
_ => Icons.home,
|
||||
};
|
||||
final leading = find.byKey(const ValueKey('mornye-compact-leading'));
|
||||
final origin = tester.getCenter(
|
||||
find.descendant(of: leading, matching: find.byIcon(movingIcon)),
|
||||
);
|
||||
final originalTabIcon = find
|
||||
.descendant(
|
||||
of: find.byType(MornyeTabBar),
|
||||
matching: find.byIcon(movingIcon),
|
||||
)
|
||||
.first;
|
||||
expect(
|
||||
(origin - tester.getCenter(originalTabIcon)).distance,
|
||||
lessThan(0.5),
|
||||
);
|
||||
chrome.value = true;
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(
|
||||
tester
|
||||
.widget<MornyeTabBar>(find.byType(MornyeTabBar))
|
||||
.hiddenIconIndex,
|
||||
index == 3 ? 0 : index,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.widget<IconButton>(leading).tooltip,
|
||||
index == 3 ? 'Home' : label,
|
||||
);
|
||||
await tester.tap(leading);
|
||||
await tester.pumpAndSettle();
|
||||
expect(activeTab.value, index == 3 ? 0 : index);
|
||||
expect(chrome.value, isFalse);
|
||||
activeTab.value = index;
|
||||
chrome.expand();
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
@@ -202,7 +247,7 @@ void main() {
|
||||
'edge icons travel continuously without fading (glass: $blur)',
|
||||
(tester) async {
|
||||
await pumpShell(tester, blur: blur);
|
||||
final homeButton = find.byKey(const ValueKey('mornye-compact-home'));
|
||||
final homeButton = find.byKey(const ValueKey('mornye-compact-leading'));
|
||||
final icon = find.descendant(
|
||||
of: homeButton,
|
||||
matching: find.byIcon(Icons.home),
|
||||
@@ -292,11 +337,11 @@ void main() {
|
||||
expect(height, lessThanOrEqualTo(previousHeight + 0.01));
|
||||
previousHeight = height;
|
||||
firstHomeCenter ??= tester.getCenter(
|
||||
find.byKey(const ValueKey('mornye-compact-home')),
|
||||
find.byKey(const ValueKey('mornye-compact-leading')),
|
||||
);
|
||||
}
|
||||
final homeCenter = tester.getCenter(
|
||||
find.byKey(const ValueKey('mornye-compact-home')),
|
||||
find.byKey(const ValueKey('mornye-compact-leading')),
|
||||
);
|
||||
expect((homeCenter.dy - firstHomeCenter!.dy).abs(), lessThan(20));
|
||||
expect(find.byTooltip('Home').hitTestable(), findsOneWidget);
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/library_browse_provider.dart';
|
||||
import 'package:spotiflac_android/screens/mornye_library_screen.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
|
||||
LibraryBrowseEntry _album(int index) => LibraryBrowseEntry(
|
||||
source: 'downloaded',
|
||||
key: 'album-$index',
|
||||
name: 'Album $index',
|
||||
artist: 'Artist $index',
|
||||
samplePath: '',
|
||||
trackCount: 1,
|
||||
);
|
||||
|
||||
void main() {
|
||||
for (final brightness in Brightness.values) {
|
||||
testWidgets('Library separates browsing and downloads ($brightness)', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final opened = <String>[];
|
||||
final requests = <LibraryBrowseRequest>[];
|
||||
final queue = DownloadQueueLookup.fromItems([
|
||||
for (final status in [DownloadStatus.queued, DownloadStatus.completed])
|
||||
DownloadItem(
|
||||
id: status.name,
|
||||
service: 'example',
|
||||
status: status,
|
||||
createdAt: DateTime(2026),
|
||||
track: Track(
|
||||
id: status.name,
|
||||
name: 'Track',
|
||||
artistName: 'Artist',
|
||||
albumName: 'Album',
|
||||
duration: 100,
|
||||
),
|
||||
),
|
||||
]);
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
downloadQueueLookupProvider.overrideWithValue(queue),
|
||||
libraryBrowseProvider.overrideWith((ref, request) async {
|
||||
requests.add(request);
|
||||
return [_album(0), _album(1)];
|
||||
}),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: MornyeTheme.build(brightness),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: MornyeLibraryScreen(onOpenSection: opened.add),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Recently Added'), findsOneWidget);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
expect(find.text('Playlists'), findsOneWidget);
|
||||
expect(find.text('Songs'), findsOneWidget);
|
||||
final grid = tester.widget<SliverGrid>(find.byType(SliverGrid));
|
||||
expect(
|
||||
(grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount)
|
||||
.crossAxisCount,
|
||||
2,
|
||||
);
|
||||
await tester.tap(find.text('Downloads'));
|
||||
await tester.tap(find.text('Songs'));
|
||||
await tester.tap(find.text('Playlists'));
|
||||
expect(opened, ['downloads', 'all', 'playlists']);
|
||||
|
||||
await tester.tap(find.text('Artists'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(requests.last.artists, isTrue);
|
||||
await tester.tap(find.text('Artist 0'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(requests.last.artists, isFalse);
|
||||
expect(requests.last.artist, 'Artist 0');
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('Library search is local and album pages load on scroll', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final requests = <LibraryBrowseRequest>[];
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
libraryBrowseProvider.overrideWith((ref, request) async {
|
||||
requests.add(request);
|
||||
return List.generate(request.limit, _album);
|
||||
}),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: MornyeTheme.build(Brightness.dark),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: MornyeLibraryScreen(
|
||||
page: MornyeLibraryPage.albums,
|
||||
onOpenSection: (_) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final scroll = tester.widget<CustomScrollView>(
|
||||
find.byType(CustomScrollView),
|
||||
);
|
||||
scroll.controller!.jumpTo(scroll.controller!.position.maxScrollExtent);
|
||||
await tester.pumpAndSettle();
|
||||
expect(requests.last.limit, 80);
|
||||
scroll.controller!.jumpTo(0);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byTooltip('Search your library'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.testTextInput.isVisible, isTrue);
|
||||
await tester.enterText(find.byType(TextField), 'My album');
|
||||
await tester.pump(const Duration(milliseconds: 350));
|
||||
await tester.pumpAndSettle();
|
||||
expect(requests.last.search, 'My album');
|
||||
expect(requests.last.limit, 40);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/re_enrich_result.dart';
|
||||
|
||||
void main() {
|
||||
for (final locale in ['en', 'id']) {
|
||||
test(
|
||||
'$locale distinguishes updated, instrumental, and unavailable lyrics',
|
||||
() async {
|
||||
final l10n = await AppLocalizations.delegate.load(Locale(locale));
|
||||
for (final entry in {
|
||||
'updated': l10n.trackReEnrichLyricsUpdated,
|
||||
'instrumental': l10n.trackInstrumental,
|
||||
'not_found': l10n.trackLyricsNotAvailable,
|
||||
'preserved': l10n.trackReEnrichLyricsPreserved,
|
||||
'disabled': l10n.trackReEnrichLyricsDisabled,
|
||||
}.entries) {
|
||||
final message = reEnrichCompletionMessage(l10n, {
|
||||
'success': true,
|
||||
'lyrics_status': entry.key,
|
||||
});
|
||||
expect(message, '${l10n.trackReEnrichMetadataSaved}\n${entry.value}');
|
||||
if (entry.key != 'updated') {
|
||||
expect(message, isNot(contains(l10n.trackReEnrichLyricsUpdated)));
|
||||
}
|
||||
}
|
||||
expect(reEnrichCompletionMessage(l10n, {}), l10n.trackReEnrichSuccess);
|
||||
expect(
|
||||
reEnrichCompletionMessage(l10n, {'lyrics_status': 'not_requested'}),
|
||||
l10n.trackReEnrichSuccess,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'$locale batch does not label every saved file as lyrics updated',
|
||||
() async {
|
||||
final l10n = await AppLocalizations.delegate.load(Locale(locale));
|
||||
final summary = ReEnrichLyricsSummary();
|
||||
for (final status in ['updated', 'instrumental', 'not_found']) {
|
||||
summary.add({'lyrics_status': status});
|
||||
}
|
||||
expect(
|
||||
summary.message(l10n, 3, 3),
|
||||
[
|
||||
'${l10n.trackReEnrichMetadataSaved} (3/3)',
|
||||
'${l10n.trackReEnrichLyricsUpdated} (1)',
|
||||
'${l10n.trackInstrumental} (1)',
|
||||
'${l10n.trackLyricsNotAvailable} (1)',
|
||||
].join('\n'),
|
||||
);
|
||||
expect(
|
||||
summary.message(l10n, 3, 4),
|
||||
startsWith(l10n.trackReEnrichSuccessWithFailures(3, 4, 1)),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,13 @@ void main() {
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
|
||||
for (final scenario in ['fallback', 'local', 'empty', 'unavailable']) {
|
||||
for (final scenario in [
|
||||
'fallback',
|
||||
'local',
|
||||
'instrumental',
|
||||
'empty',
|
||||
'unavailable',
|
||||
]) {
|
||||
testWidgets('metadata reads local lyrics: $scenario', (tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
const path = 'content://library/document/song.flac';
|
||||
@@ -34,7 +40,11 @@ void main() {
|
||||
throw PlatformException(code: 'backend_unavailable');
|
||||
}
|
||||
return jsonEncode({
|
||||
'lyrics': scenario == 'local' ? lyrics : '',
|
||||
'lyrics': scenario == 'instrumental'
|
||||
? '[instrumental:true]'
|
||||
: scenario == 'local'
|
||||
? lyrics
|
||||
: '',
|
||||
'source': scenario == 'local' ? 'Embedded' : '',
|
||||
});
|
||||
case 'readFileMetadata':
|
||||
@@ -68,10 +78,19 @@ void main() {
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(metadataReads, scenario == 'local' ? 0 : 1);
|
||||
expect(
|
||||
metadataReads,
|
||||
['local', 'instrumental'].contains(scenario) ? 0 : 1,
|
||||
);
|
||||
expect(
|
||||
find.text('A locally stored lyric line'),
|
||||
scenario == 'empty' ? findsNothing : findsOneWidget,
|
||||
['empty', 'instrumental'].contains(scenario)
|
||||
? findsNothing
|
||||
: findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.text('Instrumental track'),
|
||||
scenario == 'instrumental' ? findsOneWidget : findsNothing,
|
||||
);
|
||||
expect(
|
||||
find.text('No lyrics found in this file'),
|
||||
|
||||
Reference in New Issue
Block a user