fix(ui): hero continuity for motion (Apple Music) headers

Albums/artists with a motion banner have no square cover, so the Hero
had no destination: the open flight ended nowhere and there was no
return flight. The Hero now rides the full-bleed banner, flying the
still cover image as the shuttle so the video doesn't restart mid-air.
This commit is contained in:
zarzet
2026-07-14 09:09:57 +07:00
parent 16e2046d0f
commit 2d9df7e1bc
2 changed files with 58 additions and 28 deletions
+16 -4
View File
@@ -379,7 +379,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
final cacheWidth = coverCacheWidthForViewport(context); final cacheWidth = coverCacheWidthForViewport(context);
final headerBgUrl = final headerBgUrl =
_headerImageUrl ?? widget.headerImageUrl ?? widget.coverUrl; _headerImageUrl ?? widget.headerImageUrl ?? widget.coverUrl;
final Widget headerBgImage = headerBgUrl != null Widget headerBgImage() => headerBgUrl != null
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: highResCoverUrl(headerBgUrl) ?? headerBgUrl, imageUrl: highResCoverUrl(headerBgUrl) ?? headerBgUrl,
fit: BoxFit.cover, fit: BoxFit.cover,
@@ -397,15 +397,27 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
), ),
); );
Widget background = hasMotion
? MotionHeaderBanner(videoUrl: motionUrl, fallback: headerBgImage())
: headerBgImage();
// With a motion banner there is no square cover, so the Hero rides the
// full-bleed background instead; the shuttle flies the still image (a
// shuttle-built banner would restart the video mid-flight).
if (hasMotion && widget.heroTag != null) {
background = Hero(
tag: widget.heroTag!,
flightShuttleBuilder: (_, _, _, _, _) => headerBgImage(),
child: background,
);
}
return AlbumDetailHeader( return AlbumDetailHeader(
title: widget.albumName, title: widget.albumName,
expandedHeight: expandedHeight, expandedHeight: expandedHeight,
showTitleInAppBar: showTitleInAppBar, showTitleInAppBar: showTitleInAppBar,
backgroundColor: pageBackgroundColor, backgroundColor: pageBackgroundColor,
heroTag: widget.heroTag, heroTag: widget.heroTag,
background: hasMotion background: background,
? MotionHeaderBanner(videoUrl: motionUrl, fallback: headerBgImage)
: headerBgImage,
blurAndScrimBackground: showSquareCover, blurAndScrimBackground: showSquareCover,
coverBuilder: showSquareCover coverBuilder: showSquareCover
? (context, coverSize) => coverThumbUrl != null ? (context, coverSize) => coverThumbUrl != null
+42 -24
View File
@@ -121,8 +121,18 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
int? _monthlyListeners; int? _monthlyListeners;
String? _error; String? _error;
Widget _heroWrap(Widget child) => /// [shuttle], when given, is flown instead of re-instantiating [child]
widget.heroTag != null ? Hero(tag: widget.heroTag!, child: child) : child; /// (needed for motion banners, which would restart their video mid-flight).
Widget _heroWrap(Widget child, {Widget Function()? shuttle}) =>
widget.heroTag != null
? Hero(
tag: widget.heroTag!,
flightShuttleBuilder: shuttle == null
? null
: (_, _, _, _, _) => shuttle(),
child: child,
)
: child;
bool _showTitleInAppBar = false; bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
@@ -1170,34 +1180,42 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
if (hasMotionBanner) if (hasMotionBanner)
MotionHeaderBanner( Builder(
videoUrl: headerVideoUrl, builder: (context) {
fallback: hasValidImage Widget fallbackImage() => hasValidImage
? CachedCoverImage( ? CachedCoverImage(
imageUrl: imageUrl, imageUrl: imageUrl!,
fit: BoxFit.cover, fit: BoxFit.cover,
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
memCacheWidth: 800, memCacheWidth: 800,
placeholder: (context, url) => Container( placeholder: (context, url) => Container(
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
), ),
errorWidget: (context, url, error) => Container( errorWidget: (context, url, error) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.person,
size: 80,
color: colorScheme.onSurfaceVariant,
),
),
)
: Container(
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
child: Icon( child: Icon(
Icons.person, Icons.person,
size: 80, size: 80,
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
), );
) return _heroWrap(
: Container( MotionHeaderBanner(
color: colorScheme.surfaceContainerHighest, videoUrl: headerVideoUrl!,
child: Icon( fallback: fallbackImage(),
Icons.person, ),
size: 80, shuttle: fallbackImage,
color: colorScheme.onSurfaceVariant, );
), },
),
) )
else if (hasValidImage) else if (hasValidImage)
_heroWrap( _heroWrap(