From 2d9df7e1bcbfda2e2948501d84a105b57a8ee8a9 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 13 Jul 2026 21:34:23 +0700 Subject: [PATCH] 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. --- lib/screens/album_screen.dart | 20 ++++++++--- lib/screens/artist_screen.dart | 66 +++++++++++++++++++++------------- 2 files changed, 58 insertions(+), 28 deletions(-) diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 388491af..d1f8c00b 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -379,7 +379,7 @@ class _AlbumScreenState extends ConsumerState 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 ), ); + 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 diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 61c64c22..092cd211 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -121,8 +121,18 @@ class _ArtistScreenState extends ConsumerState 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 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(