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
This commit is contained in:
zarzet
2026-06-28 22:23:08 +07:00
parent 12fb942f16
commit ef60bba2e1
4 changed files with 557 additions and 213 deletions
+240 -81
View File
@@ -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<DownloadedAlbumScreen> {
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<DownloadedAlbumScreen> {
}
}
Future<void> _openFile(DownloadHistoryItem track) async {
Future<void> _openFile(
DownloadHistoryItem track, {
List<DownloadHistoryItem> 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<DownloadedAlbumScreen> {
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<DownloadedAlbumScreen> {
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<DownloadedAlbumScreen> {
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<DownloadedAlbumScreen> {
),
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<DownloadedAlbumScreen> {
);
}
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<DownloadHistoryItem> tracks,
String? commonQuality,
) {
final totalSeconds = tracks.fold<int>(
0,
(sum, t) => sum + ((t.duration ?? 0) > 0 ? t.duration! : 0),
);
final totalMinutes = (totalSeconds / 60).round();
final parts = <Widget>[];
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<void> _playAll(List<DownloadHistoryItem> tracks) async {
if (tracks.isEmpty) return;
await ref.read(musicPlayerControllerProvider).setShuffle(false);
await _openFile(tracks.first, queueItems: tracks);
}
Future<void> _shuffleAll(List<DownloadHistoryItem> 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<DownloadedAlbumScreen> {
? 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(
+136 -38
View File
@@ -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,
),
+180 -93
View File
@@ -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<LocalAlbumScreen> {
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<LocalLibraryItem> _buildSortedTracks() {
@@ -231,13 +234,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
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<LocalAlbumScreen> {
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<LocalAlbumScreen> {
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<LocalAlbumScreen> {
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<LocalAlbumScreen> {
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<LocalAlbumScreen> {
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<LocalAlbumScreen> {
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<int>(
0,
(sum, t) => sum + ((t.duration ?? 0) > 0 ? t.duration! : 0),
);
final totalMinutes = (totalSeconds / 60).round();
final parts = <Widget>[];
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<void> _playAll() async {
final tracks = _sortedTracksCache;
if (tracks.isEmpty) return;
await ref.read(musicPlayerControllerProvider).setShuffle(false);
await _openFile(tracks.first);
}
Future<void> _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<LocalLibraryItem> tracks) {
if (tracks.isEmpty) return null;
final first = tracks.first;
+1 -1
View File
@@ -240,7 +240,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
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) {