From b864fafa825e93547028f7c86d2d870305659587 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 30 Jun 2026 06:20:47 +0700 Subject: [PATCH] feat(ui): stabilize album and playlist header layouts Keep header metadata and action buttons visible while track lists are still loading, disable download-all when empty, and show consistent placeholder artwork when cover art is missing. --- lib/screens/album_screen.dart | 154 +++++++++++++------------ lib/screens/playlist_screen.dart | 191 +++++++++++++++---------------- 2 files changed, 173 insertions(+), 172 deletions(-) diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index a968a76e..59b12712 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -256,12 +256,10 @@ class _AlbumScreenState extends ConsumerState { _artistId = artistId; _albumType = albumType; _albumTotalTracks = totalTracks; - _headerVideoUrl = - (headerVideo != null && headerVideo.isNotEmpty) + _headerVideoUrl = (headerVideo != null && headerVideo.isNotEmpty) ? headerVideo : _headerVideoUrl; - _headerImageUrl = - (headerImage != null && headerImage.isNotEmpty) + _headerImageUrl = (headerImage != null && headerImage.isNotEmpty) ? headerImage : _headerImageUrl; _audioTraits = (audioTraits != null && audioTraits.isNotEmpty) @@ -312,12 +310,10 @@ class _AlbumScreenState extends ConsumerState { _artistId = artistId; _albumType = albumType; _albumTotalTracks = totalTracks; - _headerVideoUrl = - (headerVideo != null && headerVideo.isNotEmpty) + _headerVideoUrl = (headerVideo != null && headerVideo.isNotEmpty) ? headerVideo : _headerVideoUrl; - _headerImageUrl = - (headerImage != null && headerImage.isNotEmpty) + _headerImageUrl = (headerImage != null && headerImage.isNotEmpty) ? headerImage : _headerImageUrl; _audioTraits = (audioTraits != null && audioTraits.isNotEmpty) @@ -424,12 +420,15 @@ class _AlbumScreenState extends ConsumerState { add(trait); } - return Wrap( - alignment: WrapAlignment.center, - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 0, - runSpacing: 4, - children: items, + return ConstrainedBox( + constraints: const BoxConstraints(minHeight: 20), + child: Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 0, + runSpacing: 4, + children: items, + ), ); } @@ -536,7 +535,7 @@ class _AlbumScreenState extends ConsumerState { motionUrl.trim().isNotEmpty && Uri.tryParse(motionUrl)?.hasAuthority == true; final coverThumbUrl = widget.coverUrl ?? _headerImageUrl; - final showSquareCover = !hasMotion && coverThumbUrl != null; + final showSquareCover = !hasMotion; _tallHeader = false; final expandedHeight = _calculateExpandedHeight(context); @@ -659,27 +658,41 @@ class _AlbumScreenState extends ConsumerState { ), child: ClipRRect( borderRadius: BorderRadius.circular(16), - child: CachedNetworkImage( - imageUrl: - _highResCoverUrl(coverThumbUrl) ?? - coverThumbUrl, - fit: BoxFit.cover, - width: coverSize, - height: coverSize, - memCacheWidth: cacheWidth, - cacheManager: CoverCacheManager.instance, - placeholder: (_, _) => Container( - color: colorScheme.surfaceContainerHighest, - ), - errorWidget: (_, _, _) => Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.album, - size: 48, - color: colorScheme.onSurfaceVariant, - ), - ), - ), + child: coverThumbUrl != null + ? CachedNetworkImage( + imageUrl: + _highResCoverUrl(coverThumbUrl) ?? + coverThumbUrl, + fit: BoxFit.cover, + width: coverSize, + height: coverSize, + memCacheWidth: cacheWidth, + cacheManager: + CoverCacheManager.instance, + placeholder: (_, _) => Container( + color: colorScheme + .surfaceContainerHighest, + ), + errorWidget: (_, _, _) => Container( + color: colorScheme + .surfaceContainerHighest, + child: Icon( + Icons.album, + size: 48, + color: + colorScheme.onSurfaceVariant, + ), + ), + ) + : Container( + color: colorScheme + .surfaceContainerHighest, + child: Icon( + Icons.album, + size: 48, + color: colorScheme.onSurfaceVariant, + ), + ), ), ); }, @@ -715,41 +728,42 @@ class _AlbumScreenState extends ConsumerState { overflow: TextOverflow.ellipsis, ), ], - if (tracks.isNotEmpty) ...[ - const SizedBox(height: 12), - _buildHeaderMeta(context, releaseDate), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _buildLoveAllButton(), - const SizedBox(width: 12), - Flexible( - child: FilledButton.icon( - onPressed: () => _downloadAll(context), - icon: Icon(Icons.download, size: 18), - label: Text( - context.l10n.downloadAllCount( - tracks.length, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - style: FilledButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black87, - minimumSize: const Size(0, 48), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), - ), + const SizedBox(height: 12), + _buildHeaderMeta(context, releaseDate), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildLoveAllButton(), + const SizedBox(width: 12), + Flexible( + child: FilledButton.icon( + onPressed: tracks.isEmpty + ? null + : () => _downloadAll(context), + icon: const Icon(Icons.download, size: 18), + label: Text( + context.l10n.downloadAllCount(tracks.length), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black87, + disabledBackgroundColor: Colors.white + .withValues(alpha: 0.45), + disabledForegroundColor: Colors.black54, + minimumSize: const Size(0, 48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), ), ), ), - const SizedBox(width: 12), - _buildAddToPlaylistButton(context), - ], - ), - ], + ), + const SizedBox(width: 12), + _buildAddToPlaylistButton(context), + ], + ), ], ), ), @@ -813,9 +827,7 @@ class _AlbumScreenState extends ConsumerState { lines.add(_formatReleaseDate(releaseDate)); } final countText = context.l10n.tracksCount(tracks.length); - lines.add( - totalMinutes > 0 ? '$countText • $totalMinutes min' : countText, - ); + lines.add(totalMinutes > 0 ? '$countText • $totalMinutes min' : countText); return SliverToBoxAdapter( child: Padding( diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index eaa78c5c..4fe5e5be 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -313,6 +313,19 @@ class _PlaylistScreenState extends ConsumerState { motionUrl != null && motionUrl.trim().isNotEmpty && Uri.tryParse(motionUrl)?.hasAuthority == true; + Widget playlistPlaceholder({double? size}) { + return Container( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + ), + child: Icon( + Icons.playlist_play, + size: size ?? 80, + color: colorScheme.onSurfaceVariant, + ), + ); + } return FlexibleSpaceBar( collapseMode: CollapseMode.pin, @@ -332,7 +345,7 @@ class _PlaylistScreenState extends ConsumerState { errorWidget: (_, _, _) => Container(color: colorScheme.surface), ) - : Container(color: colorScheme.surface), + : playlistPlaceholder(), ) else if (_coverUrl != null) ImageFiltered( @@ -348,34 +361,26 @@ class _PlaylistScreenState extends ConsumerState { ), ) else - Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.playlist_play, - size: 80, - color: colorScheme.onSurfaceVariant, - ), - ), + playlistPlaceholder(), Container(color: Colors.black.withValues(alpha: 0.35)), - if (_coverUrl != null) - Positioned( - left: 0, - right: 0, - bottom: 0, - height: expandedHeight * 0.65, - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withValues(alpha: 0.6), - ], - ), + Positioned( + left: 0, + right: 0, + bottom: 0, + height: expandedHeight * 0.65, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.6), + ], ), ), ), + ), Positioned.fill( child: AnimatedOpacity( duration: const Duration(milliseconds: 150), @@ -393,14 +398,11 @@ class _PlaylistScreenState extends ConsumerState { : MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ - if (_coverUrl != null && !hasMotion) ...[ + if (!hasMotion) ...[ Builder( builder: (context) { - final coverSize = - (constraints.maxWidth * 0.5).clamp( - 140.0, - 220.0, - ); + final coverSize = (constraints.maxWidth * 0.5) + .clamp(140.0, 220.0); return Container( width: coverSize, height: coverSize, @@ -416,28 +418,22 @@ class _PlaylistScreenState extends ConsumerState { ), ], ), - child: CachedCoverImage( - imageUrl: - _highResCoverUrl(_coverUrl) ?? - _coverUrl!, - fit: BoxFit.cover, - memCacheWidth: cacheWidth, - borderRadius: BorderRadius.circular(16), - placeholder: (_, _) => Container( - decoration: BoxDecoration( - color: - colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(16), - ), - ), - errorWidget: (_, _, _) => Container( - decoration: BoxDecoration( - color: - colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(16), - ), - ), - ), + child: _coverUrl != null + ? CachedCoverImage( + imageUrl: + _highResCoverUrl(_coverUrl) ?? + _coverUrl!, + fit: BoxFit.cover, + memCacheWidth: cacheWidth, + borderRadius: BorderRadius.circular( + 16, + ), + placeholder: (_, _) => + playlistPlaceholder(), + errorWidget: (_, _, _) => + playlistPlaceholder(size: 48), + ) + : playlistPlaceholder(size: 48), ); }, ), @@ -455,51 +451,49 @@ class _PlaylistScreenState extends ConsumerState { maxLines: 3, overflow: TextOverflow.ellipsis, ), - if (_tracks.isNotEmpty) ...[ - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.playlist_play, - size: 14, - color: Colors.white, - ), - const SizedBox(width: 4), - Text( - context.l10n.tracksCount(_tracks.length), - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ], - ), + const SizedBox(height: 34), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.center, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, children: [ - _buildLoveAllButton(), - const SizedBox(width: 12), - Flexible( - child: _buildDownloadAllCenterButton(context), + const Icon( + Icons.playlist_play, + size: 14, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + context.l10n.tracksCount(_tracks.length), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), ), - const SizedBox(width: 12), - _buildAddToPlaylistButton(context), ], ), - ], + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildLoveAllButton(), + const SizedBox(width: 12), + Flexible( + child: _buildDownloadAllCenterButton(context), + ), + const SizedBox(width: 12), + _buildAddToPlaylistButton(context), + ], + ), ], ), ), @@ -565,9 +559,7 @@ class _PlaylistScreenState extends ConsumerState { lines.add(_formatReleaseDate(releaseDate)); } final countText = context.l10n.tracksCount(tracks.length); - lines.add( - totalMinutes > 0 ? '$countText • $totalMinutes min' : countText, - ); + lines.add(totalMinutes > 0 ? '$countText • $totalMinutes min' : countText); return SliverToBoxAdapter( child: Padding( @@ -666,11 +658,8 @@ class _PlaylistScreenState extends ConsumerState { child: _PlaylistTrackItem( track: track, isInHistory: isInHistory, - onDownload: () => _downloadTrack( - context, - track, - playlistPosition: index + 1, - ), + onDownload: () => + _downloadTrack(context, track, playlistPosition: index + 1), ), ), );