From c58028089092d600a9c8d89a7e0c19df254327b1 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 13 Jul 2026 20:46:01 +0700 Subject: [PATCH] feat(ui): shared-element hero covers from search and recents to detail screens Tab navigators get a HeroController (nested navigators have none, so Heroes never flew inside tabs). Search result rows, recent-access items, and artist discography cards tag their covers; Album/Artist/Downloaded screens accept a heroTag and mount the matching Hero on the header cover. Tags are scoped per list section to stay unique per page. --- lib/screens/album_screen.dart | 5 ++ lib/screens/artist_screen.dart | 100 ++++++++++++++--------- lib/screens/downloaded_album_screen.dart | 5 ++ lib/screens/home_tab.dart | 38 ++++++--- lib/screens/home_tab_widgets.dart | 91 ++++++++++++--------- lib/screens/main_shell.dart | 24 +++++- lib/widgets/album_detail_header.dart | 10 ++- 7 files changed, 180 insertions(+), 93 deletions(-) diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 101e17c5..388491af 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -47,6 +47,9 @@ class AlbumScreen extends ConsumerStatefulWidget { final String? artistId; final String? artistName; + /// Shared-element tag for the header cover (see [AlbumDetailHeader.heroTag]). + final Object? heroTag; + const AlbumScreen({ super.key, required this.albumId, @@ -59,6 +62,7 @@ class AlbumScreen extends ConsumerStatefulWidget { this.extensionId, this.artistId, this.artistName, + this.heroTag, }); @override @@ -398,6 +402,7 @@ class _AlbumScreenState extends ConsumerState expandedHeight: expandedHeight, showTitleInAppBar: showTitleInAppBar, backgroundColor: pageBackgroundColor, + heroTag: widget.heroTag, background: hasMotion ? MotionHeaderBanner(videoUrl: motionUrl, fallback: headerBgImage) : headerBgImage, diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 64be4115..11f88009 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -88,6 +88,10 @@ class ArtistScreen extends ConsumerStatefulWidget { final List? topTracks; final String? extensionId; + /// Shared-element tag: when set, the header image flies from the list item + /// that pushed this screen (which wraps its thumbnail in a matching [Hero]). + final Object? heroTag; + const ArtistScreen({ super.key, required this.artistId, @@ -99,6 +103,7 @@ class ArtistScreen extends ConsumerStatefulWidget { this.albums, this.topTracks, this.extensionId, + this.heroTag, }); @override @@ -116,6 +121,9 @@ class _ArtistScreenState extends ConsumerState int? _monthlyListeners; String? _error; + Widget _heroWrap(Widget child) => + widget.heroTag != null ? Hero(tag: widget.heroTag!, child: child) : child; + bool _showTitleInAppBar = false; final ScrollController _scrollController = ScrollController(); final PageController _popularPageController = PageController(); @@ -1192,19 +1200,21 @@ class _ArtistScreenState extends ConsumerState ), ) else if (hasValidImage) - CachedCoverImage( - imageUrl: imageUrl, - fit: BoxFit.cover, - alignment: Alignment.topCenter, - memCacheWidth: 800, - placeholder: (context, url) => - Container(color: colorScheme.surfaceContainerHighest), - errorWidget: (context, url, error) => Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.person, - size: 80, - color: colorScheme.onSurfaceVariant, + _heroWrap( + CachedCoverImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + alignment: Alignment.topCenter, + memCacheWidth: 800, + placeholder: (context, url) => + Container(color: colorScheme.surfaceContainerHighest), + errorWidget: (context, url, error) => Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.person, + size: 80, + color: colorScheme.onSurfaceVariant, + ), ), ), ) @@ -1708,6 +1718,7 @@ class _ArtistScreenState extends ConsumerState tileSize: tileSize, sectionHeight: sectionHeight, showTypeBadge: showTypeBadge, + sectionKey: title, ), ); }, @@ -1723,8 +1734,12 @@ class _ArtistScreenState extends ConsumerState required double tileSize, required double sectionHeight, bool showTypeBadge = false, + String sectionKey = '', }) { final isSelected = selectedIds.contains(album.id); + // The same album can appear in several sections (releases + its type + // bucket), so the section is part of the tag to keep it unique. + final albumHeroTag = 'discography-$sectionKey-${album.id}'; return Semantics( button: true, @@ -1737,7 +1752,7 @@ class _ArtistScreenState extends ConsumerState if (isSelectionMode) { toggleSelection(album.id); } else { - _navigateToAlbum(album); + _navigateToAlbum(album, heroTag: albumHeroTag); } }, onLongPress: () { @@ -1757,22 +1772,35 @@ class _ArtistScreenState extends ConsumerState child: Stack( fit: StackFit.expand, children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: album.coverUrl != null - ? CachedCoverImage( - imageUrl: album.coverUrl!, - width: tileSize, - height: tileSize, - fit: BoxFit.cover, - memCacheWidth: (tileSize * 2).round(), - memCacheHeight: (tileSize * 2).round(), - placeholder: (context, url) => ShimmerLoading( - child: Container( - color: colorScheme.surfaceContainerHighest, + Hero( + tag: albumHeroTag, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: album.coverUrl != null + ? CachedCoverImage( + imageUrl: album.coverUrl!, + width: tileSize, + height: tileSize, + fit: BoxFit.cover, + memCacheWidth: (tileSize * 2).round(), + memCacheHeight: (tileSize * 2).round(), + placeholder: (context, url) => ShimmerLoading( + child: Container( + color: colorScheme.surfaceContainerHighest, + ), ), - ), - errorWidget: (context, url, error) => Container( + errorWidget: (context, url, error) => + Container( + color: + colorScheme.surfaceContainerHighest, + child: Icon( + Icons.album, + color: colorScheme.onSurfaceVariant, + size: 40, + ), + ), + ) + : Container( color: colorScheme.surfaceContainerHighest, child: Icon( Icons.album, @@ -1780,15 +1808,7 @@ class _ArtistScreenState extends ConsumerState size: 40, ), ), - ) - : Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.album, - color: colorScheme.onSurfaceVariant, - size: 40, - ), - ), + ), ), if (isSelectionMode) Positioned.fill( @@ -1889,7 +1909,7 @@ class _ArtistScreenState extends ConsumerState ); } - void _navigateToAlbum(ArtistAlbum album) { + void _navigateToAlbum(ArtistAlbum album, {Object? heroTag}) { ref.read(settingsProvider.notifier).setHasSearchedBefore(); if (album.providerId != null && album.providerId!.isNotEmpty) { @@ -1903,6 +1923,7 @@ class _ArtistScreenState extends ConsumerState coverUrl: album.coverUrl, initialAlbumType: album.albumType, initialTotalTracks: album.totalTracks, + heroTag: heroTag, ), ), ); @@ -1914,6 +1935,7 @@ class _ArtistScreenState extends ConsumerState albumId: album.id, albumName: album.name, coverUrl: album.coverUrl, + heroTag: heroTag, ), ), ); diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 56db0338..e42e716d 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -35,11 +35,15 @@ class DownloadedAlbumScreen extends ConsumerStatefulWidget { final String artistName; final String? coverUrl; + /// Shared-element tag for the header cover (see [AlbumDetailHeader.heroTag]). + final Object? heroTag; + const DownloadedAlbumScreen({ super.key, required this.albumName, required this.artistName, this.coverUrl, + this.heroTag, }); @override @@ -365,6 +369,7 @@ class _DownloadedAlbumScreenState extends ConsumerState title: widget.albumName, expandedHeight: expandedHeight, showTitleInAppBar: showTitleInAppBar, + heroTag: widget.heroTag, background: background, blurAndScrimBackground: embeddedCoverPath != null || widget.coverUrl != null, diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index db1f34d3..18ef0fb8 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -2321,18 +2321,21 @@ class _HomeTabState extends ConsumerState padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), child: Row( children: [ - _DownloadedOrRemoteCover( - downloadedFilePath: isDownloaded - ? downloadFilePathByRecentKey['${item.type.name}:${item.id}'] - : null, - imageUrl: item.imageUrl, - width: 56, - height: 56, - borderRadius: BorderRadius.circular( - item.type == RecentAccessType.artist ? 28 : 4, + Hero( + tag: _recentHeroTag(item), + child: _DownloadedOrRemoteCover( + downloadedFilePath: isDownloaded + ? downloadFilePathByRecentKey['${item.type.name}:${item.id}'] + : null, + imageUrl: item.imageUrl, + width: 56, + height: 56, + borderRadius: BorderRadius.circular( + item.type == RecentAccessType.artist ? 28 : 4, + ), + fallbackIcon: typeIcon, + colorScheme: colorScheme, ), - fallbackIcon: typeIcon, - colorScheme: colorScheme, ), const SizedBox(width: 12), Expanded( @@ -2404,9 +2407,13 @@ class _HomeTabState extends ConsumerState ); } + static String _recentHeroTag(RecentAccessItem item) => + 'recent-${item.type.name}-${item.id}'; + Future _navigateToRecentItem(RecentAccessItem item) async { _searchFocusNode.unfocus(); + final heroTag = _recentHeroTag(item); switch (item.type) { case RecentAccessType.artist: if (_isEnabledMetadataExtension(item.providerId)) { @@ -2418,6 +2425,7 @@ class _HomeTabState extends ConsumerState artistId: item.id, artistName: item.name, coverUrl: item.imageUrl, + heroTag: heroTag, ), ), ); @@ -2429,6 +2437,7 @@ class _HomeTabState extends ConsumerState artistId: item.id, artistName: item.name, coverUrl: item.imageUrl, + heroTag: heroTag, ), ), ); @@ -2443,6 +2452,7 @@ class _HomeTabState extends ConsumerState albumName: item.name, artistName: item.subtitle ?? '', coverUrl: item.imageUrl, + heroTag: heroTag, ), ), ); @@ -2455,6 +2465,7 @@ class _HomeTabState extends ConsumerState albumId: item.id, albumName: item.name, coverUrl: item.imageUrl, + heroTag: heroTag, ), ), ); @@ -2466,6 +2477,7 @@ class _HomeTabState extends ConsumerState albumId: item.id, albumName: item.name, coverUrl: item.imageUrl, + heroTag: heroTag, ), ), ); @@ -3234,6 +3246,7 @@ class _HomeTabState extends ConsumerState artistId: artistId, artistName: artistName, coverUrl: imageUrl, + heroTag: 'search-artist-$artistId', ), ), ); @@ -3261,6 +3274,7 @@ class _HomeTabState extends ConsumerState albumName: album.name, coverUrl: album.imageUrl, tracks: const [], + heroTag: 'search-album-${album.id}', ), ), ); @@ -3326,6 +3340,7 @@ class _HomeTabState extends ConsumerState coverUrl: albumItem.coverUrl, initialAlbumType: albumItem.albumType, initialTotalTracks: albumItem.totalTracks, + heroTag: 'search-album-${albumItem.id}', ), ), ); @@ -3397,6 +3412,7 @@ class _HomeTabState extends ConsumerState artistId: artistItem.id, artistName: artistItem.name, coverUrl: artistItem.coverUrl, + heroTag: 'search-artist-${artistItem.id}', ), ), ); diff --git a/lib/screens/home_tab_widgets.dart b/lib/screens/home_tab_widgets.dart index 96b70d37..420bd4ea 100644 --- a/lib/screens/home_tab_widgets.dart +++ b/lib/screens/home_tab_widgets.dart @@ -434,6 +434,28 @@ class _CollectionItemWidget extends StatelessWidget { if (isPlaylist) placeholderIcon = Icons.playlist_play; if (isArtist) placeholderIcon = Icons.person; + final cover = ClipRRect( + borderRadius: BorderRadius.circular(isArtist ? 28 : 10), + child: item.coverUrl != null && item.coverUrl!.isNotEmpty + ? CachedCoverImage( + imageUrl: item.coverUrl!, + width: 56, + height: 56, + fit: BoxFit.cover, + ) + : Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: Icon(placeholderIcon, color: colorScheme.onSurfaceVariant), + ), + ); + // Matches the heroTag passed to the pushed detail screen; playlists have + // no hero destination. + final heroTag = isPlaylist + ? null + : (isArtist ? 'search-artist-${item.id}' : 'search-album-${item.id}'); + return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -445,25 +467,7 @@ class _CollectionItemWidget extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row( children: [ - ClipRRect( - borderRadius: BorderRadius.circular(isArtist ? 28 : 10), - child: item.coverUrl != null && item.coverUrl!.isNotEmpty - ? CachedCoverImage( - imageUrl: item.coverUrl!, - width: 56, - height: 56, - fit: BoxFit.cover, - ) - : Container( - width: 56, - height: 56, - color: colorScheme.surfaceContainerHighest, - child: Icon( - placeholderIcon, - color: colorScheme.onSurfaceVariant, - ), - ), - ), + heroTag != null ? Hero(tag: heroTag, child: cover) : cover, const SizedBox(width: 12), Expanded( child: Column( @@ -528,6 +532,9 @@ class _SearchResultRowItem extends StatelessWidget { final bool showDivider; final VoidCallback onTap; + /// Matches the heroTag passed to the pushed detail screen. + final Object? heroTag; + const _SearchResultRowItem({ required this.imageUrl, required this.coverBorderRadius, @@ -536,6 +543,7 @@ class _SearchResultRowItem extends StatelessWidget { required this.subtitle, required this.showDivider, required this.onTap, + this.heroTag, }); @override @@ -546,6 +554,23 @@ class _SearchResultRowItem extends StatelessWidget { imageUrl!.isNotEmpty && Uri.tryParse(imageUrl!)?.hasAuthority == true; + final cover = ClipRRect( + borderRadius: BorderRadius.circular(coverBorderRadius), + child: hasValidImage + ? CachedCoverImage( + imageUrl: imageUrl!, + width: 56, + height: 56, + fit: BoxFit.cover, + ) + : Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: Icon(fallbackIcon, color: colorScheme.onSurfaceVariant), + ), + ); + return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -557,25 +582,7 @@ class _SearchResultRowItem extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row( children: [ - ClipRRect( - borderRadius: BorderRadius.circular(coverBorderRadius), - child: hasValidImage - ? CachedCoverImage( - imageUrl: imageUrl!, - width: 56, - height: 56, - fit: BoxFit.cover, - ) - : Container( - width: 56, - height: 56, - color: colorScheme.surfaceContainerHighest, - child: Icon( - fallbackIcon, - color: colorScheme.onSurfaceVariant, - ), - ), - ), + heroTag != null ? Hero(tag: heroTag!, child: cover) : cover, const SizedBox(width: 12), Expanded( child: Column( @@ -636,6 +643,7 @@ class _SearchArtistItemWidget extends StatelessWidget { imageUrl: artist.imageUrl, coverBorderRadius: 28, fallbackIcon: Icons.person, + heroTag: 'search-artist-${artist.id}', title: artist.name, subtitle: Text( context.l10n.recentTypeArtist, @@ -671,6 +679,7 @@ class _SearchAlbumItemWidget extends StatelessWidget { imageUrl: album.imageUrl, coverBorderRadius: 10, fallbackIcon: Icons.album, + heroTag: 'search-album-${album.id}', title: album.name, subtitle: ClickableArtistName( artistName: album.artists.isNotEmpty @@ -917,6 +926,7 @@ class ExtensionAlbumScreen extends ConsumerStatefulWidget { final String? coverUrl; final String? initialAlbumType; final int? initialTotalTracks; + final Object? heroTag; const ExtensionAlbumScreen({ super.key, @@ -926,6 +936,7 @@ class ExtensionAlbumScreen extends ConsumerStatefulWidget { this.coverUrl, this.initialAlbumType, this.initialTotalTracks, + this.heroTag, }); @override @@ -1090,6 +1101,7 @@ class _ExtensionAlbumScreenState extends ConsumerState { extensionId: widget.extensionId, artistId: _artistId, artistName: _artistName, + heroTag: widget.heroTag, ); } } @@ -1234,6 +1246,7 @@ class ExtensionArtistScreen extends ConsumerStatefulWidget { final String artistId; final String artistName; final String? coverUrl; + final Object? heroTag; const ExtensionArtistScreen({ super.key, @@ -1241,6 +1254,7 @@ class ExtensionArtistScreen extends ConsumerStatefulWidget { required this.artistId, required this.artistName, this.coverUrl, + this.heroTag, }); @override @@ -1381,6 +1395,7 @@ class _ExtensionArtistScreenState extends ConsumerState { albums: _albums, topTracks: _topTracks, extensionId: widget.extensionId, // Skip Spotify/Deezer fetch + heroTag: widget.heroTag, ); } } diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 06a88cd7..063935d3 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -648,7 +648,7 @@ class _MainShellState extends ConsumerState } } -class _TabNavigator extends StatelessWidget { +class _TabNavigator extends StatefulWidget { final GlobalKey navigatorKey; final Widget child; final List observers; @@ -660,13 +660,29 @@ class _TabNavigator extends StatelessWidget { this.observers = const [], }); + @override + State<_TabNavigator> createState() => _TabNavigatorState(); +} + +class _TabNavigatorState extends State<_TabNavigator> { + // Nested navigators get no HeroController from MaterialApp; without one, + // Hero widgets on routes pushed inside a tab never fly. + final HeroController _heroController = + MaterialApp.createMaterialHeroController(); + + @override + void dispose() { + _heroController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Navigator( - key: navigatorKey, - observers: observers, + key: widget.navigatorKey, + observers: [_heroController, ...widget.observers], onGenerateInitialRoutes: (_, _) => [ - MaterialPageRoute(builder: (_) => child), + MaterialPageRoute(builder: (_) => widget.child), ], ); } diff --git a/lib/widgets/album_detail_header.dart b/lib/widgets/album_detail_header.dart index 31c64e69..1ef764bc 100644 --- a/lib/widgets/album_detail_header.dart +++ b/lib/widgets/album_detail_header.dart @@ -23,6 +23,7 @@ class AlbumDetailHeader extends StatelessWidget { this.appBarTitle, this.leading, this.backgroundColor, + this.heroTag, }); final String title; @@ -61,6 +62,10 @@ class AlbumDetailHeader extends StatelessWidget { final Color? backgroundColor; + /// Shared-element tag: when set, the cover flies from the list item that + /// pushed this screen (which wraps its thumbnail in a matching [Hero]). + final Object? heroTag; + /// Shrinks long titles so up to three lines fit the header. double _titleFontSize() { final length = title.trim().length; @@ -149,7 +154,7 @@ class AlbumDetailHeader extends StatelessWidget { final coverSize = (constraints.maxWidth * 0.5) .clamp(150.0, 210.0) .toDouble(); - return Container( + final cover = Container( width: coverSize, height: coverSize, decoration: BoxDecoration( @@ -169,6 +174,9 @@ class AlbumDetailHeader extends StatelessWidget { child: coverBuilder!(context, coverSize), ), ); + return heroTag != null + ? Hero(tag: heroTag!, child: cover) + : cover; }, ), const SizedBox(height: 20),