From caf68c813734691c7906233997eff90cc51e50e4 Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 19 Feb 2026 00:28:12 +0700 Subject: [PATCH] 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. --- lib/screens/album_screen.dart | 393 ++++++++++------------ lib/screens/artist_screen.dart | 97 +++--- lib/screens/downloaded_album_screen.dart | 361 ++++++++------------ lib/screens/local_album_screen.dart | 381 ++++++++------------- lib/screens/playlist_screen.dart | 282 +++++++--------- lib/screens/track_metadata_screen.dart | 411 +++++++++++------------ 6 files changed, 821 insertions(+), 1104 deletions(-) diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 8224c2b5..e9e756fc 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -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 { } 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 { ), ), 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 { } 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 { (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 { 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 { 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 { } 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( diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 97737649..d825ac3e 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -1100,63 +1100,68 @@ class _ArtistScreenState extends ConsumerState { 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, ), ), ], diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 12714ac6..23892a9e 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -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 { } 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 _getAlbumTracks( List allItems, @@ -359,7 +380,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { 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 { ColorScheme colorScheme, List 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 { (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 { 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 { 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 { ColorScheme colorScheme, List 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 tracks) { @@ -721,43 +671,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { return firstQuality; } - Widget _buildTrackListHeader( - BuildContext context, - ColorScheme colorScheme, - List 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, diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index aca09bfc..e144882f 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -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 { } 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 _buildSortedTracks() { final tracks = List.from(widget.tracks); tracks.sort((a, b) { @@ -248,7 +253,6 @@ class _LocalAlbumScreenState extends ConsumerState { 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 { } 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 { 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 { 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 { 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 { ColorScheme colorScheme, List 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 tracks) { @@ -595,43 +539,6 @@ class _LocalAlbumScreenState extends ConsumerState { return firstQuality; } - Widget _buildTrackListHeader( - BuildContext context, - ColorScheme colorScheme, - List 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, diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index ef7d488b..97de41dc 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -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 { } 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 { 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 { } 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 { (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 { 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 { 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 { } 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) { diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 16d1d441..5be27f4e 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -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 { } 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 _checkFile() async { var filePath = _filePath; if (filePath.startsWith('EXISTS:')) { @@ -509,19 +514,17 @@ class _TrackMetadataScreenState extends ConsumerState { @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 { 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 { 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 { 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 { 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 { 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 { 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 { ); } - 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,