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 headerBgUrl =
_headerImageUrl ?? widget.headerImageUrl ?? widget.coverUrl;
final Widget headerBgImage = headerBgUrl != null
Widget headerBgImage() => headerBgUrl != null
? CachedNetworkImage(
imageUrl: highResCoverUrl(headerBgUrl) ?? headerBgUrl,
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(
title: widget.albumName,
expandedHeight: expandedHeight,
showTitleInAppBar: showTitleInAppBar,
backgroundColor: pageBackgroundColor,
heroTag: widget.heroTag,
background: hasMotion
? MotionHeaderBanner(videoUrl: motionUrl, fallback: headerBgImage)
: headerBgImage,
background: background,
blurAndScrimBackground: showSquareCover,
coverBuilder: showSquareCover
? (context, coverSize) => coverThumbUrl != null
+42 -24
View File
@@ -121,8 +121,18 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
int? _monthlyListeners;
String? _error;
Widget _heroWrap(Widget child) =>
widget.heroTag != null ? Hero(tag: widget.heroTag!, child: child) : child;
/// [shuttle], when given, is flown instead of re-instantiating [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;
final ScrollController _scrollController = ScrollController();
@@ -1170,34 +1180,42 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
fit: StackFit.expand,
children: [
if (hasMotionBanner)
MotionHeaderBanner(
videoUrl: headerVideoUrl,
fallback: hasValidImage
? CachedCoverImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
alignment: Alignment.topCenter,
memCacheWidth: 800,
placeholder: (context, url) => Container(
color: colorScheme.surfaceContainerHighest,
),
errorWidget: (context, url, error) => Container(
Builder(
builder: (context) {
Widget fallbackImage() => 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,
),
),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.person,
size: 80,
color: colorScheme.onSurfaceVariant,
),
),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.person,
size: 80,
color: colorScheme.onSurfaceVariant,
),
),
);
return _heroWrap(
MotionHeaderBanner(
videoUrl: headerVideoUrl!,
fallback: fallbackImage(),
),
shuttle: fallbackImage,
);
},
)
else if (hasValidImage)
_heroWrap(