From ef60bba2e1b08d17e8721ad4cacd3c2c9b6d13f9 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 28 Jun 2026 22:23:08 +0700 Subject: [PATCH] feat(ui): redesign local/downloaded album and folder screen headers - Consistent header design across all local screens: blurred cover, black overlay, bottom gradient, centered square cover, adaptive title, inline meta line, Play + Shuffle buttons - Height normalized to 0.6x (clamp 400-580) everywhere --- lib/screens/downloaded_album_screen.dart | 321 +++++++++++++----- lib/screens/library_tracks_folder_screen.dart | 174 +++++++--- lib/screens/local_album_screen.dart | 273 ++++++++++----- lib/screens/playlist_screen.dart | 2 +- 4 files changed, 557 insertions(+), 213 deletions(-) diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 616bb48b..0180553a 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -1,4 +1,6 @@ import 'dart:io'; +import 'dart:math'; +import 'dart:ui' show ImageFilter; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -20,6 +22,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/widgets/batch_progress_dialog.dart'; import 'package:spotiflac_android/widgets/batch_convert_sheet.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; @@ -97,7 +100,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { double _calculateExpandedHeight(BuildContext context) { final mediaSize = MediaQuery.of(context).size; - return (mediaSize.height * 0.55).clamp(360.0, 520.0); + return (mediaSize.height * 0.6).clamp(400.0, 580.0); } String? _highResCoverUrl(String? url) { @@ -269,16 +272,14 @@ class _DownloadedAlbumScreenState extends ConsumerState { } } - Future _openFile(DownloadHistoryItem track) async { + Future _openFile( + DownloadHistoryItem track, { + List queueItems = const [], + }) async { try { - await ref - .read(playbackProvider.notifier) - .playLocalPath( - path: track.filePath, - title: track.trackName, - artist: track.artistName, - album: track.albumName, - coverUrl: track.coverUrl ?? '', + await ref.read(playbackProvider.notifier).playHistoryQueue( + queueItems.isNotEmpty ? queueItems : [track], + startItem: track, ); } catch (e) { if (mounted) { @@ -502,26 +503,32 @@ class _DownloadedAlbumScreenState extends ConsumerState { fit: StackFit.expand, children: [ if (embeddedCoverPath != null) - Image.file( - File(embeddedCoverPath), - fit: BoxFit.cover, - cacheWidth: cacheWidth, - gaplessPlayback: true, - filterQuality: FilterQuality.low, - errorBuilder: (_, _, _) => - Container(color: colorScheme.surface), + ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32), + child: Image.file( + File(embeddedCoverPath), + fit: BoxFit.cover, + cacheWidth: cacheWidth, + gaplessPlayback: true, + filterQuality: FilterQuality.low, + errorBuilder: (_, _, _) => + Container(color: colorScheme.surface), + ), ) else if (widget.coverUrl != null) - CachedNetworkImage( - imageUrl: - _highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!, - fit: BoxFit.cover, - memCacheWidth: cacheWidth, - cacheManager: CoverCacheManager.instance, - placeholder: (_, _) => - Container(color: colorScheme.surface), - errorWidget: (_, _, _) => - Container(color: colorScheme.surface), + ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32), + child: CachedNetworkImage( + imageUrl: + _highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!, + fit: BoxFit.cover, + memCacheWidth: cacheWidth, + cacheManager: CoverCacheManager.instance, + placeholder: (_, _) => + Container(color: colorScheme.surface), + errorWidget: (_, _, _) => + Container(color: colorScheme.surface), + ), ) else Container( @@ -532,6 +539,8 @@ class _DownloadedAlbumScreenState extends ConsumerState { color: colorScheme.onSurfaceVariant, ), ), + if (embeddedCoverPath != null || widget.coverUrl != null) + Container(color: Colors.black.withValues(alpha: 0.35)), Positioned( left: 0, right: 0, @@ -561,11 +570,43 @@ class _DownloadedAlbumScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ + Builder( + builder: (context) { + final coverSize = (constraints.maxWidth * 0.5) + .clamp(150.0, 210.0) + .toDouble(); + return Container( + width: coverSize, + height: coverSize, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.45), + blurRadius: 24, + offset: const Offset(0, 8), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: _buildSquareCover( + context, + colorScheme, + embeddedCoverPath, + coverSize, + cacheWidth, + ), + ), + ); + }, + ), + const SizedBox(height: 20), Text( widget.albumName, - style: const TextStyle( + style: TextStyle( color: Colors.white, - fontSize: 24, + fontSize: _albumTitleFontSize(), fontWeight: FontWeight.bold, height: 1.2, ), @@ -587,62 +628,49 @@ class _DownloadedAlbumScreenState extends ConsumerState { ), if (tracks.isNotEmpty) ...[ const SizedBox(height: 12), - Wrap( - alignment: WrapAlignment.center, - spacing: 8, - runSpacing: 8, + _buildDownloadedHeaderMeta( + context, + tracks, + commonQuality, + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, 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, + Flexible( + child: FilledButton.icon( + onPressed: () => _playAll(tracks), + icon: const Icon(Icons.play_arrow, size: 20), + label: Text( + context.l10n.tooltipPlay, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black87, + minimumSize: const Size(0, 48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), ), - const SizedBox(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, - ), + const SizedBox(width: 12), + Container( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + shape: BoxShape.circle, + ), + child: IconButton( + tooltip: 'Shuffle', + onPressed: () => _shuffleAll(tracks), + icon: const Icon( + Icons.shuffle, + color: Colors.white, ), ), + ), ], ), ], @@ -671,6 +699,136 @@ class _DownloadedAlbumScreenState extends ConsumerState { ); } + Widget _buildSquareCover( + BuildContext context, + ColorScheme colorScheme, + String? embeddedCoverPath, + double coverSize, + int cacheWidth, + ) { + Widget placeholder() => Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.album, + size: 48, + color: colorScheme.onSurfaceVariant, + ), + ); + + if (embeddedCoverPath != null) { + return Image.file( + File(embeddedCoverPath), + fit: BoxFit.cover, + width: coverSize, + height: coverSize, + cacheWidth: cacheWidth, + gaplessPlayback: true, + errorBuilder: (_, _, _) => placeholder(), + ); + } + + final coverUrl = widget.coverUrl; + if (coverUrl != null && coverUrl.isNotEmpty) { + return CachedNetworkImage( + imageUrl: _highResCoverUrl(coverUrl) ?? coverUrl, + fit: BoxFit.cover, + width: coverSize, + height: coverSize, + memCacheWidth: cacheWidth, + cacheManager: CoverCacheManager.instance, + placeholder: (_, _) => placeholder(), + errorWidget: (_, _, _) => placeholder(), + ); + } + + return placeholder(); + } + + double _albumTitleFontSize() { + final length = widget.albumName.trim().length; + if (length > 45) return 18; + if (length > 30) return 21; + return 24; + } + + Widget _metaWhiteItem(IconData? icon, String label) { + const textStyle = TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500, + ); + if (icon == null) return Text(label, style: textStyle); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 15, color: Colors.white), + const SizedBox(width: 4), + Text(label, style: textStyle), + ], + ); + } + + Widget _buildDownloadedHeaderMeta( + BuildContext context, + List tracks, + String? commonQuality, + ) { + final totalSeconds = tracks.fold( + 0, + (sum, t) => sum + ((t.duration ?? 0) > 0 ? t.duration! : 0), + ); + final totalMinutes = (totalSeconds / 60).round(); + + final parts = []; + void add(Widget w) { + if (parts.isNotEmpty) { + parts.add( + const Padding( + padding: EdgeInsets.symmetric(horizontal: 6), + child: Text( + '•', + style: TextStyle(color: Colors.white70, fontSize: 12), + ), + ), + ); + } + parts.add(w); + } + + add( + _metaWhiteItem( + null, + context.l10n.downloadedAlbumDownloadedCount(tracks.length), + ), + ); + if (totalMinutes > 0) add(_metaWhiteItem(null, '$totalMinutes min')); + if (commonQuality != null && commonQuality.isNotEmpty) { + add(_metaWhiteItem(Icons.graphic_eq, commonQuality)); + } + + return Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + runSpacing: 4, + children: parts, + ); + } + + Future _playAll(List tracks) async { + if (tracks.isEmpty) return; + await ref.read(musicPlayerControllerProvider).setShuffle(false); + await _openFile(tracks.first, queueItems: tracks); + } + + Future _shuffleAll(List tracks) async { + if (tracks.isEmpty) return; + await ref.read(musicPlayerControllerProvider).setShuffle(true); + await _openFile( + tracks[Random().nextInt(tracks.length)], + queueItems: tracks, + ); + } + Widget _buildInfoCard( BuildContext context, ColorScheme colorScheme, @@ -888,7 +1046,8 @@ class _DownloadedAlbumScreenState extends ConsumerState { ? null : IconButton( tooltip: context.l10n.tooltipPlay, - onPressed: () => _openFile(track), + onPressed: () => + _openFile(track, queueItems: navigationItems), icon: Icon(Icons.play_arrow, color: colorScheme.primary), style: IconButton.styleFrom( backgroundColor: colorScheme.primaryContainer.withValues( diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index 7a4d3f54..8c4ebc44 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:ui' show ImageFilter; import 'package:cached_network_image/cached_network_image.dart'; import 'package:file_picker/file_picker.dart'; @@ -68,7 +69,14 @@ class _LibraryTracksFolderScreenState double _calculateExpandedHeight(BuildContext context) { final mediaSize = MediaQuery.of(context).size; - return (mediaSize.height * 0.45).clamp(300.0, 420.0); + return (mediaSize.height * 0.6).clamp(400.0, 580.0); + } + + double _folderTitleFontSize(String title) { + final length = title.trim().length; + if (length > 45) return 18; + if (length > 30) return 21; + return 24; } IconData _modeIcon() { @@ -702,48 +710,67 @@ class _LibraryTracksFolderScreenState fit: StackFit.expand, children: [ if (hasCustomCover) - Image.file( - File(customCoverPath), - fit: BoxFit.cover, - cacheWidth: cacheWidth, - filterQuality: FilterQuality.low, - gaplessPlayback: true, - frameBuilder: (_, child, frame, wasSynchronouslyLoaded) { - if (wasSynchronouslyLoaded || frame != null) return child; - return coverFallback; - }, - errorBuilder: (_, _, _) => coverFallback, + ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32), + child: Image.file( + File(customCoverPath), + fit: BoxFit.cover, + cacheWidth: cacheWidth, + filterQuality: FilterQuality.low, + gaplessPlayback: true, + frameBuilder: (_, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) { + return child; + } + return coverFallback; + }, + errorBuilder: (_, _, _) => coverFallback, + ), ) else if (hasCoverUrl) _isCoverLocalPath(coverUrl) - ? Image.file( - File(coverUrl), - fit: BoxFit.cover, - cacheWidth: cacheWidth, - filterQuality: FilterQuality.low, - gaplessPlayback: true, - frameBuilder: - (_, child, frame, wasSynchronouslyLoaded) { - if (wasSynchronouslyLoaded || frame != null) { - return child; - } - return Container(color: colorScheme.surface); - }, - errorBuilder: (_, _, _) => - Container(color: colorScheme.surface), + ? ImageFiltered( + imageFilter: ImageFilter.blur( + sigmaX: 32, + sigmaY: 32, + ), + child: Image.file( + File(coverUrl), + fit: BoxFit.cover, + cacheWidth: cacheWidth, + filterQuality: FilterQuality.low, + gaplessPlayback: true, + frameBuilder: + (_, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) { + return child; + } + return Container(color: colorScheme.surface); + }, + errorBuilder: (_, _, _) => + Container(color: colorScheme.surface), + ), ) - : CachedNetworkImage( - imageUrl: _highResCoverUrl(coverUrl) ?? coverUrl, - fit: BoxFit.cover, - memCacheWidth: cacheWidth, - cacheManager: CoverCacheManager.instance, - placeholder: (_, _) => - Container(color: colorScheme.surface), - errorWidget: (_, _, _) => - Container(color: colorScheme.surface), + : ImageFiltered( + imageFilter: ImageFilter.blur( + sigmaX: 32, + sigmaY: 32, + ), + child: CachedNetworkImage( + imageUrl: _highResCoverUrl(coverUrl) ?? coverUrl, + fit: BoxFit.cover, + memCacheWidth: cacheWidth, + cacheManager: CoverCacheManager.instance, + placeholder: (_, _) => + Container(color: colorScheme.surface), + errorWidget: (_, _, _) => + Container(color: colorScheme.surface), + ), ) else coverFallback, + if (hasCustomCover || hasCoverUrl) + Container(color: Colors.black.withValues(alpha: 0.35)), Positioned( left: 0, right: 0, @@ -773,11 +800,82 @@ class _LibraryTracksFolderScreenState crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ + Builder( + builder: (context) { + final coverSize = (constraints.maxWidth * 0.5) + .clamp(150.0, 210.0) + .toDouble(); + Widget squarePlaceholder() => Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + _modeIcon(), + size: 48, + color: colorScheme.onSurfaceVariant, + ), + ); + Widget coverChild; + if (hasCustomCover) { + coverChild = Image.file( + File(customCoverPath), + fit: BoxFit.cover, + width: coverSize, + height: coverSize, + cacheWidth: cacheWidth, + gaplessPlayback: true, + errorBuilder: (_, _, _) => squarePlaceholder(), + ); + } else if (hasCoverUrl && + _isCoverLocalPath(coverUrl)) { + coverChild = Image.file( + File(coverUrl), + fit: BoxFit.cover, + width: coverSize, + height: coverSize, + cacheWidth: cacheWidth, + gaplessPlayback: true, + errorBuilder: (_, _, _) => squarePlaceholder(), + ); + } else if (hasCoverUrl) { + coverChild = CachedNetworkImage( + imageUrl: + _highResCoverUrl(coverUrl) ?? coverUrl, + fit: BoxFit.cover, + width: coverSize, + height: coverSize, + memCacheWidth: cacheWidth, + cacheManager: CoverCacheManager.instance, + placeholder: (_, _) => squarePlaceholder(), + errorWidget: (_, _, _) => squarePlaceholder(), + ); + } else { + coverChild = squarePlaceholder(); + } + return Container( + width: coverSize, + height: coverSize, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.45), + blurRadius: 24, + offset: const Offset(0, 8), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: coverChild, + ), + ); + }, + ), + const SizedBox(height: 20), Text( title, - style: const TextStyle( + style: TextStyle( color: Colors.white, - fontSize: 24, + fontSize: _folderTitleFontSize(title), fontWeight: FontWeight.bold, height: 1.2, ), diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 92762641..699d9ae2 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -1,4 +1,6 @@ import 'dart:io'; +import 'dart:math'; +import 'dart:ui' show ImageFilter; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -23,6 +25,7 @@ import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; class LocalAlbumScreen extends ConsumerStatefulWidget { @@ -95,7 +98,7 @@ class _LocalAlbumScreenState extends ConsumerState { double _calculateExpandedHeight(BuildContext context) { final mediaSize = MediaQuery.of(context).size; - return (mediaSize.height * 0.55).clamp(360.0, 520.0); + return (mediaSize.height * 0.6).clamp(400.0, 580.0); } List _buildSortedTracks() { @@ -231,13 +234,7 @@ class _LocalAlbumScreenState extends ConsumerState { try { await ref .read(playbackProvider.notifier) - .playLocalPath( - path: track.filePath, - title: track.trackName, - artist: track.artistName, - album: track.albumName, - coverUrl: track.coverPath ?? '', - ); + .playLocalLibraryQueue(_sortedTracksCache, startItem: track); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -315,7 +312,6 @@ class _LocalAlbumScreenState extends ConsumerState { Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { final expandedHeight = _calculateExpandedHeight(context); - final commonQuality = _commonQualityCache; return SliverAppBar( expandedHeight: expandedHeight, @@ -351,14 +347,17 @@ class _LocalAlbumScreenState extends ConsumerState { fit: StackFit.expand, children: [ if (widget.coverPath != null) - Image.file( - File(widget.coverPath!), - fit: BoxFit.cover, - cacheWidth: cacheWidth, - gaplessPlayback: true, - filterQuality: FilterQuality.low, - errorBuilder: (_, _, _) => - Container(color: colorScheme.surface), + ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32), + child: Image.file( + File(widget.coverPath!), + fit: BoxFit.cover, + cacheWidth: cacheWidth, + gaplessPlayback: true, + filterQuality: FilterQuality.low, + errorBuilder: (_, _, _) => + Container(color: colorScheme.surface), + ), ) else Container( @@ -369,6 +368,8 @@ class _LocalAlbumScreenState extends ConsumerState { color: colorScheme.onSurfaceVariant, ), ), + if (widget.coverPath != null) + Container(color: Colors.black.withValues(alpha: 0.35)), Positioned( left: 0, right: 0, @@ -398,11 +399,63 @@ class _LocalAlbumScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ + Builder( + builder: (context) { + final coverSize = (constraints.maxWidth * 0.5) + .clamp(150.0, 210.0) + .toDouble(); + return Container( + width: coverSize, + height: coverSize, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.45), + blurRadius: 24, + offset: const Offset(0, 8), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: widget.coverPath != null + ? Image.file( + File(widget.coverPath!), + fit: BoxFit.cover, + width: coverSize, + height: coverSize, + cacheWidth: cacheWidth, + gaplessPlayback: true, + errorBuilder: (_, _, _) => Container( + color: + colorScheme.surfaceContainerHighest, + child: Icon( + Icons.album, + size: 48, + color: colorScheme.onSurfaceVariant, + ), + ), + ) + : Container( + color: + colorScheme.surfaceContainerHighest, + child: Icon( + Icons.album, + size: 48, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ); + }, + ), + const SizedBox(height: 20), Text( widget.albumName, - style: const TextStyle( + style: TextStyle( color: Colors.white, - fontSize: 24, + fontSize: _albumTitleFontSize(), fontWeight: FontWeight.bold, height: 1.2, ), @@ -423,90 +476,45 @@ class _LocalAlbumScreenState extends ConsumerState { overflow: TextOverflow.ellipsis, ), const SizedBox(height: 12), - Wrap( - alignment: WrapAlignment.center, - spacing: 8, - runSpacing: 8, + _buildLocalHeaderMeta(context), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, 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, + Flexible( + child: FilledButton.icon( + onPressed: _playAll, + icon: const Icon(Icons.play_arrow, size: 20), + label: Text( + context.l10n.tooltipPlay, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black87, + minimumSize: const Size(0, 48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), ), - const SizedBox(width: 4), - Text( - context.l10n.librarySourceLocal, - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ], + ), ), ), + const SizedBox(width: 12), Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(20), + shape: BoxShape.circle, ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.music_note, - size: 14, - color: Colors.white, - ), - const SizedBox(width: 4), - Text( - context.l10n.queueTrackCount( - _sortedTracksCache.length, - ), - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ], + child: IconButton( + tooltip: 'Shuffle', + onPressed: _shuffleAll, + icon: const Icon( + Icons.shuffle, + color: Colors.white, + ), ), ), - 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, - ), - ), - ), ], ), ], @@ -542,6 +550,85 @@ class _LocalAlbumScreenState extends ConsumerState { return const SliverToBoxAdapter(child: SizedBox.shrink()); } + double _albumTitleFontSize() { + final length = widget.albumName.trim().length; + if (length > 45) return 18; + if (length > 30) return 21; + return 24; + } + + Widget _metaWhiteItem(IconData? icon, String label) { + const textStyle = TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500, + ); + if (icon == null) return Text(label, style: textStyle); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 15, color: Colors.white), + const SizedBox(width: 4), + Text(label, style: textStyle), + ], + ); + } + + Widget _buildLocalHeaderMeta(BuildContext context) { + final tracks = _sortedTracksCache; + final totalSeconds = tracks.fold( + 0, + (sum, t) => sum + ((t.duration ?? 0) > 0 ? t.duration! : 0), + ); + final totalMinutes = (totalSeconds / 60).round(); + + final parts = []; + void add(Widget w) { + if (parts.isNotEmpty) { + parts.add( + const Padding( + padding: EdgeInsets.symmetric(horizontal: 6), + child: Text( + '•', + style: TextStyle(color: Colors.white70, fontSize: 12), + ), + ), + ); + } + parts.add(w); + } + + add(_metaWhiteItem(null, context.l10n.queueTrackCount(tracks.length))); + if (totalMinutes > 0) add(_metaWhiteItem(null, '$totalMinutes min')); + final quality = _commonQualityCache; + if (quality != null && quality.isNotEmpty) { + add(_metaWhiteItem(Icons.graphic_eq, quality)); + } + + return Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + runSpacing: 4, + children: parts, + ); + } + + Future _playAll() async { + final tracks = _sortedTracksCache; + if (tracks.isEmpty) return; + await ref.read(musicPlayerControllerProvider).setShuffle(false); + await _openFile(tracks.first); + } + + Future _shuffleAll() async { + final tracks = _sortedTracksCache + .where((t) => !isCueVirtualPath(t.filePath)) + .toList(); + if (tracks.isEmpty) return; + await ref.read(musicPlayerControllerProvider).setShuffle(true); + await _openFile(tracks[Random().nextInt(tracks.length)]); + } + String? _computeCommonQuality(List tracks) { if (tracks.isEmpty) return null; final first = tracks.first; diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index ef02fb5c..eaa78c5c 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -240,7 +240,7 @@ class _PlaylistScreenState extends ConsumerState { double _calculateExpandedHeight(BuildContext context) { final mediaSize = MediaQuery.of(context).size; - return (mediaSize.height * 0.55).clamp(360.0, 520.0); + return (mediaSize.height * 0.6).clamp(400.0, 580.0); } String? _highResCoverUrl(String? url) {