diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 18aebbce..145d7aa7 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -214,10 +214,12 @@ class _DownloadedAlbumScreenState extends ConsumerState }) 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( diff --git a/lib/screens/home_tab_recent.dart b/lib/screens/home_tab_recent.dart index 05c61d3f..e4b7fd0a 100644 --- a/lib/screens/home_tab_recent.dart +++ b/lib/screens/home_tab_recent.dart @@ -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( diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index 2061ae88..0b144dba 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -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(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(page: TrackMetadataScreen(localItem: localItem)), ); diff --git a/lib/screens/queue_tab_navigation.dart b/lib/screens/queue_tab_navigation.dart index e93e0d2b..3d8e74aa 100644 --- a/lib/screens/queue_tab_navigation.dart +++ b/lib/screens/queue_tab_navigation.dart @@ -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(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( @@ -173,13 +180,15 @@ extension _QueueTabNavigation on _QueueTabState { ); } - void _navigateToLocalMetadataScreen( + Future _navigateToLocalMetadataScreen( LocalLibraryItem item, { List? navigationItems, int? navigationIndex, - }) { + }) async { _searchFocusNode.unfocus(); - Navigator.push( + await precacheMetadataBackdrop(context, item.coverPath); + if (!mounted) return; + await Navigator.push( context, slidePageRoute( 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 route) { diff --git a/lib/screens/settings/settings_tab.dart b/lib/screens/settings/settings_tab.dart index 40fdd89c..c5299359 100644 --- a/lib/screens/settings/settings_tab.dart +++ b/lib/screens/settings/settings_tab.dart @@ -95,17 +95,6 @@ class _SettingsTabState extends ConsumerState { 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 { ), _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, diff --git a/lib/screens/track_metadata_cards.dart b/lib/screens/track_metadata_cards.dart index de3d006e..33cdf0d8 100644 --- a/lib/screens/track_metadata_cards.dart +++ b/lib/screens/track_metadata_cards.dart @@ -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('metadata_backdrop_$_itemId'), + child: ImageFiltered( + imageFilter: _trackMetadataBackdropBlur, + child: backdropImage(), + ), ), Container(color: Colors.black.withValues(alpha: 0.35)), Positioned( diff --git a/lib/widgets/cached_cover_image.dart b/lib/widgets/cached_cover_image.dart index c6490048..48a69e9a 100644 --- a/lib/widgets/cached_cover_image.dart +++ b/lib/widgets/cached_cover_image.dart @@ -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 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) {