redesign: full-screen cover art with parallax scroll across all detail screens

Replace blurred background + centered cover thumbnail with full-screen
cover art, dark gradient overlay, and parallax collapse mode for a
consistent Apple Music-inspired design across album, playlist, downloaded
album, local album, and track metadata screens. Remove select button UI
(users enter selection via long-press), upgrade cover resolution for
Spotify/Deezer CDN, and move track/album info into the overlay.
This commit is contained in:
zarzet
2026-02-19 00:28:12 +07:00
parent 5161ac8f77
commit caf68c8137
6 changed files with 821 additions and 1104 deletions
+174 -219
View File
@@ -1,5 +1,3 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
@@ -117,12 +115,38 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
void _onScroll() {
final shouldShow = _scrollController.offset > 280;
final expandedHeight = _calculateExpandedHeight(context);
final shouldShow = _scrollController.offset > (expandedHeight - kToolbarHeight - 20);
if (shouldShow != _showTitleInAppBar) {
setState(() => _showTitleInAppBar = shouldShow);
}
}
double _calculateExpandedHeight(BuildContext context) {
final mediaSize = MediaQuery.of(context).size;
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
}
/// Upgrade cover URL to a reasonable resolution for full-screen display.
/// Spotify CDN only has 300, 640, ~2000 — we stay at 640 (no intermediate).
/// Deezer CDN: upgrade to 1000x1000 (available: 56, 250, 500, 1000, 1400, 1800).
String? _highResCoverUrl(String? url) {
if (url == null) return null;
// Spotify CDN: upgrade 300 → 640 only (no intermediate between 640 and 2000)
if (url.contains('ab67616d00001e02')) {
return url.replaceAll('ab67616d00001e02', 'ab67616d0000b273');
}
// Deezer CDN: upgrade to 1000x1000
final deezerRegex = RegExp(r'/(\d+)x(\d+)-(\d+)-(\d+)-(\d+)-(\d+)\.jpg$');
if (url.contains('cdn-images.dzcdn.net') && deezerRegex.hasMatch(url)) {
return url.replaceAllMapped(
deezerRegex,
(m) => '/1000x1000-${m[3]}-${m[4]}-${m[5]}-${m[6]}.jpg',
);
}
return url;
}
String _formatReleaseDate(String date) {
if (date.length >= 10) {
final parts = date.substring(0, 10).split('-');
@@ -223,7 +247,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
),
),
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
_buildTrackListHeader(context, colorScheme),
_buildTrackList(context, colorScheme, tracks),
],
const SliverToBoxAdapter(child: SizedBox(height: 32)),
@@ -233,14 +256,10 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final mediaSize = MediaQuery.of(context).size;
final screenWidth = mediaSize.width;
final shortestSide = mediaSize.shortestSide;
final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0);
final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0);
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
final expandedHeight = _calculateExpandedHeight(context);
final tracks = _tracks ?? [];
final artistName = tracks.isNotEmpty ? tracks.first.artistName : null;
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
return SliverAppBar(
expandedHeight: expandedHeight,
@@ -268,25 +287,17 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3;
final dpr = MediaQuery.devicePixelRatioOf(
context,
).clamp(1.0, 3.0).toDouble();
final backgroundMemCacheWidth = (constraints.maxWidth * dpr)
.round()
.clamp(720, 1440)
.toInt();
return FlexibleSpaceBar(
collapseMode: CollapseMode.none,
collapseMode: CollapseMode.parallax,
background: Stack(
fit: StackFit.expand,
children: [
// Blurred cover background
// Full-screen cover background (no blur, full resolution)
if (widget.coverUrl != null)
CachedNetworkImage(
imageUrl: widget.coverUrl!,
imageUrl: _highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: backgroundMemCacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) =>
Container(color: colorScheme.surface),
@@ -294,80 +305,167 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
Container(color: colorScheme.surface),
)
else
Container(color: colorScheme.surface),
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(
color: colorScheme.surface.withValues(alpha: 0.4),
Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
size: 80,
color: colorScheme.onSurfaceVariant,
),
),
),
// Bottom gradient for readability
Positioned(
left: 0,
right: 0,
bottom: 0,
height: bottomGradientHeight,
height: expandedHeight * 0.65,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.surface.withValues(alpha: 0.0),
colorScheme.surface,
Colors.transparent,
Colors.black.withValues(alpha: 0.85),
],
),
),
),
),
AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: showContent ? 1.0 : 0.0,
child: Center(
child: Padding(
padding: EdgeInsets.only(top: coverTopPadding),
child: Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.4),
blurRadius: 30,
offset: const Offset(0, 15),
),
],
// Album info overlay at bottom
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: [
Text(
widget.albumName,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
height: 1.2,
),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: widget.coverUrl != null
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(),
cacheManager: CoverCacheManager.instance,
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
size: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
if (artistName != null && artistName.isNotEmpty) ...[
const SizedBox(height: 6),
GestureDetector(
onTap: () =>
_navigateToArtist(context, artistName),
child: Text(
artistName,
style: TextStyle(
color: colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
if (tracks.isNotEmpty) ...[
const SizedBox(height: 12),
Wrap(
alignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
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.music_note,
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,
),
),
],
),
),
if (releaseDate != null &&
releaseDate.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: [
const Icon(
Icons.calendar_today,
size: 14,
color: Colors.white,
),
const SizedBox(width: 4),
Text(
_formatReleaseDate(releaseDate),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
),
),
],
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () => _downloadAll(context),
icon: const Icon(Icons.download, size: 18),
label: Text(
context.l10n.downloadAllCount(tracks.length),
),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
),
],
],
),
),
),
],
),
stretchModes: const [
StretchMode.zoomBackground,
StretchMode.blurBackground,
],
stretchModes: const [StretchMode.zoomBackground],
);
},
),
@@ -375,10 +473,10 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.8),
color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
child: const Icon(Icons.arrow_back, color: Colors.white),
),
onPressed: () => Navigator.pop(context),
),
@@ -386,151 +484,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
final tracks = _tracks ?? [];
final artistName = tracks.isNotEmpty ? tracks.first.artistName : null;
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.albumName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
if (artistName != null && artistName.isNotEmpty) ...[
const SizedBox(height: 4),
GestureDetector(
onTap: () => _navigateToArtist(context, artistName),
child: Text(
artistName,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.primary,
),
),
),
],
const SizedBox(height: 12),
if (tracks.isNotEmpty)
Wrap(
spacing: 8,
runSpacing: 8,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.music_note,
size: 14,
color: colorScheme.onSecondaryContainer,
),
const SizedBox(width: 4),
Text(
context.l10n.tracksCount(tracks.length),
style: TextStyle(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
if (releaseDate != null && releaseDate.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.calendar_today,
size: 14,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 4),
Text(
_formatReleaseDate(releaseDate),
style: TextStyle(
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
],
),
if (tracks.isNotEmpty) ...[
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () => _downloadAll(context),
icon: const Icon(Icons.download, size: 18),
label: Text(context.l10n.downloadAllCount(tracks.length)),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
),
],
],
),
),
),
),
);
}
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
child: Row(
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text(
context.l10n.tracksHeader,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
],
),
),
);
// Info is now displayed in the full-screen cover overlay
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
Widget _buildTrackList(
+51 -46
View File
@@ -1100,63 +1100,68 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
left: 16,
right: 16,
bottom: 16,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
widget.artistName,
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: [
Shadow(
offset: const Offset(0, 1),
blurRadius: 4,
color: Colors.black.withValues(alpha: 0.5),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.artistName,
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: [
Shadow(
offset: const Offset(0, 1),
blurRadius: 4,
color: Colors.black.withValues(alpha: 0.5),
),
],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (listenersText != null) ...[
const SizedBox(height: 4),
Text(
listenersText,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.white.withValues(alpha: 0.8),
shadows: [
Shadow(
offset: const Offset(0, 1),
blurRadius: 2,
color: Colors.black.withValues(alpha: 0.5),
if (listenersText != null) ...[
const SizedBox(height: 4),
Text(
listenersText,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.white.withValues(alpha: 0.8),
shadows: [
Shadow(
offset: const Offset(0, 1),
blurRadius: 2,
color: Colors.black.withValues(alpha: 0.5),
),
],
),
),
],
),
],
),
],
// Download Discography button
),
// Download Discography button (icon only, right-aligned)
if (hasDiscography && !_isSelectionMode) ...[
const SizedBox(height: 12),
SizedBox(
height: 40,
child: FilledButton.icon(
const SizedBox(width: 12),
Container(
width: 52,
height: 52,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: IconButton(
onPressed: () => _showDiscographyOptions(
context,
colorScheme,
albums,
),
icon: const Icon(Icons.download, size: 18),
label: Text(context.l10n.discographyDownload),
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
padding: const EdgeInsets.symmetric(horizontal: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
icon: const Icon(Icons.download_rounded, size: 26),
color: Colors.black87,
tooltip: context.l10n.discographyDownload,
),
),
],
+137 -224
View File
@@ -1,5 +1,4 @@
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -79,12 +78,34 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}
void _onScroll() {
final shouldShow = _scrollController.offset > 280;
final expandedHeight = _calculateExpandedHeight(context);
final shouldShow = _scrollController.offset > (expandedHeight - kToolbarHeight - 20);
if (shouldShow != _showTitleInAppBar) {
setState(() => _showTitleInAppBar = shouldShow);
}
}
double _calculateExpandedHeight(BuildContext context) {
final mediaSize = MediaQuery.of(context).size;
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
}
/// Upgrade cover URL to a reasonable resolution for full-screen display.
String? _highResCoverUrl(String? url) {
if (url == null) return null;
if (url.contains('ab67616d00001e02')) {
return url.replaceAll('ab67616d00001e02', 'ab67616d0000b273');
}
final deezerRegex = RegExp(r'/(\d+)x(\d+)-(\d+)-(\d+)-(\d+)-(\d+)\.jpg$');
if (url.contains('cdn-images.dzcdn.net') && deezerRegex.hasMatch(url)) {
return url.replaceAllMapped(
deezerRegex,
(m) => '/1000x1000-${m[3]}-${m[4]}-${m[5]}-${m[6]}.jpg',
);
}
return url;
}
/// Get tracks for this album from history provider (reactive)
List<DownloadHistoryItem> _getAlbumTracks(
List<DownloadHistoryItem> allItems,
@@ -359,7 +380,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
slivers: [
_buildAppBar(context, colorScheme, tracks),
_buildInfoCard(context, colorScheme, tracks),
_buildTrackListHeader(context, colorScheme, tracks),
_buildTrackList(context, colorScheme, tracks),
SliverToBoxAdapter(
child: SizedBox(height: _isSelectionMode ? 120 : 32),
@@ -412,22 +432,15 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
ColorScheme colorScheme,
List<DownloadHistoryItem> tracks,
) {
final mediaSize = MediaQuery.of(context).size;
final screenWidth = mediaSize.width;
final shortestSide = mediaSize.shortestSide;
final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0);
final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0);
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
final expandedHeight = _calculateExpandedHeight(context);
final embeddedCoverPath = _resolveAlbumEmbeddedCoverPath(tracks);
final commonQuality = _getCommonQuality(tracks);
return SliverAppBar(
expandedHeight: expandedHeight,
pinned: true,
stretch: true,
backgroundColor:
colorScheme.surface, // Use theme color for collapsed state
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
title: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
@@ -449,33 +462,24 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3;
final dpr = MediaQuery.devicePixelRatioOf(
context,
).clamp(1.0, 3.0).toDouble();
final backgroundMemCacheWidth = (constraints.maxWidth * dpr)
.round()
.clamp(720, 1440)
.toInt();
return FlexibleSpaceBar(
collapseMode: CollapseMode.none,
collapseMode: CollapseMode.parallax,
background: Stack(
fit: StackFit.expand,
children: [
// Blurred cover background
// Full-screen cover background
if (embeddedCoverPath != null)
Image.file(
File(embeddedCoverPath),
fit: BoxFit.cover,
cacheWidth: backgroundMemCacheWidth,
errorBuilder: (_, _, _) =>
Container(color: colorScheme.surface),
)
else if (widget.coverUrl != null)
CachedNetworkImage(
imageUrl: widget.coverUrl!,
imageUrl: _highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: backgroundMemCacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) =>
Container(color: colorScheme.surface),
@@ -483,96 +487,136 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
Container(color: colorScheme.surface),
)
else
Container(color: colorScheme.surface),
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(
color: colorScheme.surface.withValues(alpha: 0.4),
Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
size: 80,
color: colorScheme.onSurfaceVariant,
),
),
),
// Bottom gradient for readability
Positioned(
left: 0,
right: 0,
bottom: 0,
height: bottomGradientHeight,
height: expandedHeight * 0.65,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.surface.withValues(alpha: 0.0),
colorScheme.surface,
Colors.transparent,
Colors.black.withValues(alpha: 0.85),
],
),
),
),
),
// Cover image centered - fade out when collapsing
AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: showContent ? 1.0 : 0.0,
child: Center(
child: Padding(
padding: EdgeInsets.only(top: coverTopPadding),
child: Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.4),
blurRadius: 30,
offset: const Offset(0, 15),
),
],
// Album info overlay at bottom
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: [
Text(
widget.albumName,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
height: 1.2,
),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: embeddedCoverPath != null
? Image.file(
File(embeddedCoverPath),
fit: BoxFit.cover,
cacheWidth: (coverSize * 2).toInt(),
cacheHeight: (coverSize * 2).toInt(),
errorBuilder: (_, _, _) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
size: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
const SizedBox(height: 6),
Text(
widget.artistName,
style: const TextStyle(
color: Colors.white70,
fontSize: 16,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (tracks.isNotEmpty) ...[
const SizedBox(height: 12),
Wrap(
alignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
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.download_done,
size: 14,
color: Colors.white,
),
const SizedBox(width: 4),
Text(
context.l10n.downloadedAlbumDownloadedCount(
tracks.length,
),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
if (commonQuality != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(20),
),
child: Text(
commonQuality,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
)
: widget.coverUrl != null
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(),
cacheManager: CoverCacheManager.instance,
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
size: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
),
),
),
),
],
),
],
],
),
),
),
],
),
stretchModes: const [
StretchMode.zoomBackground,
StretchMode.blurBackground,
],
stretchModes: const [StretchMode.zoomBackground],
);
},
),
@@ -580,10 +624,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.8),
color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
child: const Icon(Icons.arrow_back, color: Colors.white),
),
onPressed: () => Navigator.pop(context),
),
@@ -595,102 +639,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
ColorScheme colorScheme,
List<DownloadHistoryItem> tracks,
) {
final commonQuality = _getCommonQuality(tracks);
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.albumName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 4),
Text(
widget.artistName,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.download_done,
size: 14,
color: colorScheme.onPrimaryContainer,
),
const SizedBox(width: 4),
Text(
context.l10n.downloadedAlbumDownloadedCount(
tracks.length,
),
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
const SizedBox(width: 8),
if (commonQuality != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: commonQuality.startsWith('24')
? colorScheme.tertiaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Text(
commonQuality,
style: TextStyle(
color: commonQuality.startsWith('24')
? colorScheme.onTertiaryContainer
: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
],
),
],
),
),
),
),
);
// Info is now displayed in the full-screen cover overlay
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
String? _getCommonQuality(List<DownloadHistoryItem> tracks) {
@@ -721,43 +671,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
return firstQuality;
}
Widget _buildTrackListHeader(
BuildContext context,
ColorScheme colorScheme,
List<DownloadHistoryItem> tracks,
) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
child: Row(
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text(
context.l10n.downloadedAlbumTracksHeader,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
const Spacer(),
if (!_isSelectionMode)
TextButton.icon(
onPressed: tracks.isNotEmpty
? () => _enterSelectionMode(tracks.first.id)
: null,
icon: const Icon(Icons.checklist, size: 18),
label: Text(context.l10n.actionSelect),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
],
),
),
);
}
Widget _buildTrackList(
BuildContext context,
ColorScheme colorScheme,
+144 -237
View File
@@ -1,5 +1,4 @@
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -66,12 +65,18 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
}
void _onScroll() {
final shouldShow = _scrollController.offset > 280;
final expandedHeight = _calculateExpandedHeight(context);
final shouldShow = _scrollController.offset > (expandedHeight - kToolbarHeight - 20);
if (shouldShow != _showTitleInAppBar) {
setState(() => _showTitleInAppBar = shouldShow);
}
}
double _calculateExpandedHeight(BuildContext context) {
final mediaSize = MediaQuery.of(context).size;
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
}
List<LocalLibraryItem> _buildSortedTracks() {
final tracks = List<LocalLibraryItem>.from(widget.tracks);
tracks.sort((a, b) {
@@ -248,7 +253,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
slivers: [
_buildAppBar(context, colorScheme),
_buildInfoCard(context, colorScheme, tracks),
_buildTrackListHeader(context, colorScheme, tracks),
_buildTrackList(context, colorScheme, tracks),
SliverToBoxAdapter(
child: SizedBox(height: _isSelectionMode ? 120 : 32),
@@ -276,14 +280,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final mediaSize = MediaQuery.of(context).size;
final screenWidth = mediaSize.width;
final shortestSide = mediaSize.shortestSide;
final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0);
final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0);
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
final expandedHeight = _calculateExpandedHeight(context);
final commonQuality = _commonQualityCache;
return SliverAppBar(
expandedHeight: expandedHeight,
@@ -313,11 +311,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar(
collapseMode: CollapseMode.none,
collapseMode: CollapseMode.parallax,
background: Stack(
fit: StackFit.expand,
children: [
// Blurred cover background
// Full-screen cover background
if (widget.coverPath != null)
Image.file(
File(widget.coverPath!),
@@ -326,90 +324,161 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
Container(color: colorScheme.surface),
)
else
Container(color: colorScheme.surface),
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(
color: colorScheme.surface.withValues(alpha: 0.4),
Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
size: 80,
color: colorScheme.onSurfaceVariant,
),
),
),
// Bottom gradient for readability
Positioned(
left: 0,
right: 0,
bottom: 0,
height: bottomGradientHeight,
height: expandedHeight * 0.65,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.surface.withValues(alpha: 0.0),
colorScheme.surface,
Colors.transparent,
Colors.black.withValues(alpha: 0.85),
],
),
),
),
),
// Cover image centered
AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: showContent ? 1.0 : 0.0,
child: Center(
child: Padding(
padding: EdgeInsets.only(top: coverTopPadding),
child: Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.4),
blurRadius: 30,
offset: const Offset(0, 15),
),
],
// Album info overlay at bottom
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: [
Text(
widget.albumName,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
height: 1.2,
),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: widget.coverPath != null
? Image.file(
File(widget.coverPath!),
fit: BoxFit.cover,
cacheWidth: (coverSize * 2).toInt(),
errorBuilder: (context, error, stackTrace) =>
Container(
color:
colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
size: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
),
),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
size: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
const SizedBox(height: 6),
Text(
widget.artistName,
style: const TextStyle(
color: Colors.white70,
fontSize: 16,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 12),
Wrap(
alignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
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.folder,
size: 14,
color: Colors.white,
),
const SizedBox(width: 4),
const Text(
'Local',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 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.music_note,
size: 14,
color: Colors.white,
),
const SizedBox(width: 4),
Text(
'${_sortedTracksCache.length} tracks',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
if (commonQuality != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(20),
),
child: Text(
commonQuality,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
],
),
),
],
),
),
),
],
),
stretchModes: const [
StretchMode.zoomBackground,
StretchMode.blurBackground,
],
stretchModes: const [StretchMode.zoomBackground],
);
},
),
@@ -417,10 +486,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.8),
color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
child: const Icon(Icons.arrow_back, color: Colors.white),
),
onPressed: () => Navigator.pop(context),
),
@@ -432,133 +501,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
ColorScheme colorScheme,
List<LocalLibraryItem> tracks,
) {
final commonQuality = _commonQualityCache;
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.albumName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 4),
Text(
widget.artistName,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
Row(
children: [
// "Local" badge
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.folder,
size: 14,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 4),
Text(
'Local',
style: TextStyle(
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
const SizedBox(width: 8),
// Track count
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.music_note,
size: 14,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
'${tracks.length} tracks',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
const SizedBox(width: 8),
// Quality badge if all tracks have the same quality
if (commonQuality != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: commonQuality.contains('24')
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Text(
commonQuality,
style: TextStyle(
color: commonQuality.contains('24')
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
],
),
],
),
),
),
),
);
// Info is now displayed in the full-screen cover overlay
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
String? _computeCommonQuality(List<LocalLibraryItem> tracks) {
@@ -595,43 +539,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return firstQuality;
}
Widget _buildTrackListHeader(
BuildContext context,
ColorScheme colorScheme,
List<LocalLibraryItem> tracks,
) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
child: Row(
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text(
context.l10n.downloadedAlbumTracksHeader,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
const Spacer(),
if (!_isSelectionMode)
TextButton.icon(
onPressed: tracks.isNotEmpty
? () => _enterSelectionMode(tracks.first.id)
: null,
icon: const Icon(Icons.checklist, size: 18),
label: Text(context.l10n.actionSelect),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
],
),
),
);
}
Widget _buildTrackList(
BuildContext context,
ColorScheme colorScheme,
+113 -169
View File
@@ -1,5 +1,3 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
@@ -120,12 +118,36 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
}
void _onScroll() {
final shouldShow = _scrollController.offset > 280;
final expandedHeight = _calculateExpandedHeight(context);
final shouldShow = _scrollController.offset > (expandedHeight - kToolbarHeight - 20);
if (shouldShow != _showTitleInAppBar) {
setState(() => _showTitleInAppBar = shouldShow);
}
}
double _calculateExpandedHeight(BuildContext context) {
final mediaSize = MediaQuery.of(context).size;
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
}
/// Upgrade cover URL to a reasonable resolution for full-screen display.
String? _highResCoverUrl(String? url) {
if (url == null) return null;
// Spotify CDN: upgrade 300 640 only
if (url.contains('ab67616d00001e02')) {
return url.replaceAll('ab67616d00001e02', 'ab67616d0000b273');
}
// Deezer CDN: upgrade to 1000x1000
final deezerRegex = RegExp(r'/(\d+)x(\d+)-(\d+)-(\d+)-(\d+)-(\d+)\.jpg$');
if (url.contains('cdn-images.dzcdn.net') && deezerRegex.hasMatch(url)) {
return url.replaceAllMapped(
deezerRegex,
(m) => '/1000x1000-${m[3]}-${m[4]}-${m[5]}-${m[6]}.jpg',
);
}
return url;
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
@@ -136,7 +158,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
slivers: [
_buildAppBar(context, colorScheme),
_buildInfoCard(context, colorScheme),
_buildTrackListHeader(context, colorScheme),
_buildTrackList(context, colorScheme),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
@@ -145,21 +166,13 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final mediaSize = MediaQuery.of(context).size;
final screenWidth = mediaSize.width;
final shortestSide = mediaSize.shortestSide;
final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0);
final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0);
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
final expandedHeight = _calculateExpandedHeight(context);
return SliverAppBar(
expandedHeight: expandedHeight,
pinned: true,
stretch: true,
backgroundColor:
colorScheme.surface, // Use theme color for collapsed state
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
title: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
@@ -181,25 +194,17 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3;
final dpr = MediaQuery.devicePixelRatioOf(
context,
).clamp(1.0, 3.0).toDouble();
final backgroundMemCacheWidth = (constraints.maxWidth * dpr)
.round()
.clamp(720, 1440)
.toInt();
return FlexibleSpaceBar(
collapseMode: CollapseMode.none,
collapseMode: CollapseMode.parallax,
background: Stack(
fit: StackFit.expand,
children: [
// Blurred cover background
// Full-screen cover background
if (widget.coverUrl != null)
CachedNetworkImage(
imageUrl: widget.coverUrl!,
imageUrl: _highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: backgroundMemCacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) =>
Container(color: colorScheme.surface),
@@ -207,81 +212,110 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
Container(color: colorScheme.surface),
)
else
Container(color: colorScheme.surface),
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(
color: colorScheme.surface.withValues(alpha: 0.4),
Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.playlist_play,
size: 80,
color: colorScheme.onSurfaceVariant,
),
),
),
// Bottom gradient for readability
Positioned(
left: 0,
right: 0,
bottom: 0,
height: bottomGradientHeight,
height: expandedHeight * 0.65,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.surface.withValues(alpha: 0.0),
colorScheme.surface,
Colors.transparent,
Colors.black.withValues(alpha: 0.85),
],
),
),
),
),
// Cover image centered - fade out when collapsing
AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: showContent ? 1.0 : 0.0,
child: Center(
child: Padding(
padding: EdgeInsets.only(top: coverTopPadding),
child: Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.4),
blurRadius: 30,
offset: const Offset(0, 15),
),
],
// Playlist info overlay at bottom
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: [
Text(
widget.playlistName,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
height: 1.2,
),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: widget.coverUrl != null
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(),
cacheManager: CoverCacheManager.instance,
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.playlist_play,
size: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
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: 16),
FilledButton.icon(
onPressed: () => _downloadAll(context),
icon: const Icon(Icons.download, size: 18),
label: Text(
context.l10n.downloadAllCount(_tracks.length),
),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
),
],
],
),
),
),
],
),
stretchModes: const [
StretchMode.zoomBackground,
StretchMode.blurBackground,
],
stretchModes: const [StretchMode.zoomBackground],
);
},
),
@@ -289,10 +323,10 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.8),
color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
child: const Icon(Icons.arrow_back, color: Colors.white),
),
onPressed: () => Navigator.pop(context),
),
@@ -300,98 +334,8 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
}
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.playlistName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.playlist_play,
size: 14,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 4),
Text(
context.l10n.tracksCount(_tracks.length),
style: TextStyle(
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _tracks.isEmpty
? null
: () => _downloadAll(context),
icon: const Icon(Icons.download, size: 18),
label: Text(context.l10n.downloadAllCount(_tracks.length)),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
),
],
),
),
),
),
);
}
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
child: Row(
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text(
context.l10n.tracksHeader,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
],
),
),
);
// Info is now displayed in the full-screen cover overlay
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme) {
+202 -209
View File
@@ -1,6 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -205,12 +204,18 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
void _onScroll() {
final shouldShow = _scrollController.offset > 280;
final expandedHeight = _calculateExpandedHeight(context);
final shouldShow = _scrollController.offset > (expandedHeight - kToolbarHeight - 20);
if (shouldShow != _showTitleInAppBar) {
setState(() => _showTitleInAppBar = shouldShow);
}
}
double _calculateExpandedHeight(BuildContext context) {
final mediaSize = MediaQuery.of(context).size;
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
}
Future<void> _checkFile() async {
var filePath = _filePath;
if (filePath.startsWith('EXISTS:')) {
@@ -509,19 +514,17 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5;
final expandedHeight = _calculateExpandedHeight(context);
return Scaffold(
body: CustomScrollView(
controller: _scrollController,
slivers: [
SliverAppBar(
expandedHeight: 320,
expandedHeight: expandedHeight,
pinned: true,
stretch: true,
backgroundColor:
colorScheme.surface, // Use theme color for collapsed state
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
title: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
@@ -541,21 +544,18 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
builder: (context, constraints) {
final collapseRatio =
(constraints.maxHeight - kToolbarHeight) /
(320 - kToolbarHeight);
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar(
collapseMode: CollapseMode.none,
collapseMode: CollapseMode.parallax,
background: _buildHeaderBackground(
context,
colorScheme,
coverSize,
expandedHeight,
showContent,
),
stretchModes: const [
StretchMode.zoomBackground,
StretchMode.blurBackground,
],
stretchModes: const [StretchMode.zoomBackground],
);
},
),
@@ -563,10 +563,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.8),
color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
child: const Icon(Icons.arrow_back, color: Colors.white),
),
onPressed: _popWithMetadataResult,
),
@@ -575,10 +575,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.8),
color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: Icon(Icons.more_vert, color: colorScheme.onSurface),
child: const Icon(Icons.more_vert, color: Colors.white),
),
onPressed: () => _showOptionsMenu(context, ref, colorScheme),
),
@@ -591,10 +591,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTrackInfoCard(context, colorScheme, _fileExists),
const SizedBox(height: 16),
_buildMetadataCard(context, colorScheme, _fileSize),
const SizedBox(height: 16),
@@ -627,34 +623,23 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
Widget _buildHeaderBackground(
BuildContext context,
ColorScheme colorScheme,
double coverSize,
double expandedHeight,
bool showContent,
) {
final screenSize = MediaQuery.sizeOf(context);
final pixelRatio = MediaQuery.devicePixelRatioOf(context);
final backgroundCacheWidth = (screenSize.width * pixelRatio).round();
final backgroundCacheHeight = (screenSize.height * 0.65 * pixelRatio)
.round();
final coverCacheSize = (coverSize * pixelRatio).round();
return Stack(
fit: StackFit.expand,
children: [
// Blurred cover art background
// Full-screen cover background
if (_hasPath(_embeddedCoverPreviewPath))
Image.file(
File(_embeddedCoverPreviewPath!),
fit: BoxFit.cover,
cacheWidth: backgroundCacheWidth,
cacheHeight: backgroundCacheHeight,
errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
)
else if (_coverUrl != null)
CachedNetworkImage(
imageUrl: _coverUrl!,
fit: BoxFit.cover,
memCacheWidth: backgroundCacheWidth,
memCacheHeight: backgroundCacheHeight,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => Container(color: colorScheme.surface),
errorWidget: (_, _, _) => Container(color: colorScheme.surface),
@@ -663,113 +648,209 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
Image.file(
File(_localCoverPath!),
fit: BoxFit.cover,
cacheWidth: backgroundCacheWidth,
cacheHeight: backgroundCacheHeight,
errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
)
else
Container(color: colorScheme.surface),
// Blur filter
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(color: colorScheme.surface.withValues(alpha: 0.4)),
Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
size: 80,
color: colorScheme.onSurfaceVariant,
),
),
),
// Bottom fade to surface
// Bottom gradient for readability
Positioned(
left: 0,
right: 0,
bottom: 0,
height: 80,
height: expandedHeight * 0.65,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.surface.withValues(alpha: 0.0),
colorScheme.surface,
Colors.transparent,
Colors.black.withValues(alpha: 0.85),
],
),
),
),
),
// Cover art
AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: showContent ? 1.0 : 0.0,
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Hero(
tag: 'cover_$_itemId',
child: Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.4),
blurRadius: 30,
offset: const Offset(0, 15),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: _hasPath(_embeddedCoverPreviewPath)
? Image.file(
File(_embeddedCoverPreviewPath!),
fit: BoxFit.cover,
cacheWidth: coverCacheSize,
cacheHeight: coverCacheSize,
errorBuilder: (_, _, _) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
)
: _coverUrl != null
? CachedNetworkImage(
imageUrl: _coverUrl!,
fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(),
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
)
: _localCoverPath != null && _localCoverPath!.isNotEmpty
? Image.file(
File(_localCoverPath!),
fit: BoxFit.cover,
cacheWidth: coverCacheSize,
cacheHeight: coverCacheSize,
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
// Track info overlay at bottom
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: [
Text(
trackName,
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: 6),
Text(
artistName,
style: const TextStyle(
color: Colors.white70,
fontSize: 16,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
albumName,
style: const TextStyle(
color: Colors.white54,
fontSize: 14,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 12),
Wrap(
alignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
if (_quality != null && _quality!.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(20),
),
child: Text(
_quality!,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
if (duration != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(20),
),
child: Text(
_formatDuration(duration!),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
if (_service != 'local')
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(20),
),
child: Text(
_service[0].toUpperCase() + _service.substring(1),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
)
else
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.folder,
size: 14,
color: Colors.white,
),
const SizedBox(width: 4),
const Text(
'Local',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
if (!_fileExists)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.warning_rounded,
size: 14,
color: Colors.white,
),
const SizedBox(width: 4),
Text(
context.l10n.trackFileNotFound,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
],
),
],
),
),
),
@@ -777,94 +858,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
);
}
Widget _buildTrackInfoCard(
BuildContext context,
ColorScheme colorScheme,
bool fileExists,
) {
return Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
trackName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 4),
Text(
artistName,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(color: colorScheme.primary),
),
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.album,
size: 16,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: Text(
albumName,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
if (!fileExists) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.warning_rounded,
size: 16,
color: colorScheme.onErrorContainer,
),
const SizedBox(width: 6),
Text(
context.l10n.trackFileNotFound,
style: TextStyle(
color: colorScheme.onErrorContainer,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
],
),
),
);
}
Widget _buildMetadataCard(
BuildContext context,
ColorScheme colorScheme,