refactor(screens): migrate playlist and library-folder headers to AlbumDetailHeader

Both carried yet another copy of the collapsing detail header.
AlbumDetailHeader gains appBarTitle (selection-aware toolbar title) and a
leading override for the folder screen's close/back switch. The playlist
header adopts the album layout (bottom-anchored content, 0.85 gradient,
length-adaptive title size, scrim only when blurred) instead of its
drifted centered variant; motion-banner and pill-chip meta are preserved
through the slots.
This commit is contained in:
zarzet
2026-07-11 17:18:01 +07:00
parent 05415d96d5
commit 59a5ca622d
3 changed files with 234 additions and 517 deletions
+140 -274
View File
@@ -1,9 +1,9 @@
import 'dart:io';
import 'dart:ui' show ImageFilter;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:spotiflac_android/widgets/album_detail_header.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
@@ -76,13 +76,6 @@ class _LibraryTracksFolderScreenState
return (mediaSize.height * 0.6).clamp(400.0, 580.0);
}
double _folderTitleFontSize(String title) {
final length = title.trim().length;
if (length > 45) return 18;
if (length > 30) return 21;
return 24;
}
IconData _modeIcon() {
return switch (widget.mode) {
LibraryTracksFolderMode.wishlist => Icons.bookmark,
@@ -532,29 +525,146 @@ class _LibraryTracksFolderScreenState
customCoverPath != null && customCoverPath.isNotEmpty;
final hasCoverUrl = coverUrl != null;
return SliverAppBar(
final dpr = MediaQuery.devicePixelRatioOf(context);
final cacheWidth = (MediaQuery.sizeOf(context).width * dpr).round().clamp(
320,
2048,
);
final coverFallback = Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(_modeIcon(), size: 80, color: colorScheme.onSurfaceVariant),
);
Widget squarePlaceholder() => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(_modeIcon(), size: 48, color: colorScheme.onSurfaceVariant),
);
final Widget background = hasCustomCover
? Image.file(
File(customCoverPath),
fit: BoxFit.cover,
cacheWidth: cacheWidth,
filterQuality: FilterQuality.low,
gaplessPlayback: true,
frameBuilder: (_, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) {
return child;
}
return coverFallback;
},
errorBuilder: (_, _, _) => coverFallback,
)
: hasCoverUrl
? _isCoverLocalPath(coverUrl)
? Image.file(
File(coverUrl),
fit: BoxFit.cover,
cacheWidth: cacheWidth,
filterQuality: FilterQuality.low,
gaplessPlayback: true,
frameBuilder: (_, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) {
return child;
}
return Container(color: colorScheme.surface);
},
errorBuilder: (_, _, _) =>
Container(color: colorScheme.surface),
)
: CachedNetworkImage(
imageUrl: highResCoverUrl(coverUrl) ?? coverUrl,
fit: BoxFit.cover,
memCacheWidth: cacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => Container(color: colorScheme.surface),
errorWidget: (_, _, _) =>
Container(color: colorScheme.surface),
)
: coverFallback;
return AlbumDetailHeader(
title: title,
appBarTitle: _isSelectionMode
? context.l10n.selectionSelected(_selectedKeys.length)
: title,
expandedHeight: expandedHeight,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
title: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _showTitleInAppBar ? 1.0 : 0.0,
child: Text(
_isSelectionMode
? context.l10n.selectionSelected(_selectedKeys.length)
: title,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
fontSize: 16,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
actions: [
showTitleInAppBar: _showTitleInAppBar,
background: background,
blurAndScrimBackground: hasCustomCover || hasCoverUrl,
coverBuilder: (context, coverSize) {
if (hasCustomCover) {
return Image.file(
File(customCoverPath),
fit: BoxFit.cover,
width: coverSize,
height: coverSize,
cacheWidth: cacheWidth,
gaplessPlayback: true,
errorBuilder: (_, _, _) => squarePlaceholder(),
);
}
if (hasCoverUrl && _isCoverLocalPath(coverUrl)) {
return Image.file(
File(coverUrl),
fit: BoxFit.cover,
width: coverSize,
height: coverSize,
cacheWidth: cacheWidth,
gaplessPlayback: true,
errorBuilder: (_, _, _) => squarePlaceholder(),
);
}
if (hasCoverUrl) {
return CachedNetworkImage(
imageUrl: highResCoverUrl(coverUrl) ?? coverUrl,
fit: BoxFit.cover,
width: coverSize,
height: coverSize,
memCacheWidth: cacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => squarePlaceholder(),
errorWidget: (_, _, _) => squarePlaceholder(),
);
}
return squarePlaceholder();
},
meta: entries.isNotEmpty
? 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: [
Icon(_modeIcon(), size: 14, color: Colors.white),
const SizedBox(width: 4),
Text(
context.l10n.tracksCount(entries.length),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
)
: null,
actions: entries.isNotEmpty
? Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildHeaderActionPlaceholder(),
const SizedBox(width: 12),
_buildDownloadAllCenterButton(entries),
const SizedBox(width: 12),
_buildHeaderActionPlaceholder(),
],
)
: null,
appBarActions: [
if (isPlaylistMode && !_isSelectionMode) ...[
IconButton(
tooltip: context.l10n.collectionRenamePlaylist,
@@ -593,250 +703,6 @@ class _LibraryTracksFolderScreenState
),
],
],
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final collapseRatio =
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3;
final dpr = MediaQuery.devicePixelRatioOf(context);
final cacheWidth = (MediaQuery.sizeOf(context).width * dpr)
.round()
.clamp(320, 2048);
final coverFallback = Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
_modeIcon(),
size: 80,
color: colorScheme.onSurfaceVariant,
),
);
return FlexibleSpaceBar(
collapseMode: CollapseMode.pin,
background: Stack(
fit: StackFit.expand,
children: [
if (hasCustomCover)
ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32),
child: Image.file(
File(customCoverPath),
fit: BoxFit.cover,
cacheWidth: cacheWidth,
filterQuality: FilterQuality.low,
gaplessPlayback: true,
frameBuilder: (_, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) {
return child;
}
return coverFallback;
},
errorBuilder: (_, _, _) => coverFallback,
),
)
else if (hasCoverUrl)
_isCoverLocalPath(coverUrl)
? ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32),
child: Image.file(
File(coverUrl),
fit: BoxFit.cover,
cacheWidth: cacheWidth,
filterQuality: FilterQuality.low,
gaplessPlayback: true,
frameBuilder:
(_, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) {
return child;
}
return Container(color: colorScheme.surface);
},
errorBuilder: (_, _, _) =>
Container(color: colorScheme.surface),
),
)
: ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32),
child: CachedNetworkImage(
imageUrl: highResCoverUrl(coverUrl) ?? coverUrl,
fit: BoxFit.cover,
memCacheWidth: cacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) =>
Container(color: colorScheme.surface),
errorWidget: (_, _, _) =>
Container(color: colorScheme.surface),
),
)
else
coverFallback,
if (hasCustomCover || hasCoverUrl)
Container(color: Colors.black.withValues(alpha: 0.35)),
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.85),
],
),
),
),
),
Positioned(
left: 20,
right: 20,
bottom: 40,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: showContent ? 1.0 : 0.0,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Builder(
builder: (context) {
final coverSize = (constraints.maxWidth * 0.5)
.clamp(150.0, 210.0)
.toDouble();
Widget squarePlaceholder() => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
_modeIcon(),
size: 48,
color: colorScheme.onSurfaceVariant,
),
);
Widget coverChild;
if (hasCustomCover) {
coverChild = Image.file(
File(customCoverPath),
fit: BoxFit.cover,
width: coverSize,
height: coverSize,
cacheWidth: cacheWidth,
gaplessPlayback: true,
errorBuilder: (_, _, _) => squarePlaceholder(),
);
} else if (hasCoverUrl &&
_isCoverLocalPath(coverUrl)) {
coverChild = Image.file(
File(coverUrl),
fit: BoxFit.cover,
width: coverSize,
height: coverSize,
cacheWidth: cacheWidth,
gaplessPlayback: true,
errorBuilder: (_, _, _) => squarePlaceholder(),
);
} else if (hasCoverUrl) {
coverChild = CachedNetworkImage(
imageUrl: highResCoverUrl(coverUrl) ?? coverUrl,
fit: BoxFit.cover,
width: coverSize,
height: coverSize,
memCacheWidth: cacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => squarePlaceholder(),
errorWidget: (_, _, _) => squarePlaceholder(),
);
} else {
coverChild = squarePlaceholder();
}
return Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.45),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: coverChild,
),
);
},
),
const SizedBox(height: 20),
Text(
title,
style: TextStyle(
color: Colors.white,
fontSize: _folderTitleFontSize(title),
fontWeight: FontWeight.bold,
height: 1.2,
),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
if (entries.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: [
Icon(
_modeIcon(),
size: 14,
color: Colors.white,
),
const SizedBox(width: 4),
Text(
context.l10n.tracksCount(entries.length),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildHeaderActionPlaceholder(),
const SizedBox(width: 12),
_buildDownloadAllCenterButton(entries),
const SizedBox(width: 12),
_buildHeaderActionPlaceholder(),
],
),
],
],
),
),
),
],
),
stretchModes: const [StretchMode.zoomBackground],
);
},
),
leading: IconButton(
tooltip: _isSelectionMode
? MaterialLocalizations.of(context).closeButtonTooltip
+72 -231
View File
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'dart:ui' show ImageFilter;
import 'package:spotiflac_android/widgets/album_detail_header.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
@@ -251,242 +251,83 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final expandedHeight = _calculateExpandedHeight(context);
final cacheWidth = coverCacheWidthForViewport(context);
final motionUrl = _headerVideoUrl;
final hasMotion =
motionUrl != null &&
motionUrl.trim().isNotEmpty &&
Uri.tryParse(motionUrl)?.hasAuthority == true;
Widget playlistPlaceholder({double? size}) {
return Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.playlist_play,
size: size ?? 80,
color: colorScheme.onSurfaceVariant,
),
);
}
return SliverAppBar(
final Widget coverImage = _coverUrl != null
? CachedCoverImage(
imageUrl: highResCoverUrl(_coverUrl) ?? _coverUrl!,
fit: BoxFit.cover,
memCacheWidth: cacheWidth,
placeholder: (_, _) => Container(color: colorScheme.surface),
errorWidget: (_, _, _) => Container(color: colorScheme.surface),
)
: playlistPlaceholder();
return AlbumDetailHeader(
title: _playlistName,
expandedHeight: expandedHeight,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
title: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _showTitleInAppBar ? 1.0 : 0.0,
child: Text(
_playlistName,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
fontSize: 16,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
showTitleInAppBar: _showTitleInAppBar,
background: hasMotion
? MotionHeaderBanner(videoUrl: motionUrl, fallback: coverImage)
: coverImage,
blurAndScrimBackground: !hasMotion && _coverUrl != null,
coverBuilder: hasMotion
? null
: (context, coverSize) => _coverUrl != null
? CachedCoverImage(
imageUrl: highResCoverUrl(_coverUrl) ?? _coverUrl!,
fit: BoxFit.cover,
memCacheWidth: cacheWidth,
placeholder: (_, _) => playlistPlaceholder(),
errorWidget: (_, _, _) => playlistPlaceholder(size: 48),
)
: playlistPlaceholder(size: 48),
meta: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(20),
),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final collapseRatio =
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3;
final cacheWidth = coverCacheWidthForViewport(context);
final motionUrl = _headerVideoUrl;
final hasMotion =
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: 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,
),
child: Icon(
Icons.playlist_play,
size: size ?? 80,
color: colorScheme.onSurfaceVariant,
),
);
}
return FlexibleSpaceBar(
collapseMode: CollapseMode.pin,
background: Stack(
fit: StackFit.expand,
children: [
if (hasMotion)
MotionHeaderBanner(
videoUrl: motionUrl,
fallback: _coverUrl != null
? CachedCoverImage(
imageUrl: highResCoverUrl(_coverUrl) ?? _coverUrl!,
fit: BoxFit.cover,
memCacheWidth: cacheWidth,
placeholder: (_, _) =>
Container(color: colorScheme.surface),
errorWidget: (_, _, _) =>
Container(color: colorScheme.surface),
)
: playlistPlaceholder(),
)
else if (_coverUrl != null)
ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32),
child: CachedCoverImage(
imageUrl: highResCoverUrl(_coverUrl) ?? _coverUrl!,
fit: BoxFit.cover,
memCacheWidth: cacheWidth,
placeholder: (_, _) =>
Container(color: colorScheme.surface),
errorWidget: (_, _, _) =>
Container(color: colorScheme.surface),
),
)
else
playlistPlaceholder(),
Container(color: Colors.black.withValues(alpha: 0.35)),
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),
opacity: showContent ? 1.0 : 0.0,
child: Padding(
padding: const EdgeInsets.fromLTRB(
20,
kToolbarHeight + 8,
20,
28,
),
child: Column(
mainAxisAlignment: hasMotion
? MainAxisAlignment.end
: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (!hasMotion) ...[
Builder(
builder: (context) {
final coverSize = (constraints.maxWidth * 0.5)
.clamp(140.0, 220.0);
return Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(
alpha: 0.45,
),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
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),
);
},
),
const SizedBox(height: 20),
],
Text(
_playlistName,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
height: 1.2,
),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 34),
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: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildLoveAllButton(),
const SizedBox(width: 12),
Flexible(
child: _buildDownloadAllCenterButton(context),
),
const SizedBox(width: 12),
_buildAddToPlaylistButton(context),
],
),
],
),
),
),
),
],
),
stretchModes: const [StretchMode.zoomBackground],
);
},
),
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: const Icon(Icons.arrow_back, color: Colors.white),
],
),
onPressed: () => Navigator.pop(context),
),
actions: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildLoveAllButton(),
const SizedBox(width: 12),
Flexible(child: _buildDownloadAllCenterButton(context)),
const SizedBox(width: 12),
_buildAddToPlaylistButton(context),
],
),
);
}
+22 -12
View File
@@ -20,6 +20,8 @@ class AlbumDetailHeader extends StatelessWidget {
this.meta,
this.actions,
this.appBarActions,
this.appBarTitle,
this.leading,
this.backgroundColor,
});
@@ -50,6 +52,13 @@ class AlbumDetailHeader extends StatelessWidget {
/// Extra toolbar actions (top-right).
final List<Widget>? appBarActions;
/// Toolbar title when it should differ from [title] (e.g. a selection
/// count); defaults to [title].
final String? appBarTitle;
/// Replaces the default circular back button.
final Widget? leading;
final Color? backgroundColor;
/// Shrinks long titles so up to three lines fit the header.
@@ -74,7 +83,7 @@ class AlbumDetailHeader extends StatelessWidget {
duration: const Duration(milliseconds: 200),
opacity: showTitleInAppBar ? 1.0 : 0.0,
child: Text(
title,
appBarTitle ?? title,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
@@ -198,18 +207,19 @@ class AlbumDetailHeader extends StatelessWidget {
);
},
),
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle,
leading: leading ??
IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: const Icon(Icons.arrow_back, color: Colors.white),
),
onPressed: () => Navigator.pop(context),
),
child: const Icon(Icons.arrow_back, color: Colors.white),
),
onPressed: () => Navigator.pop(context),
),
actions: appBarActions,
);
}