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.
This commit is contained in:
zarzet
2026-06-30 06:20:47 +07:00
parent ee5ab1a751
commit b864fafa82
2 changed files with 173 additions and 172 deletions
+83 -71
View File
@@ -256,12 +256,10 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
_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<AlbumScreen> {
_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<AlbumScreen> {
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<AlbumScreen> {
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<AlbumScreen> {
),
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<AlbumScreen> {
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<AlbumScreen> {
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(
+90 -101
View File
@@ -313,6 +313,19 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
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<PlaylistScreen> {
errorWidget: (_, _, _) =>
Container(color: colorScheme.surface),
)
: Container(color: colorScheme.surface),
: playlistPlaceholder(),
)
else if (_coverUrl != null)
ImageFiltered(
@@ -348,34 +361,26 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
),
)
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<PlaylistScreen> {
: 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<PlaylistScreen> {
),
],
),
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<PlaylistScreen> {
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<PlaylistScreen> {
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<PlaylistScreen> {
child: _PlaylistTrackItem(
track: track,
isInHistory: isInHistory,
onDownload: () => _downloadTrack(
context,
track,
playlistPosition: index + 1,
),
onDownload: () =>
_downloadTrack(context, track, playlistPosition: index + 1),
),
),
);