mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-28 23:08:59 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user