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.
This commit is contained in:
zarzet
2026-07-14 09:09:49 +07:00
parent 702fc9825e
commit c580280890
7 changed files with 180 additions and 93 deletions
+5
View File
@@ -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<AlbumScreen>
expandedHeight: expandedHeight,
showTitleInAppBar: showTitleInAppBar,
backgroundColor: pageBackgroundColor,
heroTag: widget.heroTag,
background: hasMotion
? MotionHeaderBanner(videoUrl: motionUrl, fallback: headerBgImage)
: headerBgImage,
+61 -39
View File
@@ -88,6 +88,10 @@ class ArtistScreen extends ConsumerStatefulWidget {
final List<Track>? 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<ArtistScreen>
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<ArtistScreen>
),
)
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<ArtistScreen>
tileSize: tileSize,
sectionHeight: sectionHeight,
showTypeBadge: showTypeBadge,
sectionKey: title,
),
);
},
@@ -1723,8 +1734,12 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
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<ArtistScreen>
if (isSelectionMode) {
toggleSelection(album.id);
} else {
_navigateToAlbum(album);
_navigateToAlbum(album, heroTag: albumHeroTag);
}
},
onLongPress: () {
@@ -1757,22 +1772,35 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
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<ArtistScreen>
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<ArtistScreen>
);
}
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<ArtistScreen>
coverUrl: album.coverUrl,
initialAlbumType: album.albumType,
initialTotalTracks: album.totalTracks,
heroTag: heroTag,
),
),
);
@@ -1914,6 +1935,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
albumId: album.id,
albumName: album.name,
coverUrl: album.coverUrl,
heroTag: heroTag,
),
),
);
+5
View File
@@ -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<DownloadedAlbumScreen>
title: widget.albumName,
expandedHeight: expandedHeight,
showTitleInAppBar: showTitleInAppBar,
heroTag: widget.heroTag,
background: background,
blurAndScrimBackground:
embeddedCoverPath != null || widget.coverUrl != null,
+27 -11
View File
@@ -2321,18 +2321,21 @@ class _HomeTabState extends ConsumerState<HomeTab>
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<HomeTab>
);
}
static String _recentHeroTag(RecentAccessItem item) =>
'recent-${item.type.name}-${item.id}';
Future<void> _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<HomeTab>
artistId: item.id,
artistName: item.name,
coverUrl: item.imageUrl,
heroTag: heroTag,
),
),
);
@@ -2429,6 +2437,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
artistId: item.id,
artistName: item.name,
coverUrl: item.imageUrl,
heroTag: heroTag,
),
),
);
@@ -2443,6 +2452,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
albumName: item.name,
artistName: item.subtitle ?? '',
coverUrl: item.imageUrl,
heroTag: heroTag,
),
),
);
@@ -2455,6 +2465,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
albumId: item.id,
albumName: item.name,
coverUrl: item.imageUrl,
heroTag: heroTag,
),
),
);
@@ -2466,6 +2477,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
albumId: item.id,
albumName: item.name,
coverUrl: item.imageUrl,
heroTag: heroTag,
),
),
);
@@ -3234,6 +3246,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
artistId: artistId,
artistName: artistName,
coverUrl: imageUrl,
heroTag: 'search-artist-$artistId',
),
),
);
@@ -3261,6 +3274,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
albumName: album.name,
coverUrl: album.imageUrl,
tracks: const [],
heroTag: 'search-album-${album.id}',
),
),
);
@@ -3326,6 +3340,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
coverUrl: albumItem.coverUrl,
initialAlbumType: albumItem.albumType,
initialTotalTracks: albumItem.totalTracks,
heroTag: 'search-album-${albumItem.id}',
),
),
);
@@ -3397,6 +3412,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
artistId: artistItem.id,
artistName: artistItem.name,
coverUrl: artistItem.coverUrl,
heroTag: 'search-artist-${artistItem.id}',
),
),
);
+53 -38
View File
@@ -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<ExtensionAlbumScreen> {
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<ExtensionArtistScreen> {
albums: _albums,
topTracks: _topTracks,
extensionId: widget.extensionId, // Skip Spotify/Deezer fetch
heroTag: widget.heroTag,
);
}
}
+20 -4
View File
@@ -648,7 +648,7 @@ class _MainShellState extends ConsumerState<MainShell>
}
}
class _TabNavigator extends StatelessWidget {
class _TabNavigator extends StatefulWidget {
final GlobalKey<NavigatorState> navigatorKey;
final Widget child;
final List<NavigatorObserver> 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<void>(builder: (_) => child),
MaterialPageRoute<void>(builder: (_) => widget.child),
],
);
}
+9 -1
View File
@@ -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),