perf(details): load local albums with skeletons and bound artwork decoding

This commit is contained in:
zarzet committed 2026-10-03 04:24:55 +07:00
1 parent a377885e56
commit d97b4bfd2d
7 files changed
+320 -57

No files matched your search

+19 -14
View File
@@ -211,17 +211,20 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen>
required int navigationIndex,
}) async {
final navigator = Navigator.of(context);
final embeddedCoverPath =
await DownloadedEmbeddedCoverResolver.resolveOrExtract(
item.filePath,
onChanged: _onEmbeddedCoverChanged,
);
final embeddedCoverPath = context.isMornye
? DownloadedEmbeddedCoverResolver.resolve(item.filePath)
: await DownloadedEmbeddedCoverResolver.resolveOrExtract(
item.filePath,
onChanged: _onEmbeddedCoverChanged,
);
if (!mounted) return;
final artworkSource = embeddedCoverPath ?? item.coverUrl;
if (embeddedCoverPath == null) {
if (embeddedCoverPath == null && !context.isMornye) {
precacheCoverImage(context, item.coverUrl);
}
final backdropReady = precacheMetadataBackdrop(context, artworkSource);
final backdropReady = context.isMornye
? Future<void>.value()
: precacheMetadataBackdrop(context, artworkSource);
final beforeModTime =
await DownloadedEmbeddedCoverResolver.readFileModTimeMillis(
item.filePath,
@@ -229,15 +232,17 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen>
await backdropReady;
if (!mounted) return;
final result = await navigator.push(
slidePageRoute<bool>(
page: TrackMetadataScreen(
item: item,
historyNavigationItems: navigationItems,
navigationIndex: navigationIndex,
),
final route = slidePageRoute<bool>(
page: TrackMetadataScreen(
item: item,
historyNavigationItems: navigationItems,
navigationIndex: navigationIndex,
),
);
final result = await navigator.push(route);
// Refresh the album after the return animation, not while both pages paint.
if (route is TransitionRoute<bool>) await route.completed;
if (!mounted) return;
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
item.filePath,
beforeModTime: beforeModTime,
+59 -6
View File
@@ -42,13 +42,15 @@ class LocalAlbumScreen extends ConsumerStatefulWidget {
final String artistName;
final String? coverPath;
final List<LocalLibraryItem> tracks;
final Future<List<LocalLibraryItem>> Function()? loadTracks;
const LocalAlbumScreen({
super.key,
required this.albumName,
required this.artistName,
this.coverPath,
required this.tracks,
this.tracks = const [],
this.loadTracks,
});
@override
@@ -61,6 +63,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
CollapsingHeaderScrollMixin<LocalAlbumScreen> {
late List<LocalLibraryItem> _sortedTracksCache;
late Map<int, List<LocalLibraryItem>> _discGroupsCache;
List<LocalLibraryItem>? _loadedTracks;
bool _loadingTracks = false;
Object? _loadError;
int _loadGeneration = 0;
void _showCueVirtualTrackSnackBar() {
ScaffoldMessenger.of(context).showSnackBar(
@@ -77,19 +83,44 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
void initState() {
super.initState();
_rebuildTrackCaches();
_loadTracks();
}
Future<void> _loadTracks() async {
final loader = widget.loadTracks;
if (loader == null) return;
final generation = ++_loadGeneration;
_loadingTracks = true;
_loadError = null;
try {
final tracks = await loader();
if (!mounted || generation != _loadGeneration) return;
_loadedTracks = tracks;
} catch (error) {
if (!mounted || generation != _loadGeneration) return;
_loadError = error;
}
_loadingTracks = false;
setState(_rebuildTrackCaches);
}
@override
void didUpdateWidget(covariant LocalAlbumScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(oldWidget.tracks, widget.tracks) ||
oldWidget.tracks.length != widget.tracks.length) {
oldWidget.tracks.length != widget.tracks.length ||
oldWidget.loadTracks != widget.loadTracks) {
_loadGeneration++;
_loadedTracks = null;
_loadingTracks = false;
_loadError = null;
_rebuildTrackCaches();
_loadTracks();
}
}
List<LocalLibraryItem> _buildSortedTracks() {
final tracks = List<LocalLibraryItem>.from(widget.tracks);
final tracks = List<LocalLibraryItem>.from(_loadedTracks ?? widget.tracks);
tracks.sort((a, b) {
final aDisc = a.discNumber ?? 1;
final bDisc = b.discNumber ?? 1;
@@ -196,7 +227,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
: 0.0;
final tracks = _sortedTracksCache;
if (tracks.isEmpty) {
if (tracks.isEmpty && !_loadingTracks && _loadError == null) {
return Scaffold(
appBar: AppBar(title: Text(widget.albumName)),
body: Center(child: Text(context.l10n.noTracksFoundForAlbum)),
@@ -210,7 +241,26 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
isSelectionMode: isSelectionMode,
onExitSelectionMode: exitSelectionMode,
appBar: _buildAppBar(context, colorScheme, qualityLabelMode),
slivers: [_buildTrackList(context, colorScheme, tracks)],
slivers: [
if (_loadError != null)
SliverToBoxAdapter(
child: Center(
child: TextButton.icon(
onPressed: () => setState(() {
_loadTracks();
}),
icon: const Icon(Icons.refresh),
label: Text(context.l10n.dialogRetry),
),
),
)
else if (_loadingTracks)
const SliverToBoxAdapter(
child: RepaintBoundary(child: AlbumTrackListSkeleton(itemCount: 8)),
)
else
_buildTrackList(context, colorScheme, tracks),
],
selectionBar: _buildSelectionBottomBar(
context,
colorScheme,
@@ -231,7 +281,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
.watch(
playerMotionArtworkProvider((
album: widget.albumName,
artist: _sortedTracksCache.first.artistName,
artist:
_sortedTracksCache.firstOrNull?.artistName ??
widget.artistName,
)),
)
.value
@@ -403,6 +455,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
key: ValueKey(track.id),
child: StaggeredListItem(
index: index,
animate: widget.loadTracks == null,
child: _buildTrackItem(context, colorScheme, track),
),
);
+8 -5
View File
@@ -119,17 +119,20 @@ class _MornyeLibraryScreenState extends ConsumerState<MornyeLibraryScreen> {
);
return;
}
final rows = await LibraryDatabase.instance.getQueueLocalAlbumTracksByKey(
entry.key,
);
if (!mounted) return;
Future<List<LocalLibraryItem>> loadTracks() async {
final rows = await LibraryDatabase.instance.getQueueLocalAlbumTracksByKey(
entry.key,
);
return rows.map(LocalLibraryItem.fromJson).toList(growable: false);
}
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),
loadTracks: loadTracks,
),
),
);
+45 -31
View File
@@ -104,24 +104,30 @@ extension _QueueTabNavigation on _QueueTabState {
);
final navigator = Navigator.of(context);
final embeddedCoverPath =
await DownloadedEmbeddedCoverResolver.resolveOrExtract(
historyItem.filePath,
onChanged: _onEmbeddedCoverChanged,
);
final embeddedCoverPath = context.isMornye
? DownloadedEmbeddedCoverResolver.resolve(historyItem.filePath)
: await DownloadedEmbeddedCoverResolver.resolveOrExtract(
historyItem.filePath,
onChanged: _onEmbeddedCoverChanged,
);
if (!mounted) return;
final artworkSource = embeddedCoverPath ?? historyItem.coverUrl;
if (embeddedCoverPath == null) {
if (embeddedCoverPath == null && !context.isMornye) {
precacheCoverImage(context, historyItem.coverUrl);
}
final backdropReady = precacheMetadataBackdrop(context, artworkSource);
final backdropReady = context.isMornye
? Future<void>.value()
: precacheMetadataBackdrop(context, artworkSource);
_searchFocusNode.unfocus();
final beforeModTime = await _readFileModTimeMillis(historyItem.filePath);
await backdropReady;
if (!mounted) return;
final result = await navigator.push(
slidePageRoute<bool>(page: TrackMetadataScreen(item: historyItem)),
final route = slidePageRoute<bool>(
page: TrackMetadataScreen(item: historyItem),
);
final result = await navigator.push(route);
if (route is TransitionRoute<bool>) await route.completed;
if (!mounted) return;
_searchFocusNode.unfocus();
if (result == true) {
await _scheduleDownloadedEmbeddedCoverRefreshForPath(
@@ -143,31 +149,35 @@ extension _QueueTabNavigation on _QueueTabState {
int? navigationIndex,
}) async {
final navigator = Navigator.of(context);
final embeddedCoverPath =
await DownloadedEmbeddedCoverResolver.resolveOrExtract(
item.filePath,
onChanged: _onEmbeddedCoverChanged,
);
final embeddedCoverPath = context.isMornye
? DownloadedEmbeddedCoverResolver.resolve(item.filePath)
: await DownloadedEmbeddedCoverResolver.resolveOrExtract(
item.filePath,
onChanged: _onEmbeddedCoverChanged,
);
if (!mounted) return;
final artworkSource = embeddedCoverPath ?? item.coverUrl;
if (embeddedCoverPath == null) {
if (embeddedCoverPath == null && !context.isMornye) {
precacheCoverImage(context, item.coverUrl);
}
final backdropReady = precacheMetadataBackdrop(context, artworkSource);
final backdropReady = context.isMornye
? Future<void>.value()
: precacheMetadataBackdrop(context, artworkSource);
_searchFocusNode.unfocus();
final beforeModTime = await _readFileModTimeMillis(item.filePath);
await backdropReady;
if (!mounted) return;
final result = await navigator.push(
slidePageRoute<bool>(
page: TrackMetadataScreen(
item: item,
historyNavigationItems: navigationItems,
navigationIndex: navigationIndex,
coverHeroTag: 'cover_lib_dl_${item.id}',
),
final route = slidePageRoute<bool>(
page: TrackMetadataScreen(
item: item,
historyNavigationItems: navigationItems,
navigationIndex: navigationIndex,
coverHeroTag: 'cover_lib_dl_${item.id}',
),
);
final result = await navigator.push(route);
if (route is TransitionRoute<bool>) await route.completed;
if (!mounted) return;
_searchFocusNode.unfocus();
if (result == true) {
await _scheduleDownloadedEmbeddedCoverRefreshForPath(
@@ -189,7 +199,9 @@ extension _QueueTabNavigation on _QueueTabState {
int? navigationIndex,
}) async {
_searchFocusNode.unfocus();
await precacheMetadataBackdrop(context, item.coverPath);
if (!context.isMornye) {
await precacheMetadataBackdrop(context, item.coverPath);
}
if (!mounted) return;
await Navigator.push(
context,
@@ -222,9 +234,8 @@ extension _QueueTabNavigation on _QueueTabState {
);
}
Future<void> _navigateToLocalAlbum(_GroupedLocalAlbum album) async {
var tracks = album.tracks;
if (tracks.isEmpty && album.displayTrackCount > 0) {
void _navigateToLocalAlbum(_GroupedLocalAlbum album) {
Future<List<LocalLibraryItem>> loadTracks() async {
var rows = album.albumKey.isNotEmpty
? await LibraryDatabase.instance.getQueueLocalAlbumTracksByKey(
album.albumKey,
@@ -239,16 +250,19 @@ extension _QueueTabNavigation on _QueueTabState {
album.artistName,
);
}
tracks = rows.map(LocalLibraryItem.fromJson).toList(growable: false);
if (!mounted) return;
return rows.map(LocalLibraryItem.fromJson).toList(growable: false);
}
_navigateWithUnfocus(
slidePageRoute(
page: LocalAlbumScreen(
albumName: album.albumName,
artistName: album.artistName,
coverPath: album.coverPath,
tracks: tracks,
tracks: album.tracks,
loadTracks: album.tracks.isEmpty && album.displayTrackCount > 0
? loadTracks
: null,
),
),
);
+7
View File
@@ -73,6 +73,13 @@ extension _TrackMetadataMornye on _TrackMetadataScreenState {
artUri: artwork,
colorScheme: scheme,
iconSize: 80,
cacheWidth:
(320 *
MediaQuery.devicePixelRatioOf(
context,
))
.round()
.clamp(320, 1280),
),
),
),
+1 -1
View File
@@ -191,7 +191,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
_currentDownloadItem = widget.item;
_currentLocalLibraryItem = widget.localItem;
_currentNavigationIndex = widget.navigationIndex;
_checkFile();
unawaited(_checkFile());
}
@override
+181
View File
@@ -0,0 +1,181 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/screens/local_album_screen.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
LocalLibraryItem _track(int number) => LocalLibraryItem(
id: 'track-$number',
trackName: 'Song $number',
artistName: 'Artist',
albumName: 'Album',
filePath: 'content://library/document/song-$number.flac',
trackNumber: number,
scannedAt: DateTime(2026),
);
void main() {
Future<GlobalKey<NavigatorState>> mount(WidgetTester tester) async {
SharedPreferences.setMockInitialValues({});
tester.view.physicalSize = const Size(430, 1200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final navigator = GlobalKey<NavigatorState>();
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
navigatorKey: navigator,
theme: MornyeTheme.build(Brightness.light),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: Text('Library')),
),
),
);
await tester.pumpAndSettle();
return navigator;
}
PageRoute<void> route(Widget page) => PageRouteBuilder<void>(
transitionDuration: const Duration(milliseconds: 400),
reverseTransitionDuration: const Duration(milliseconds: 400),
pageBuilder: (_, _, _) => page,
transitionsBuilder: (_, animation, _, child) => SlideTransition(
position: Tween(
begin: const Offset(1, 0),
end: Offset.zero,
).animate(animation),
child: child,
),
);
for (final popEarly in [false, true]) {
testWidgets(
'metadata starts reading during entrance, early pop=$popEarly',
(tester) async {
final navigator = await mount(tester);
var statCalls = 0;
var followUpCalls = 0;
final delayedStat = Completer<String>();
const channel = MethodChannel('com.zarz.spotiflac/backend');
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'safStat') {
statCalls++;
if (popEarly) return delayedStat.future;
return jsonEncode({'exists': false});
}
followUpCalls++;
return null;
});
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
unawaited(
navigator.currentState!.push(
route(TrackMetadataScreen(localItem: _track(1))),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 150));
expect(find.text('Song 1'), findsWidgets);
expect(statCalls, 1);
if (popEarly) navigator.currentState!.pop();
await tester.pumpAndSettle();
if (popEarly) {
delayedStat.complete(jsonEncode({'exists': true, 'size': 100}));
await tester.pump();
expect(followUpCalls, 0);
}
expect(statCalls, 1);
expect(tester.takeException(), isNull);
},
);
}
testWidgets(
'album shows header immediately and applies rows during entrance',
(tester) async {
final navigator = await mount(tester);
final result = Completer<List<LocalLibraryItem>>();
unawaited(
navigator.currentState!.push(
route(
LocalAlbumScreen(
albumName: 'Album',
artistName: 'Artist',
loadTracks: () => result.future,
),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 16));
expect(find.text('Album'), findsWidgets);
expect(find.text('No tracks found for this album'), findsNothing);
expect(find.byType(AlbumTrackListSkeleton), findsOneWidget);
expect(find.byType(ShimmerLoading), findsOneWidget);
expect(find.byType(CircularProgressIndicator), findsNothing);
result.complete([_track(2), _track(1)]);
await tester.pump(const Duration(milliseconds: 150));
await tester.pump();
expect(find.text('Song 1'), findsOneWidget);
expect(find.text('Song 2'), findsOneWidget);
expect(find.byType(AlbumTrackListSkeleton), findsNothing);
expect(
ModalRoute.of(
tester.element(find.byType(LocalAlbumScreen)),
)!.animation!.value,
lessThan(1),
);
await tester.pumpAndSettle();
expect(find.text('Song 1'), findsOneWidget);
expect(find.text('Song 2'), findsOneWidget);
expect(
tester.getTopLeft(find.text('Song 1')).dy,
lessThan(tester.getTopLeft(find.text('Song 2')).dy),
);
expect(tester.takeException(), isNull);
},
);
testWidgets('album retries failed loading and ignores completion after pop', (
tester,
) async {
final navigator = await mount(tester);
final result = Completer<List<LocalLibraryItem>>();
var attempts = 0;
unawaited(
navigator.currentState!.push(
route(
LocalAlbumScreen(
albumName: 'Album',
artistName: 'Artist',
loadTracks: () async {
if (++attempts == 1) throw StateError('Unavailable');
return result.future;
},
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Retry'));
await tester.pump();
expect(attempts, 2);
navigator.currentState!.pop();
await tester.pumpAndSettle();
result.complete([_track(1)]);
await tester.pump();
expect(find.byType(LocalAlbumScreen), findsNothing);
expect(tester.takeException(), isNull);
});
}