fix(ui): smooth metadata transitions and refine settings

This commit is contained in:
zarzet
2026-08-26 22:36:54 +07:00
parent 747dea886c
commit 0de4135966
7 changed files with 131 additions and 22 deletions
+2
View File
@@ -214,10 +214,12 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen>
}) async {
final navigator = Navigator.of(context);
precacheCoverImage(context, item.coverUrl);
final backdropReady = precacheMetadataBackdrop(context, item.coverUrl);
final beforeModTime =
await DownloadedEmbeddedCoverResolver.readFileModTimeMillis(
item.filePath,
);
await backdropReady;
if (!mounted) return;
final result = await navigator.push(
+2
View File
@@ -352,10 +352,12 @@ extension _HomeTabRecentUI on _HomeTabState {
}) async {
final navigator = Navigator.of(context);
precacheCoverImage(context, item.coverUrl);
final backdropReady = precacheMetadataBackdrop(context, item.coverUrl);
final beforeModTime =
await DownloadedEmbeddedCoverResolver.readFileModTimeMillis(
item.filePath,
);
await backdropReady;
if (!mounted) return;
final result = await navigator.push(
slidePageRoute<bool>(
@@ -1083,6 +1083,8 @@ class _CollectionTrackTile extends ConsumerWidget {
if (!context.mounted) return;
if (historyItem != null) {
await precacheMetadataBackdrop(context, historyItem.coverUrl);
if (!context.mounted) return;
await Navigator.of(context).push(
slidePageRoute<void>(page: TrackMetadataScreen(item: historyItem)),
);
@@ -1099,6 +1101,8 @@ class _CollectionTrackTile extends ConsumerWidget {
if (!context.mounted) return;
if (localItem != null) {
await precacheMetadataBackdrop(context, localItem.coverPath);
if (!context.mounted) return;
await Navigator.of(context).push(
slidePageRoute<void>(page: TrackMetadataScreen(localItem: localItem)),
);
+14 -4
View File
@@ -117,8 +117,13 @@ extension _QueueTabNavigation on _QueueTabState {
final navigator = Navigator.of(context);
precacheCoverImage(context, historyItem.coverUrl);
final backdropReady = precacheMetadataBackdrop(
context,
historyItem.coverUrl,
);
_searchFocusNode.unfocus();
final beforeModTime = await _readFileModTimeMillis(historyItem.filePath);
await backdropReady;
if (!mounted) return;
final result = await navigator.push(
slidePageRoute<bool>(page: TrackMetadataScreen(item: historyItem)),
@@ -145,8 +150,10 @@ extension _QueueTabNavigation on _QueueTabState {
}) async {
final navigator = Navigator.of(context);
precacheCoverImage(context, item.coverUrl);
final backdropReady = precacheMetadataBackdrop(context, item.coverUrl);
_searchFocusNode.unfocus();
final beforeModTime = await _readFileModTimeMillis(item.filePath);
await backdropReady;
if (!mounted) return;
final result = await navigator.push(
slidePageRoute<bool>(
@@ -173,13 +180,15 @@ extension _QueueTabNavigation on _QueueTabState {
);
}
void _navigateToLocalMetadataScreen(
Future<void> _navigateToLocalMetadataScreen(
LocalLibraryItem item, {
List<LocalLibraryItem>? navigationItems,
int? navigationIndex,
}) {
}) async {
_searchFocusNode.unfocus();
Navigator.push(
await precacheMetadataBackdrop(context, item.coverPath);
if (!mounted) return;
await Navigator.push(
context,
slidePageRoute<void>(
page: TrackMetadataScreen(
@@ -189,7 +198,8 @@ extension _QueueTabNavigation on _QueueTabState {
coverHeroTag: 'cover_lib_local_${item.id}',
),
),
).then((_) => _searchFocusNode.unfocus());
);
if (mounted) _searchFocusNode.unfocus();
}
void _navigateWithUnfocus(Route<dynamic> route) {
+7 -11
View File
@@ -95,17 +95,6 @@ class _SettingsTabState extends ConsumerState<SettingsTab> {
final searchCatalog = SettingsSearchCatalog(l10n);
final groups = [
_Group(
destinations: [
_Destination(
icon: Icons.favorite_outline,
title: l10n.settingsDonate,
subtitle: l10n.settingsDonateSubtitle,
keywords: const ['support', 'ko-fi', 'sponsor'],
pageBuilder: () => const DonatePage(),
),
],
),
_Group(
destinations: [
_Destination(
@@ -243,6 +232,13 @@ class _SettingsTabState extends ConsumerState<SettingsTab> {
),
_Group(
destinations: [
_Destination(
icon: Icons.favorite_outline,
title: l10n.settingsDonate,
subtitle: l10n.settingsDonateSubtitle,
keywords: const ['support', 'ko-fi', 'sponsor'],
pageBuilder: () => const DonatePage(),
),
_Destination(
icon: Icons.info_outline,
title: l10n.settingsAbout,
+61 -7
View File
@@ -5,6 +5,10 @@ const _trackMetadataHeroScheme = ColorScheme.dark(
onSurface: Colors.white,
onSurfaceVariant: Colors.white70,
);
final ImageFilter _trackMetadataBackdropBlur = ImageFilter.blur(
sigmaX: 32,
sigmaY: 32,
);
extension _TrackMetadataCards on _TrackMetadataScreenState {
Widget _buildAnimatedTrackContent(
@@ -90,12 +94,17 @@ extension _TrackMetadataCards on _TrackMetadataScreenState {
double expandedHeight,
bool showContent,
) {
final cacheWidth = coverCacheWidthForViewport(context);
final coverCacheWidth = coverCacheWidthForViewport(context);
final backdropCacheWidth = metadataBackdropCacheExtent(context);
final backdropRemoteUrl = _isLocalItem
? null
: normalizeRemoteHttpUrl(_downloadItem!.coverUrl);
Widget coverImage() => _hasPath(_embeddedCoverPreviewPath)
? Image.file(
File(_embeddedCoverPreviewPath!),
fit: BoxFit.cover,
cacheWidth: cacheWidth,
cacheWidth: coverCacheWidth,
gaplessPlayback: true,
filterQuality: FilterQuality.low,
errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
@@ -104,7 +113,7 @@ extension _TrackMetadataCards on _TrackMetadataScreenState {
? CachedCoverImage(
imageUrl: _coverUrl!,
fit: BoxFit.cover,
memCacheWidth: cacheWidth,
memCacheWidth: coverCacheWidth,
placeholder: (_, _) => Container(color: colorScheme.surface),
errorWidget: (_, _, _) => Container(color: colorScheme.surface),
)
@@ -112,7 +121,49 @@ extension _TrackMetadataCards on _TrackMetadataScreenState {
? Image.file(
File(_localCoverPath!),
fit: BoxFit.cover,
cacheWidth: cacheWidth,
cacheWidth: coverCacheWidth,
gaplessPlayback: true,
filterQuality: FilterQuality.low,
errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
size: 80,
color: colorScheme.onSurfaceVariant,
),
);
// Keep the backdrop on the stable Library artwork source instead of
// switching to the asynchronously extracted embedded preview mid-route.
// The smaller resize key is prewarmed before navigation and is sufficient
// behind a strong blur, while the foreground Hero keeps full resolution.
Widget backdropImage() => backdropRemoteUrl != null
? CachedCoverImage(
imageUrl: backdropRemoteUrl,
fit: BoxFit.cover,
memCacheWidth: backdropCacheWidth,
memCacheHeight: backdropCacheWidth,
placeholder: (_, _) => Container(color: colorScheme.surface),
errorWidget: (_, _, _) => Container(color: colorScheme.surface),
)
: _localCoverPath != null && _localCoverPath!.isNotEmpty
? Image.file(
File(_localCoverPath!),
fit: BoxFit.cover,
cacheWidth: backdropCacheWidth,
cacheHeight: backdropCacheWidth,
gaplessPlayback: true,
filterQuality: FilterQuality.low,
errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
)
: _hasPath(_embeddedCoverPreviewPath)
? Image.file(
File(_embeddedCoverPreviewPath!),
fit: BoxFit.cover,
cacheWidth: backdropCacheWidth,
cacheHeight: backdropCacheWidth,
gaplessPlayback: true,
filterQuality: FilterQuality.low,
errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
@@ -135,9 +186,12 @@ extension _TrackMetadataCards on _TrackMetadataScreenState {
return Stack(
fit: StackFit.expand,
children: [
ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32),
child: coverImage(),
RepaintBoundary(
key: ValueKey<String>('metadata_backdrop_$_itemId'),
child: ImageFiltered(
imageFilter: _trackMetadataBackdropBlur,
child: backdropImage(),
),
),
Container(color: Colors.black.withValues(alpha: 0.35)),
Positioned(
+41
View File
@@ -180,6 +180,47 @@ CachedNetworkImageProvider cachedCoverImageProvider(String url) {
);
}
/// Decode size shared by Track Metadata's blurred backdrop and its prewarm.
/// The backdrop is heavily blurred, so a modest square bitmap is sufficient
/// and avoids allocating a second full-viewport image beside the Hero cover.
int metadataBackdropCacheExtent(BuildContext context) {
final dpr = MediaQuery.devicePixelRatioOf(context).clamp(1.0, 3.0);
final logicalWidth = MediaQuery.sizeOf(context).width;
return (logicalWidth * dpr * 0.35).round().clamp(192, 384).toInt();
}
/// Warms the exact resized image used by Track Metadata's blurred backdrop.
/// Navigation waits for the common memory/disk-cache path, but a bounded budget
/// prevents a cold network request from delaying the route for too long.
Future<void> precacheMetadataBackdrop(
BuildContext context,
String? source,
) async {
final normalized = source?.trim();
if (normalized == null || normalized.isEmpty) return;
final ImageProvider provider;
if (normalized.startsWith('http://') || normalized.startsWith('https://')) {
provider = cachedCoverImageProvider(normalized);
} else if (!normalized.startsWith('content://')) {
final filePath = normalized.startsWith('file://')
? Uri.parse(normalized).toFilePath()
: normalized;
provider = FileImage(File(filePath));
} else {
return;
}
final extent = metadataBackdropCacheExtent(context);
try {
await precacheImage(
ResizeImage(provider, width: extent, height: extent),
context,
onError: (_, _) {},
).timeout(const Duration(milliseconds: 200));
} catch (_) {}
}
/// Pre-warms the cover cache at the metadata-screen display size so the hero
/// transition doesn't pop in a low-res frame. Http(s) URLs only.
void precacheCoverImage(BuildContext context, String? url) {