From 05415d96d5282b5953d2bbe506352e2d5ab84183 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sat, 11 Jul 2026 16:36:29 +0700 Subject: [PATCH] refactor(screens): unify album header, selection UI, and batch engine The local/downloaded album screens had drifted copies of the album header, selection bottom bar, disc chip, batch convert/ReplayGain trio, and assorted small helpers. All now delegate to the shared widgets, with the online album screen's design as the reference, and both album screens run batch actions through the queue_tab engine via UnifiedLibraryItem (strict-superset implementation covering both DB writebacks and SAF paths). Also folds the remaining per-screen helper copies (cover URL, error card, byte/clock formatting, readPositiveInt) into their shared homes. Intentional deltas: selection-bar strings follow queue_tab's l10n keys, both providers reload after a conversion, and audio-analysis durations round instead of floor. --- lib/screens/album_screen.dart | 427 ++---- lib/screens/artist_screen.dart | 66 +- lib/screens/downloaded_album_screen.dart | 1122 +++------------- lib/screens/home_tab.dart | 59 +- lib/screens/library_tracks_folder_screen.dart | 279 ++-- lib/screens/local_album_screen.dart | 1155 +++-------------- lib/screens/now_playing_screen.dart | 11 +- lib/screens/playlist_screen.dart | 22 +- lib/screens/queue_tab.dart | 10 +- lib/screens/queue_tab_batch_actions.dart | 674 +--------- lib/screens/queue_tab_helpers.dart | 183 --- lib/screens/queue_tab_item_widgets.dart | 279 ++-- lib/screens/queue_tab_widgets.dart | 59 - lib/screens/track_metadata_actions.dart | 9 - lib/screens/track_metadata_cards.dart | 12 +- lib/screens/track_metadata_convert.dart | 4 +- lib/screens/track_metadata_screen.dart | 18 +- lib/utils/audio_conversion_utils.dart | 9 - lib/widgets/audio_analysis_widget.dart | 28 +- 19 files changed, 693 insertions(+), 3733 deletions(-) diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index fd625484..8da638c1 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -1,4 +1,3 @@ -import 'dart:ui' show ImageFilter; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; @@ -13,6 +12,9 @@ import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/image_cache_utils.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; +import 'package:spotiflac_android/utils/cover_art_utils.dart'; +import 'package:spotiflac_android/widgets/error_card.dart'; +import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/utils/provider_resource_ids.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; @@ -168,21 +170,6 @@ class _AlbumScreenState extends ConsumerState { return (mediaSize.height * 0.6).clamp(400.0, 580.0); } - 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; - } - String _formatReleaseDate(String date) { if (date.length >= 10) { final parts = date.substring(0, 10).split('-'); @@ -326,13 +313,6 @@ class _AlbumScreenState extends ConsumerState { return stripPrefixedResourceId(widget.albumId); } - double _albumTitleFontSize() { - final length = widget.albumName.trim().length; - if (length > 45) return 18; - if (length > 30) return 21; - return 24; - } - Widget _metaInlineItem(IconData? icon, String label) { const textStyle = TextStyle( color: Colors.white, @@ -487,7 +467,7 @@ class _AlbumScreenState extends ConsumerState { SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(16), - child: _buildErrorWidget(_error!, colorScheme), + child: ErrorCard(error: _error!, colorScheme: colorScheme), ), ), if (!_isLoading && _error == null && tracks.isNotEmpty) ...[ @@ -522,255 +502,113 @@ class _AlbumScreenState extends ConsumerState { final showSquareCover = !hasMotion; _tallHeader = false; final expandedHeight = _calculateExpandedHeight(context); + final cacheWidth = coverCacheWidthForViewport(context); + final headerBgUrl = + _headerImageUrl ?? widget.headerImageUrl ?? widget.coverUrl; + final Widget headerBgImage = headerBgUrl != null + ? CachedNetworkImage( + imageUrl: highResCoverUrl(headerBgUrl) ?? headerBgUrl, + fit: BoxFit.cover, + memCacheWidth: cacheWidth, + cacheManager: CoverCacheManager.instance, + placeholder: (_, _) => Container(color: colorScheme.surface), + errorWidget: (_, _, _) => Container(color: colorScheme.surface), + ) + : Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.album, + size: 80, + color: colorScheme.onSurfaceVariant, + ), + ); - return SliverAppBar( + return AlbumDetailHeader( + title: widget.albumName, expandedHeight: expandedHeight, - pinned: true, - stretch: true, + showTitleInAppBar: _showTitleInAppBar, backgroundColor: pageBackgroundColor, - surfaceTintColor: Colors.transparent, - title: AnimatedOpacity( - duration: const Duration(milliseconds: 200), - opacity: _showTitleInAppBar ? 1.0 : 0.0, - child: Text( - widget.albumName, - style: TextStyle( - color: colorScheme.onSurface, - fontWeight: FontWeight.w600, - fontSize: 16, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - flexibleSpace: LayoutBuilder( - builder: (context, constraints) { - final collapseRatio = - (constraints.maxHeight - kToolbarHeight) / - (expandedHeight - kToolbarHeight); - final showContent = collapseRatio > 0.3; - final cacheWidth = coverCacheWidthForViewport(context); - final headerBgUrl = - _headerImageUrl ?? widget.headerImageUrl ?? widget.coverUrl; - final Widget headerBgImage = headerBgUrl != null - ? CachedNetworkImage( - imageUrl: _highResCoverUrl(headerBgUrl) ?? headerBgUrl, - fit: BoxFit.cover, - memCacheWidth: cacheWidth, - cacheManager: CoverCacheManager.instance, - placeholder: (_, _) => Container(color: colorScheme.surface), - errorWidget: (_, _, _) => - Container(color: colorScheme.surface), - ) - : Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.album, - size: 80, - color: colorScheme.onSurfaceVariant, - ), - ); - - return FlexibleSpaceBar( - collapseMode: CollapseMode.pin, - background: Stack( - fit: StackFit.expand, - children: [ - if (hasMotion) - MotionHeaderBanner( - videoUrl: motionUrl, - fallback: headerBgImage, - ) - else if (showSquareCover) - ImageFiltered( - imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32), - child: headerBgImage, - ) - else - headerBgImage, - if (showSquareCover) - Container(color: Colors.black.withValues(alpha: 0.35)), - Positioned( - left: 0, - right: 0, - bottom: 0, - height: expandedHeight * 0.65, - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withValues(alpha: 0.85), - ], + background: hasMotion + ? MotionHeaderBanner(videoUrl: motionUrl, fallback: headerBgImage) + : headerBgImage, + blurAndScrimBackground: showSquareCover, + coverBuilder: showSquareCover + ? (context, coverSize) => coverThumbUrl != null + ? CachedNetworkImage( + imageUrl: highResCoverUrl(coverThumbUrl) ?? coverThumbUrl, + fit: BoxFit.cover, + width: coverSize, + height: coverSize, + memCacheWidth: cacheWidth, + cacheManager: CoverCacheManager.instance, + placeholder: (_, _) => + Container(color: colorScheme.surfaceContainerHighest), + errorWidget: (_, _, _) => Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.album, + size: 48, + color: colorScheme.onSurfaceVariant, ), ), - ), - ), - 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: [ - if (showSquareCover) ...[ - 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: coverThumbUrl != null - ? CachedNetworkImage( - imageUrl: - _highResCoverUrl(coverThumbUrl) ?? - coverThumbUrl, - fit: BoxFit.cover, - width: coverSize, - height: coverSize, - memCacheWidth: cacheWidth, - cacheManager: - CoverCacheManager.instance, - placeholder: (_, _) => Container( - color: colorScheme - .surfaceContainerHighest, - ), - errorWidget: (_, _, _) => 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: TextStyle( - color: Colors.white, - fontSize: _albumTitleFontSize(), - fontWeight: FontWeight.bold, - height: 1.2, - ), - textAlign: TextAlign.center, - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - if (artistName != null && artistName.isNotEmpty) ...[ - const SizedBox(height: 6), - ClickableArtistName( - artistName: artistName, - artistId: _artistId, - coverUrl: widget.coverUrl, - extensionId: widget.extensionId, - style: TextStyle( - color: colorScheme.primary, - fontSize: 16, - fontWeight: FontWeight.w600, - ), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - const SizedBox(height: 12), - _buildHeaderMeta(context, releaseDate), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _buildLoveAllButton(), - const SizedBox(width: 12), - Flexible( - child: FilledButton.icon( - onPressed: tracks.isEmpty - ? null - : () => _downloadAll(context), - icon: const Icon(Icons.download, size: 18), - label: Text( - context.l10n.downloadAllCount(tracks.length), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - style: FilledButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black87, - disabledBackgroundColor: Colors.white - .withValues(alpha: 0.45), - disabledForegroundColor: Colors.black54, - minimumSize: const Size(0, 48), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), - ), - ), - ), - ), - const SizedBox(width: 12), - _buildAddToPlaylistButton(context), - ], - ), - ], + ) + : Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.album, + size: 48, + color: colorScheme.onSurfaceVariant, ), - ), + ) + : null, + subtitle: (artistName != null && artistName.isNotEmpty) + ? ClickableArtistName( + artistName: artistName, + artistId: _artistId, + coverUrl: widget.coverUrl, + extensionId: widget.extensionId, + style: TextStyle( + color: colorScheme.primary, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + : null, + meta: _buildHeaderMeta(context, releaseDate), + actions: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildLoveAllButton(), + const SizedBox(width: 12), + Flexible( + child: FilledButton.icon( + onPressed: tracks.isEmpty ? null : () => _downloadAll(context), + icon: const Icon(Icons.download, size: 18), + label: Text( + context.l10n.downloadAllCount(tracks.length), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black87, + disabledBackgroundColor: Colors.white.withValues(alpha: 0.45), + disabledForegroundColor: Colors.black54, + minimumSize: const Size(0, 48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), ), - ], + ), ), - stretchModes: const [StretchMode.zoomBackground], - ); - }, - ), - leading: IconButton( - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - icon: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.4), - shape: BoxShape.circle, ), - child: const Icon(Icons.arrow_back, color: Colors.white), - ), - onPressed: () => Navigator.pop(context), + const SizedBox(width: 12), + _buildAddToPlaylistButton(context), + ], ), - actions: [ + appBarActions: [ Padding( padding: const EdgeInsets.only(right: 8), child: IconButton( @@ -1139,67 +977,4 @@ class _AlbumScreenState extends ConsumerState { } } - Widget _buildErrorWidget(String error, ColorScheme colorScheme) { - final isRateLimit = - error.contains('429') || - error.toLowerCase().contains('rate limit') || - error.toLowerCase().contains('too many requests'); - - if (isRateLimit) { - return Card( - elevation: 0, - color: colorScheme.errorContainer, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Icon(Icons.timer_off, color: colorScheme.onErrorContainer), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.errorRateLimited, - style: TextStyle( - color: colorScheme.onErrorContainer, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Text( - context.l10n.errorRateLimitedMessage, - style: TextStyle( - color: colorScheme.onErrorContainer, - fontSize: 12, - ), - ), - ], - ), - ), - ], - ), - ), - ); - } - - return Card( - elevation: 0, - color: colorScheme.errorContainer.withValues(alpha: 0.5), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Icon(Icons.error_outline, color: colorScheme.error), - const SizedBox(width: 12), - Expanded( - child: Text(error, style: TextStyle(color: colorScheme.error)), - ), - ], - ), - ), - ); - } } diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 1b6c0145..ef50a170 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -20,6 +20,7 @@ import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen; import 'package:spotiflac_android/utils/local_playback.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; +import 'package:spotiflac_android/widgets/error_card.dart'; import 'package:spotiflac_android/widgets/in_library_badge.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; @@ -507,7 +508,7 @@ class _ArtistScreenState extends ConsumerState { SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(16), - child: _buildErrorWidget(_error!, colorScheme), + child: ErrorCard(error: _error!, colorScheme: colorScheme), ), ), if (!_isLoadingDiscography && _error == null) ...[ @@ -1946,69 +1947,6 @@ class _ArtistScreenState extends ConsumerState { } } - Widget _buildErrorWidget(String error, ColorScheme colorScheme) { - final isRateLimit = - error.contains('429') || - error.toLowerCase().contains('rate limit') || - error.toLowerCase().contains('too many requests'); - - if (isRateLimit) { - return Card( - elevation: 0, - color: colorScheme.errorContainer, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Icon(Icons.timer_off, color: colorScheme.onErrorContainer), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.errorRateLimited, - style: TextStyle( - color: colorScheme.onErrorContainer, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Text( - context.l10n.errorRateLimitedMessage, - style: TextStyle( - color: colorScheme.onErrorContainer, - fontSize: 12, - ), - ), - ], - ), - ), - ], - ), - ), - ); - } - - return Card( - elevation: 0, - color: colorScheme.errorContainer.withValues(alpha: 0.5), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Icon(Icons.error_outline, color: colorScheme.error), - const SizedBox(width: 12), - Expanded( - child: Text(error, style: TextStyle(color: colorScheme.error)), - ), - ], - ), - ), - ); - } } class _DiscographyOptionTile extends StatelessWidget { diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 25a0d443..8b7f555e 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -1,32 +1,29 @@ 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'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:share_plus/share_plus.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; -import 'package:spotiflac_android/services/ffmpeg_service.dart'; -import 'package:spotiflac_android/services/replaygain_service.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; -import 'package:spotiflac_android/services/history_database.dart'; +import 'package:spotiflac_android/services/batch_track_actions.dart'; +import 'package:spotiflac_android/models/unified_library_item.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; -import 'package:spotiflac_android/utils/audio_conversion_utils.dart'; +import 'package:spotiflac_android/utils/cover_art_utils.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/image_cache_utils.dart'; -import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; 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'; import 'package:spotiflac_android/widgets/animation_utils.dart'; +import 'package:spotiflac_android/widgets/selection_action_button.dart'; +import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; +import 'package:spotiflac_android/widgets/disc_separator_chip.dart'; +import 'package:spotiflac_android/widgets/album_detail_header.dart'; class DownloadedAlbumScreen extends ConsumerStatefulWidget { final String albumName; @@ -103,21 +100,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { return (mediaSize.height * 0.6).clamp(400.0, 580.0); } - 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; - } - List _getAlbumTracks( List allItems, ) { @@ -471,233 +453,70 @@ class _DownloadedAlbumScreenState extends ConsumerState { final embeddedCoverPath = _resolveAlbumEmbeddedCoverPath(tracks); final commonQuality = _getCommonQuality(tracks); - return SliverAppBar( - expandedHeight: expandedHeight, - pinned: true, - stretch: true, - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - title: AnimatedOpacity( - duration: const Duration(milliseconds: 200), - opacity: _showTitleInAppBar ? 1.0 : 0.0, - child: Text( - widget.albumName, - style: TextStyle( - color: colorScheme.onSurface, - fontWeight: FontWeight.w600, - fontSize: 16, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - flexibleSpace: LayoutBuilder( - builder: (context, constraints) { - final collapseRatio = - (constraints.maxHeight - kToolbarHeight) / - (expandedHeight - kToolbarHeight); - final showContent = collapseRatio > 0.3; - final cacheWidth = coverCacheWidthForViewport(context); - - return FlexibleSpaceBar( - collapseMode: CollapseMode.pin, - background: Stack( - fit: StackFit.expand, - children: [ - if (embeddedCoverPath != null) - 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) - 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( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.album, - size: 80, - color: colorScheme.onSurfaceVariant, - ), - ), - if (embeddedCoverPath != null || widget.coverUrl != null) - Container(color: Colors.black.withValues(alpha: 0.35)), - Positioned( - left: 0, - right: 0, - bottom: 0, - height: expandedHeight * 0.65, - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withValues(alpha: 0.85), - ], - ), - ), - ), - ), - Positioned( - left: 20, - right: 20, - bottom: 40, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 150), - opacity: showContent ? 1.0 : 0.0, - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Builder( - builder: (context) { - final coverSize = (constraints.maxWidth * 0.5) - .clamp(150.0, 210.0) - .toDouble(); - 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: TextStyle( - color: Colors.white, - fontSize: _albumTitleFontSize(), - fontWeight: FontWeight.bold, - height: 1.2, - ), - textAlign: TextAlign.center, - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - 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), - _buildDownloadedHeaderMeta( - context, - tracks, - commonQuality, - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - 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: 12), - Container( - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - shape: BoxShape.circle, - ), - child: IconButton( - tooltip: context.l10n.actionShuffle, - onPressed: () => _shuffleAll(tracks), - icon: const Icon( - Icons.shuffle, - color: Colors.white, - ), - ), - ), - ], - ), - ], - ], - ), - ), - ), - ], + final cacheWidth = coverCacheWidthForViewport(context); + final Widget background = embeddedCoverPath != null + ? Image.file( + File(embeddedCoverPath), + fit: BoxFit.cover, + cacheWidth: cacheWidth, + gaplessPlayback: true, + filterQuality: FilterQuality.low, + errorBuilder: (_, _, _) => Container(color: colorScheme.surface), + ) + : 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), + ) + : Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.album, + size: 80, + color: colorScheme.onSurfaceVariant, ), - stretchModes: const [StretchMode.zoomBackground], ); - }, + + return AlbumDetailHeader( + title: widget.albumName, + expandedHeight: expandedHeight, + showTitleInAppBar: _showTitleInAppBar, + background: background, + blurAndScrimBackground: + embeddedCoverPath != null || widget.coverUrl != null, + coverBuilder: (context, coverSize) => _buildSquareCover( + context, + colorScheme, + embeddedCoverPath, + coverSize, + cacheWidth, ), - leading: IconButton( - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - icon: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.4), - shape: BoxShape.circle, - ), - child: const Icon(Icons.arrow_back, color: Colors.white), + subtitle: Text( + widget.artistName, + style: const TextStyle( + color: Colors.white70, + fontSize: 16, + fontWeight: FontWeight.w600, ), - onPressed: () => Navigator.pop(context), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), + meta: tracks.isNotEmpty + ? _buildDownloadedHeaderMeta(context, tracks, commonQuality) + : null, + actions: tracks.isNotEmpty + ? AlbumPlayActions( + playLabel: context.l10n.tooltipPlay, + shuffleTooltip: context.l10n.actionShuffle, + onPlay: () => _playAll(tracks), + onShuffle: () => _shuffleAll(tracks), + ) + : null, ); } @@ -728,7 +547,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { final coverUrl = widget.coverUrl; if (coverUrl != null && coverUrl.isNotEmpty) { return CachedNetworkImage( - imageUrl: _highResCoverUrl(coverUrl) ?? coverUrl, + imageUrl: highResCoverUrl(coverUrl) ?? coverUrl, fit: BoxFit.cover, width: coverSize, height: coverSize, @@ -742,13 +561,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { 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, @@ -899,7 +711,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { final discTracks = discMap[discNumber]; if (discTracks == null || discTracks.isEmpty) continue; - children.add(_buildDiscSeparator(context, colorScheme, discNumber)); + children.add(DiscSeparatorChip(discNumber: discNumber)); for (final track in discTracks) { final navigationIndex = tracks.indexOf(track); @@ -924,52 +736,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { return SliverList(delegate: SliverChildListDelegate(children)); } - Widget _buildDiscSeparator( - BuildContext context, - ColorScheme colorScheme, - int discNumber, - ) { - return Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), - child: Row( - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(16), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.album, - size: 16, - color: colorScheme.onSecondaryContainer, - ), - const SizedBox(width: 6), - Text( - context.l10n.downloadedAlbumDiscHeader(discNumber), - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: colorScheme.onSecondaryContainer, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - const SizedBox(width: 12), - Expanded( - child: Container( - height: 1, - color: colorScheme.outlineVariant.withValues(alpha: 0.5), - ), - ), - ], - ), - ); - } - Widget _buildTrackItem( BuildContext context, ColorScheme colorScheme, @@ -1098,457 +864,14 @@ class _DownloadedAlbumScreenState extends ConsumerState { } } - void _showBatchConvertSheet( - BuildContext context, - List allTracks, + List _selectedUnifiedItems( + List tracks, ) { - final tracksById = {for (final t in allTracks) t.id: t}; - final sourceFormats = {}; - final sourceBitDepths = []; - final sourceSampleRates = []; - for (final id in _selectedIds) { - final item = tracksById[id]; - if (item == null) continue; - final sourceFormat = convertibleAudioSourceFormat( - storedFormat: item.format, - filePath: item.filePath, - fileName: item.safFileName, - ); - if (sourceFormat != null) sourceFormats.add(sourceFormat); - sourceBitDepths.add(item.bitDepth); - sourceSampleRates.add(item.sampleRate); - } - - final formats = audioConversionTargetFormats - .where( - (target) => sourceFormats.any( - (source) => canConvertAudioFormat( - sourceFormat: source, - targetFormat: target, - ), - ), - ) - .toList(); - - if (formats.isEmpty) return; - - final sheetTitle = context.l10n.selectionBatchConvertConfirmTitle; - final sheetConfirmLabel = context.l10n.selectionConvertCount( - _selectedIds.length, - ); - - showModalBottomSheet( - context: context, - useRootNavigator: true, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (sheetContext) => BatchConvertSheet( - formats: formats, - title: sheetTitle, - confirmLabel: sheetConfirmLabel, - sourceBitDepth: lowestKnownPositiveInt(sourceBitDepths), - sourceSampleRate: lowestKnownPositiveInt(sourceSampleRates), - onConvert: (format, bitrate, losslessQuality, losslessProcessing) { - Navigator.pop(sheetContext); - _performBatchConversion( - allTracks: allTracks, - targetFormat: format, - bitrate: bitrate, - losslessQuality: losslessQuality, - losslessProcessing: losslessProcessing, - ); - }, - ), - ); - } - - Future _performBatchConversion({ - required List allTracks, - required String targetFormat, - required String bitrate, - LosslessConversionQuality losslessQuality = - const LosslessConversionQuality(), - LosslessConversionProcessing losslessProcessing = - const LosslessConversionProcessing(), - }) async { - final tracksById = {for (final t in allTracks) t.id: t}; - final selected = []; - for (final id in _selectedIds) { - final item = tracksById[id]; - if (item == null) continue; - final sourceFormat = convertibleAudioSourceFormat( - storedFormat: item.format, - filePath: item.filePath, - fileName: item.safFileName, - ); - if (sourceFormat == null || - !canConvertAudioFormat( - sourceFormat: sourceFormat, - targetFormat: targetFormat, - )) { - continue; - } - selected.add(item); - } - - if (selected.isEmpty) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.selectionConvertNoConvertible)), - ); - } - return; - } - - final isLossless = isLosslessConversionTarget(targetFormat); - final losslessLabels = context.l10n.losslessConversionLabels; - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(context.l10n.selectionBatchConvertConfirmTitle), - content: Text( - isLossless && losslessQuality.hasCaps - ? context.l10n.selectionBatchConvertConfirmMessageLosslessCapped( - selected.length, - targetFormat, - losslessQualityLabel( - losslessQuality, - originalLabel: losslessLabels.original, - originalQualityLabel: losslessLabels.originalQuality, - ), - ) - : isLossless - ? context.l10n.selectionBatchConvertConfirmMessageLossless( - selected.length, - targetFormat, - ) - : context.l10n.selectionBatchConvertConfirmMessage( - selected.length, - targetFormat, - bitrate, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: Text(context.l10n.dialogCancel), - ), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - child: Text(context.l10n.trackConvertFormat), - ), - ], - ), - ); - - if (confirmed != true || !mounted) return; - - int successCount = 0; - final total = selected.length; - final historyDb = HistoryDatabase.instance; - final settings = ref.read(settingsProvider); - final shouldEmbedLyrics = - settings.embedLyrics && settings.lyricsMode != 'external'; - - var cancelled = false; - BatchProgressDialog.show( - context: context, - title: context.l10n.trackConvertConverting, - total: total, - icon: Icons.transform, - onCancel: () { - cancelled = true; - BatchProgressDialog.dismiss(context); - }, - ); - - for (int i = 0; i < total; i++) { - if (!mounted || cancelled) break; - final item = selected[i]; - - BatchProgressDialog.update(current: i + 1, detail: item.trackName); - - try { - final metadata = { - 'TITLE': item.trackName, - 'ARTIST': item.artistName, - 'ALBUM': item.albumName, - }; - try { - final result = await PlatformBridge.readFileMetadata(item.filePath); - if (result['error'] == null) { - mergePlatformMetadataForTagEmbed(target: metadata, source: result); - } - } catch (_) {} - await ensureLyricsMetadataForConversion( - metadata: metadata, - sourcePath: item.filePath, - shouldEmbedLyrics: shouldEmbedLyrics, - trackName: item.trackName, - artistName: item.artistName, - spotifyId: item.spotifyId ?? '', - durationMs: (item.duration ?? 0) * 1000, - ); - - String? coverPath; - try { - final tempDir = await getTemporaryDirectory(); - final coverOutput = - '${tempDir.path}${Platform.pathSeparator}batch_cover_${DateTime.now().millisecondsSinceEpoch}.jpg'; - final coverResult = await PlatformBridge.extractCoverToFile( - item.filePath, - coverOutput, - ); - if (coverResult['error'] == null) coverPath = coverOutput; - } catch (_) {} - - String workingPath = item.filePath; - final isSaf = isContentUri(item.filePath); - String? safTempPath; - - if (isSaf) { - safTempPath = await PlatformBridge.copyContentUriToTemp( - item.filePath, - ); - if (safTempPath == null) continue; - workingPath = safTempPath; - } - - final newPath = await FFmpegService.convertAudioFormat( - inputPath: workingPath, - targetFormat: targetFormat.toLowerCase(), - bitrate: bitrate, - metadata: metadata, - coverPath: coverPath, - artistTagMode: settings.artistTagMode, - deleteOriginal: !isSaf, - sourceBitDepth: item.bitDepth, - losslessQuality: losslessQuality, - losslessProcessing: losslessProcessing, - ); - - if (coverPath != null) { - try { - await File(coverPath).delete(); - } catch (_) {} - } - - if (newPath == null) { - if (safTempPath != null) { - try { - await File(safTempPath).delete(); - } catch (_) {} - } - continue; - } - - final isLosslessOutput = isLosslessConversionTarget(targetFormat); - int? convertedBitDepth; - int? convertedSampleRate; - if (isLosslessOutput) { - try { - final convertedMetadata = await PlatformBridge.readFileMetadata( - newPath, - ); - if (convertedMetadata['error'] == null) { - convertedBitDepth = readPositiveAudioInt( - convertedMetadata['bit_depth'], - ); - convertedSampleRate = readPositiveAudioInt( - convertedMetadata['sample_rate'], - ); - } - } catch (_) {} - convertedBitDepth ??= losslessQuality.effectiveBitDepth( - item.bitDepth, - ); - convertedSampleRate ??= losslessQuality.effectiveSampleRate( - item.sampleRate, - ); - } - final newQuality = convertedAudioQualityLabel( - targetFormat: targetFormat, - bitrate: bitrate, - labels: losslessLabels, - losslessQuality: losslessQuality, - actualBitDepth: convertedBitDepth, - actualSampleRate: convertedSampleRate, - ); - - if (isSaf) { - final treeUri = item.downloadTreeUri; - final relativeDir = item.safRelativeDir ?? ''; - if (treeUri != null && treeUri.isNotEmpty) { - final oldFileName = item.safFileName ?? ''; - final dotIdx = oldFileName.lastIndexOf('.'); - final baseName = dotIdx > 0 - ? oldFileName.substring(0, dotIdx) - : oldFileName; - final convTarget = convertTargetExtAndMime(targetFormat); - final newExt = convTarget.ext; - final mimeType = convTarget.mime; - final newFileName = '$baseName$newExt'; - - final safUri = await PlatformBridge.createSafFileFromPath( - treeUri: treeUri, - relativeDir: relativeDir, - fileName: newFileName, - mimeType: mimeType, - srcPath: newPath, - ); - - if (safUri == null || safUri.isEmpty) { - try { - await File(newPath).delete(); - } catch (_) {} - if (safTempPath != null) { - try { - await File(safTempPath).delete(); - } catch (_) {} - } - continue; - } - - if (!isSameContentUri(item.filePath, safUri)) { - try { - await PlatformBridge.safDelete(item.filePath); - } catch (_) {} - } - await historyDb.updateFilePath( - item.id, - safUri, - newSafFileName: newFileName, - newQuality: newQuality, - newFormat: normalizedConvertedAudioFormat(targetFormat), - newBitrate: convertedAudioBitrateKbps( - targetFormat: targetFormat, - bitrate: bitrate, - ), - newBitDepth: convertedBitDepth, - newSampleRate: convertedSampleRate, - clearAudioSpecs: !isLosslessOutput, - ); - } - try { - await File(newPath).delete(); - } catch (_) {} - if (safTempPath != null) { - try { - await File(safTempPath).delete(); - } catch (_) {} - } - } else { - await historyDb.updateFilePath( - item.id, - newPath, - newQuality: newQuality, - newFormat: normalizedConvertedAudioFormat(targetFormat), - newBitrate: convertedAudioBitrateKbps( - targetFormat: targetFormat, - bitrate: bitrate, - ), - newBitDepth: convertedBitDepth, - newSampleRate: convertedSampleRate, - clearAudioSpecs: !isLosslessOutput, - ); - } - - successCount++; - } catch (_) {} - } - - ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); - _exitSelectionMode(); - - if (mounted) { - if (!cancelled) { - BatchProgressDialog.dismiss(context); - } - ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.selectionBatchConvertSuccess( - successCount, - total, - targetFormat, - ), - ), - ), - ); - } - } - - Future _runBatchReplayGain(List tracks) async { final tracksById = {for (final t in tracks) t.id: t}; - final selected = []; - for (final id in _selectedIds) { - final item = tracksById[id]; - if (item == null) continue; - selected.add(item); - } - - if (selected.isEmpty) return; - - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(ctx.l10n.replayGainBatchConfirmTitle), - content: Text(ctx.l10n.replayGainBatchConfirmMessage(selected.length)), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: Text(ctx.l10n.dialogCancel), - ), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - child: Text(ctx.l10n.replayGainBatchConfirmTitle), - ), - ], - ), - ); - - if (confirmed != true || !mounted) return; - - var cancelled = false; - int successCount = 0; - final total = selected.length; - - BatchProgressDialog.show( - context: context, - title: context.l10n.replayGainBatchAnalyzing, - total: total, - icon: Icons.graphic_eq, - onCancel: () { - cancelled = true; - BatchProgressDialog.dismiss(context); - }, - ); - - for (int i = 0; i < total; i++) { - if (!mounted || cancelled) break; - final item = selected[i]; - BatchProgressDialog.update(current: i + 1, detail: item.trackName); - try { - final ok = await ReplayGainService.applyToFile(item.filePath); - if (ok) successCount++; - } catch (_) {} - } - - _exitSelectionMode(); - - if (!mounted) return; - if (!cancelled) { - BatchProgressDialog.dismiss(context); - } - ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.replayGainBatchSuccess(successCount, total)), - ), - ); + return [ + for (final t in _selectedIds.map((id) => tracksById[id])) + if (t != null) UnifiedLibraryItem.fromDownloadHistory(t), + ]; } Widget _buildSelectionBottomBar( @@ -1560,227 +883,96 @@ class _DownloadedAlbumScreenState extends ConsumerState { final selectedCount = _selectedIds.length; final allSelected = selectedCount == tracks.length && tracks.isNotEmpty; - return Container( - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHigh, - borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.15), - blurRadius: 12, - offset: const Offset(0, -4), - ), - ], - ), - child: SafeArea( - top: false, - child: Padding( - padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 32, - height: 4, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: colorScheme.outlineVariant, - borderRadius: BorderRadius.circular(2), - ), + return SelectionBottomBar( + selectedCount: selectedCount, + allSelected: allSelected, + onClose: _exitSelectionMode, + onToggleSelectAll: () { + if (allSelected) { + _exitSelectionMode(); + } else { + _selectAll(tracks); + } + }, + bottomPadding: bottomPadding, + children: [ + LayoutBuilder( + builder: (context, constraints) { + const spacing = 8.0; + final itemWidth = (constraints.maxWidth - spacing) / 2; + final actions = [ + SelectionActionButton( + icon: Icons.share_outlined, + label: context.l10n.selectionShareCount(selectedCount), + onPressed: selectedCount > 0 + ? () => _shareSelected(tracks) + : null, + colorScheme: colorScheme, ), - Row( - children: [ - IconButton.filledTonal( - onPressed: _exitSelectionMode, - tooltip: MaterialLocalizations.of( - context, - ).closeButtonTooltip, - icon: const Icon(Icons.close), - style: IconButton.styleFrom( - backgroundColor: colorScheme.surfaceContainerHighest, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.downloadedAlbumSelectedCount( - selectedCount, - ), - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.bold), - ), - Text( - allSelected - ? context.l10n.downloadedAlbumAllSelected - : context.l10n.downloadedAlbumTapToSelect, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: colorScheme.onSurfaceVariant), - ), - ], - ), - ), - TextButton.icon( - onPressed: () { - if (allSelected) { - _exitSelectionMode(); - } else { - _selectAll(tracks); - } - }, - icon: Icon( - allSelected ? Icons.deselect : Icons.select_all, - size: 20, - ), - label: Text( - allSelected - ? context.l10n.actionDeselect - : context.l10n.actionSelectAll, - ), - style: TextButton.styleFrom( - foregroundColor: colorScheme.primary, - ), - ), - ], + SelectionActionButton( + icon: Icons.swap_horiz, + label: context.l10n.selectionConvertCount(selectedCount), + onPressed: selectedCount > 0 + ? () => showBatchConvertSheet( + context, + ref, + _selectedUnifiedItems(tracks), + onExitSelectionMode: _exitSelectionMode, + ) + : null, + colorScheme: colorScheme, ), - const SizedBox(height: 12), + SelectionActionButton( + icon: Icons.graphic_eq, + label: context.l10n.selectionReplayGainCount(selectedCount), + onPressed: selectedCount > 0 + ? () => runBatchReplayGain( + context, + _selectedUnifiedItems(tracks), + onExitSelectionMode: _exitSelectionMode, + ) + : null, + colorScheme: colorScheme, + ), + ]; - LayoutBuilder( - builder: (context, constraints) { - const spacing = 8.0; - final itemWidth = (constraints.maxWidth - spacing) / 2; - final actions = [ - _DownloadedAlbumSelectionActionButton( - icon: Icons.share_outlined, - label: context.l10n.selectionShareCount(selectedCount), - onPressed: selectedCount > 0 - ? () => _shareSelected(tracks) - : null, - colorScheme: colorScheme, - ), - _DownloadedAlbumSelectionActionButton( - icon: Icons.swap_horiz, - label: context.l10n.selectionConvertCount(selectedCount), - onPressed: selectedCount > 0 - ? () => _showBatchConvertSheet(context, tracks) - : null, - colorScheme: colorScheme, - ), - _DownloadedAlbumSelectionActionButton( - icon: Icons.graphic_eq, - label: context.l10n.selectionReplayGainCount( - selectedCount, - ), - onPressed: selectedCount > 0 - ? () => _runBatchReplayGain(tracks) - : null, - colorScheme: colorScheme, - ), - ]; + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: [ + for (final action in actions) + SizedBox(width: itemWidth, child: action), + ], + ); + }, + ), - return Wrap( - spacing: spacing, - runSpacing: spacing, - children: [ - for (final action in actions) - SizedBox(width: itemWidth, child: action), - ], - ); - }, + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null, + icon: const Icon(Icons.delete_outline), + label: Text( + selectedCount > 0 + ? context.l10n.downloadedAlbumDeleteCount(selectedCount) + : context.l10n.downloadedAlbumSelectToDelete, + ), + style: FilledButton.styleFrom( + backgroundColor: selectedCount > 0 + ? colorScheme.error + : colorScheme.surfaceContainerHighest, + foregroundColor: selectedCount > 0 + ? colorScheme.onError + : colorScheme.onSurfaceVariant, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), ), - - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: selectedCount > 0 - ? () => _deleteSelected(tracks) - : null, - icon: const Icon(Icons.delete_outline), - label: Text( - selectedCount > 0 - ? context.l10n.downloadedAlbumDeleteCount(selectedCount) - : context.l10n.downloadedAlbumSelectToDelete, - ), - style: FilledButton.styleFrom( - backgroundColor: selectedCount > 0 - ? colorScheme.error - : colorScheme.surfaceContainerHighest, - foregroundColor: selectedCount > 0 - ? colorScheme.onError - : colorScheme.onSurfaceVariant, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), - ), - ), - ], + ), ), ), - ), - ); - } -} - -class _DownloadedAlbumSelectionActionButton extends StatelessWidget { - final IconData icon; - final String label; - final VoidCallback? onPressed; - final ColorScheme colorScheme; - - const _DownloadedAlbumSelectionActionButton({ - required this.icon, - required this.label, - required this.onPressed, - required this.colorScheme, - }); - - @override - Widget build(BuildContext context) { - final isDisabled = onPressed == null; - return Material( - color: isDisabled - ? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5) - : colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(14), - child: InkWell( - onTap: onPressed, - borderRadius: BorderRadius.circular(14), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - icon, - size: 18, - color: isDisabled - ? colorScheme.onSurfaceVariant.withValues(alpha: 0.5) - : colorScheme.onSecondaryContainer, - ), - const SizedBox(width: 6), - Flexible( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: isDisabled - ? colorScheme.onSurfaceVariant.withValues(alpha: 0.5) - : colorScheme.onSecondaryContainer, - ), - ), - ), - ], - ), - ), - ), + ], ); } } diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index c542f871..aad6b58c 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -32,6 +32,7 @@ import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; +import 'package:spotiflac_android/widgets/error_card.dart'; import 'package:spotiflac_android/widgets/in_library_badge.dart'; import 'package:spotiflac_android/widgets/preview_button.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; @@ -2574,42 +2575,7 @@ class _HomeTabState extends ConsumerState final isUrlNotRecognized = error == 'url_not_recognized'; if (isRateLimit) { - return Card( - elevation: 0, - color: colorScheme.errorContainer, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Icon(Icons.timer_off, color: colorScheme.onErrorContainer), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.errorRateLimited, - style: TextStyle( - color: colorScheme.onErrorContainer, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Text( - l10n.errorRateLimitedMessage, - style: TextStyle( - color: colorScheme.onErrorContainer, - fontSize: 12, - ), - ), - ], - ), - ), - ], - ), - ), - ); + return ErrorCard(error: error, colorScheme: colorScheme); } if (isUrlNotRecognized) { @@ -2648,26 +2614,7 @@ class _HomeTabState extends ConsumerState ); } - return Card( - elevation: 0, - color: colorScheme.errorContainer.withValues(alpha: 0.5), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Icon(Icons.error_outline, color: colorScheme.error), - const SizedBox(width: 12), - Expanded( - child: Text( - l10n.errorUrlFetchFailed, - style: TextStyle(color: colorScheme.error), - ), - ), - ], - ), - ), - ); + return ErrorCard(error: l10n.errorUrlFetchFailed, colorScheme: colorScheme); } Widget _buildEmptySearchResultWidget(ColorScheme colorScheme) { diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index 37976cfb..8830c6f5 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -16,7 +16,10 @@ import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; +import 'package:spotiflac_android/utils/cover_art_utils.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; +import 'package:spotiflac_android/widgets/selection_action_button.dart'; +import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/in_library_badge.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; @@ -113,24 +116,6 @@ class _LibraryTracksFolderScreenState return !url.startsWith('http://') && !url.startsWith('https://'); } - /// Upgrade cover URL to higher resolution for full-screen display. - String? _highResCoverUrl(String? url) { - if (url == null) return null; - // Spotify CDN: upgrade 300 → 640 - 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; - } - void _enterSelectionMode(String key) { HapticFeedback.mediumImpact(); setState(() { @@ -423,156 +408,78 @@ class _LibraryTracksFolderScreenState final allSelected = selectedCount == entries.length && entries.isNotEmpty; final isWishlist = widget.mode == LibraryTracksFolderMode.wishlist; - return Container( - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHigh, - borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.15), - blurRadius: 12, - offset: const Offset(0, -4), - ), - ], - ), - child: SafeArea( - top: false, - child: Padding( - padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 32, - height: 4, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: colorScheme.outlineVariant, - borderRadius: BorderRadius.circular(2), - ), - ), - - Row( - children: [ - IconButton.filledTonal( - onPressed: _exitSelectionMode, - tooltip: MaterialLocalizations.of( - context, - ).closeButtonTooltip, - icon: const Icon(Icons.close), - style: IconButton.styleFrom( - backgroundColor: colorScheme.surfaceContainerHighest, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.selectionSelected(selectedCount), - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.bold), - ), - Text( - allSelected - ? context.l10n.selectionAllSelected - : context.l10n.selectionSelectToDelete, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: colorScheme.onSurfaceVariant), - ), - ], - ), - ), - TextButton.icon( - onPressed: () { - if (allSelected) { - _exitSelectionMode(); - } else { - _selectAll(entries); - } - }, - icon: Icon( - allSelected ? Icons.deselect : Icons.select_all, - size: 20, - ), - label: Text( - allSelected - ? context.l10n.actionDeselect - : context.l10n.actionSelectAll, - ), - style: TextButton.styleFrom( - foregroundColor: colorScheme.primary, - ), - ), - ], - ), - - const SizedBox(height: 12), - - Row( - children: [ - if (isWishlist) - Expanded( - child: _SelectionActionButton( - icon: Icons.download, - label: - '${context.l10n.settingsDownload} ($selectedCount)', - onPressed: selectedCount > 0 - ? () => _downloadSelected(entries) - : null, - colorScheme: colorScheme, - ), - ), - if (isWishlist) const SizedBox(width: 8), - Expanded( - child: _SelectionActionButton( - icon: Icons.playlist_add, - label: - '${context.l10n.collectionAddToPlaylist} ($selectedCount)', - onPressed: selectedCount > 0 - ? () => _addSelectedToPlaylist(entries) - : null, - colorScheme: colorScheme, - ), - ), - ], - ), - - const SizedBox(height: 8), - - SizedBox( - width: double.infinity, - child: FilledButton.icon( + return SelectionBottomBar( + selectedCount: selectedCount, + allSelected: allSelected, + onClose: _exitSelectionMode, + onToggleSelectAll: () { + if (allSelected) { + _exitSelectionMode(); + } else { + _selectAll(entries); + } + }, + bottomPadding: bottomPadding, + children: [ + Row( + children: [ + if (isWishlist) + Expanded( + child: SelectionActionButton( + icon: Icons.download, + label: '${context.l10n.settingsDownload} ($selectedCount)', onPressed: selectedCount > 0 - ? () => _removeSelected(entries) + ? () => _downloadSelected(entries) : null, - icon: const Icon(Icons.remove_circle_outline), - label: Text( - selectedCount > 0 - ? '${widget.mode == LibraryTracksFolderMode.playlist ? context.l10n.collectionRemoveFromPlaylist : context.l10n.collectionRemoveFromFolder} ($selectedCount)' - : widget.mode == LibraryTracksFolderMode.playlist - ? context.l10n.collectionRemoveFromPlaylist - : context.l10n.collectionRemoveFromFolder, - ), - style: FilledButton.styleFrom( - backgroundColor: selectedCount > 0 - ? colorScheme.error - : colorScheme.surfaceContainerHighest, - foregroundColor: selectedCount > 0 - ? colorScheme.onError - : colorScheme.onSurfaceVariant, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), + colorScheme: colorScheme, ), ), - ], + if (isWishlist) const SizedBox(width: 8), + Expanded( + child: SelectionActionButton( + icon: Icons.playlist_add, + label: + '${context.l10n.collectionAddToPlaylist} ($selectedCount)', + onPressed: selectedCount > 0 + ? () => _addSelectedToPlaylist(entries) + : null, + colorScheme: colorScheme, + ), + ), + ], + ), + + const SizedBox(height: 8), + + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: selectedCount > 0 + ? () => _removeSelected(entries) + : null, + icon: const Icon(Icons.remove_circle_outline), + label: Text( + selectedCount > 0 + ? '${widget.mode == LibraryTracksFolderMode.playlist ? context.l10n.collectionRemoveFromPlaylist : context.l10n.collectionRemoveFromFolder} ($selectedCount)' + : widget.mode == LibraryTracksFolderMode.playlist + ? context.l10n.collectionRemoveFromPlaylist + : context.l10n.collectionRemoveFromFolder, + ), + style: FilledButton.styleFrom( + backgroundColor: selectedCount > 0 + ? colorScheme.error + : colorScheme.surfaceContainerHighest, + foregroundColor: selectedCount > 0 + ? colorScheme.onError + : colorScheme.onSurfaceVariant, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), ), ), - ), + ], ); } @@ -731,10 +638,7 @@ class _LibraryTracksFolderScreenState else if (hasCoverUrl) _isCoverLocalPath(coverUrl) ? ImageFiltered( - imageFilter: ImageFilter.blur( - sigmaX: 32, - sigmaY: 32, - ), + imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32), child: Image.file( File(coverUrl), fit: BoxFit.cover, @@ -753,12 +657,9 @@ class _LibraryTracksFolderScreenState ), ) : ImageFiltered( - imageFilter: ImageFilter.blur( - sigmaX: 32, - sigmaY: 32, - ), + imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32), child: CachedNetworkImage( - imageUrl: _highResCoverUrl(coverUrl) ?? coverUrl, + imageUrl: highResCoverUrl(coverUrl) ?? coverUrl, fit: BoxFit.cover, memCacheWidth: cacheWidth, cacheManager: CoverCacheManager.instance, @@ -838,8 +739,7 @@ class _LibraryTracksFolderScreenState ); } else if (hasCoverUrl) { coverChild = CachedNetworkImage( - imageUrl: - _highResCoverUrl(coverUrl) ?? coverUrl, + imageUrl: highResCoverUrl(coverUrl) ?? coverUrl, fit: BoxFit.cover, width: coverSize, height: coverSize, @@ -1603,39 +1503,6 @@ class _CollectionTrackTile extends ConsumerWidget { } } -class _SelectionActionButton extends StatelessWidget { - final IconData icon; - final String label; - final VoidCallback? onPressed; - final ColorScheme colorScheme; - - const _SelectionActionButton({ - required this.icon, - required this.label, - required this.onPressed, - required this.colorScheme, - }); - - @override - Widget build(BuildContext context) { - return FilledButton.icon( - onPressed: onPressed, - icon: Icon(icon, size: 18), - label: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), - style: FilledButton.styleFrom( - backgroundColor: onPressed != null - ? colorScheme.primaryContainer - : colorScheme.surfaceContainerHighest, - foregroundColor: onPressed != null - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - ), - ); - } -} - class _EmptyFolderState extends StatelessWidget { final String title; final String subtitle; diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index c6fc9885..d70f6680 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -1,32 +1,33 @@ 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'; -import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; -import 'package:spotiflac_android/utils/audio_conversion_utils.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/image_cache_utils.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/services/batch_track_actions.dart'; +import 'package:spotiflac_android/models/unified_library_item.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; -import 'package:spotiflac_android/services/replaygain_service.dart'; import 'package:spotiflac_android/services/local_track_redownload_service.dart'; import 'package:spotiflac_android/widgets/batch_progress_dialog.dart'; -import 'package:spotiflac_android/widgets/batch_convert_sheet.dart'; 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'; +import 'package:spotiflac_android/widgets/selection_action_button.dart'; +import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; +import 'package:spotiflac_android/widgets/disc_separator_chip.dart'; +import 'package:spotiflac_android/widgets/album_detail_header.dart'; class LocalAlbumScreen extends ConsumerStatefulWidget { final String albumName; @@ -313,231 +314,73 @@ class _LocalAlbumScreenState extends ConsumerState { Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { final expandedHeight = _calculateExpandedHeight(context); - return SliverAppBar( - expandedHeight: expandedHeight, - pinned: true, - stretch: true, - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - title: AnimatedOpacity( - duration: const Duration(milliseconds: 200), - opacity: _showTitleInAppBar ? 1.0 : 0.0, - child: Text( - widget.albumName, - style: TextStyle( - color: colorScheme.onSurface, - fontWeight: FontWeight.w600, - fontSize: 16, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - flexibleSpace: LayoutBuilder( - builder: (context, constraints) { - final collapseRatio = - (constraints.maxHeight - kToolbarHeight) / - (expandedHeight - kToolbarHeight); - final showContent = collapseRatio > 0.3; - final cacheWidth = coverCacheWidthForViewport(context); - - return FlexibleSpaceBar( - collapseMode: CollapseMode.pin, - background: Stack( - fit: StackFit.expand, - children: [ - if (widget.coverPath != null) - 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( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.album, - size: 80, - color: colorScheme.onSurfaceVariant, - ), - ), - if (widget.coverPath != null) - Container(color: Colors.black.withValues(alpha: 0.35)), - Positioned( - left: 0, - right: 0, - bottom: 0, - height: expandedHeight * 0.65, - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withValues(alpha: 0.85), - ], - ), - ), - ), - ), - Positioned( - left: 20, - right: 20, - bottom: 40, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 150), - opacity: showContent ? 1.0 : 0.0, - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Builder( - builder: (context) { - final coverSize = (constraints.maxWidth * 0.5) - .clamp(150.0, 210.0) - .toDouble(); - 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: TextStyle( - color: Colors.white, - fontSize: _albumTitleFontSize(), - fontWeight: FontWeight.bold, - height: 1.2, - ), - textAlign: TextAlign.center, - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - 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), - _buildLocalHeaderMeta(context), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - 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: 12), - Container( - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - shape: BoxShape.circle, - ), - child: IconButton( - tooltip: context.l10n.actionShuffle, - onPressed: _shuffleAll, - icon: const Icon( - Icons.shuffle, - color: Colors.white, - ), - ), - ), - ], - ), - ], - ), - ), - ), - ], + final cacheWidth = coverCacheWidthForViewport(context); + final Widget background = widget.coverPath != null + ? Image.file( + File(widget.coverPath!), + fit: BoxFit.cover, + cacheWidth: cacheWidth, + gaplessPlayback: true, + filterQuality: FilterQuality.low, + errorBuilder: (_, _, _) => Container(color: colorScheme.surface), + ) + : Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.album, + size: 80, + color: colorScheme.onSurfaceVariant, ), - stretchModes: const [StretchMode.zoomBackground], ); - }, - ), - leading: IconButton( - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - icon: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.4), - shape: BoxShape.circle, - ), - child: const Icon(Icons.arrow_back, color: Colors.white), + + return AlbumDetailHeader( + title: widget.albumName, + expandedHeight: expandedHeight, + showTitleInAppBar: _showTitleInAppBar, + background: background, + blurAndScrimBackground: widget.coverPath != null, + coverBuilder: (context, coverSize) => 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, + ), + ), + subtitle: Text( + widget.artistName, + style: const TextStyle( + color: Colors.white70, + fontSize: 16, + fontWeight: FontWeight.w600, ), - onPressed: () => Navigator.pop(context), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + meta: _buildLocalHeaderMeta(context), + actions: AlbumPlayActions( + playLabel: context.l10n.tooltipPlay, + shuffleTooltip: context.l10n.actionShuffle, + onPlay: _playAll, + onShuffle: _shuffleAll, ), ); } @@ -550,13 +393,6 @@ 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, @@ -676,51 +512,7 @@ class _LocalAlbumScreenState extends ConsumerState { if (hasMultipleDiscs) { slivers.add( - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), - child: Row( - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(16), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.album, - size: 16, - color: colorScheme.onSecondaryContainer, - ), - const SizedBox(width: 6), - Text( - context.l10n.downloadedAlbumDiscHeader(discNumber), - style: Theme.of(context).textTheme.labelLarge - ?.copyWith( - color: colorScheme.onSecondaryContainer, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - const SizedBox(width: 12), - Expanded( - child: Container( - height: 1, - color: colorScheme.outlineVariant.withValues(alpha: 0.5), - ), - ), - ], - ), - ), - ), + SliverToBoxAdapter(child: DiscSeparatorChip(discNumber: discNumber)), ); } @@ -1278,475 +1070,14 @@ class _LocalAlbumScreenState extends ConsumerState { ).showSnackBar(SnackBar(content: Text(summary))); } - void _showBatchConvertSheet( - BuildContext context, - List allTracks, + List _selectedUnifiedItems( + List tracks, ) { - final tracksById = {for (final t in allTracks) t.id: t}; - final sourceFormats = {}; - final sourceBitDepths = []; - final sourceSampleRates = []; - for (final id in _selectedIds) { - final item = tracksById[id]; - if (item == null) continue; - final sourceFormat = convertibleAudioSourceFormat( - storedFormat: item.format, - filePath: item.filePath, - ); - if (sourceFormat != null) sourceFormats.add(sourceFormat); - sourceBitDepths.add(item.bitDepth); - sourceSampleRates.add(item.sampleRate); - } - - final formats = audioConversionTargetFormats - .where( - (target) => sourceFormats.any( - (source) => canConvertAudioFormat( - sourceFormat: source, - targetFormat: target, - ), - ), - ) - .toList(); - - if (formats.isEmpty) return; - - final sheetTitle = context.l10n.selectionBatchConvertConfirmTitle; - final sheetConfirmLabel = context.l10n.selectionConvertCount( - _selectedIds.length, - ); - - showModalBottomSheet( - context: context, - useRootNavigator: true, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (sheetContext) => BatchConvertSheet( - formats: formats, - title: sheetTitle, - confirmLabel: sheetConfirmLabel, - sourceBitDepth: lowestKnownPositiveInt(sourceBitDepths), - sourceSampleRate: lowestKnownPositiveInt(sourceSampleRates), - onConvert: (format, bitrate, losslessQuality, losslessProcessing) { - Navigator.pop(sheetContext); - _performBatchConversion( - allTracks: allTracks, - targetFormat: format, - bitrate: bitrate, - losslessQuality: losslessQuality, - losslessProcessing: losslessProcessing, - ); - }, - ), - ); - } - - Future _performBatchConversion({ - required List allTracks, - required String targetFormat, - required String bitrate, - LosslessConversionQuality losslessQuality = - const LosslessConversionQuality(), - LosslessConversionProcessing losslessProcessing = - const LosslessConversionProcessing(), - }) async { - final tracksById = {for (final t in allTracks) t.id: t}; - final selected = []; - for (final id in _selectedIds) { - final item = tracksById[id]; - if (item == null) continue; - final currentFormat = convertibleAudioSourceFormat( - storedFormat: item.format, - filePath: item.filePath, - ); - if (currentFormat == null || - !canConvertAudioFormat( - sourceFormat: currentFormat, - targetFormat: targetFormat, - )) { - continue; - } - selected.add(item); - } - - if (selected.isEmpty) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.selectionConvertNoConvertible)), - ); - } - return; - } - - final isLossless = isLosslessConversionTarget(targetFormat); - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(context.l10n.selectionBatchConvertConfirmTitle), - content: Text( - isLossless && losslessQuality.hasCaps - ? context.l10n.selectionBatchConvertConfirmMessageLosslessCapped( - selected.length, - targetFormat, - losslessQualityLabel( - losslessQuality, - originalLabel: - context.l10n.losslessConversionLabels.original, - originalQualityLabel: - context.l10n.losslessConversionLabels.originalQuality, - ), - ) - : isLossless - ? context.l10n.selectionBatchConvertConfirmMessageLossless( - selected.length, - targetFormat, - ) - : context.l10n.selectionBatchConvertConfirmMessage( - selected.length, - targetFormat, - bitrate, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: Text(context.l10n.dialogCancel), - ), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - child: Text(context.l10n.trackConvertFormat), - ), - ], - ), - ); - - if (confirmed != true || !mounted) return; - - int successCount = 0; - final total = selected.length; - final localDb = LibraryDatabase.instance; - final settings = ref.read(settingsProvider); - final shouldEmbedLyrics = - settings.embedLyrics && settings.lyricsMode != 'external'; - - var cancelled = false; - BatchProgressDialog.show( - context: context, - title: context.l10n.trackConvertConverting, - total: total, - icon: Icons.transform, - onCancel: () { - cancelled = true; - BatchProgressDialog.dismiss(context); - }, - ); - - for (int i = 0; i < total; i++) { - if (!mounted || cancelled) break; - final item = selected[i]; - - BatchProgressDialog.update(current: i + 1, detail: item.trackName); - - try { - final metadata = { - 'TITLE': item.trackName, - 'ARTIST': item.artistName, - 'ALBUM': item.albumName, - }; - try { - final result = await PlatformBridge.readFileMetadata(item.filePath); - if (result['error'] == null) { - mergePlatformMetadataForTagEmbed(target: metadata, source: result); - } - } catch (_) {} - await ensureLyricsMetadataForConversion( - metadata: metadata, - sourcePath: item.filePath, - shouldEmbedLyrics: shouldEmbedLyrics, - trackName: item.trackName, - artistName: item.artistName, - durationMs: (item.duration ?? 0) * 1000, - ); - - String? coverPath; - try { - final tempDir = await getTemporaryDirectory(); - final coverOutput = - '${tempDir.path}${Platform.pathSeparator}batch_cover_${DateTime.now().millisecondsSinceEpoch}.jpg'; - final coverResult = await PlatformBridge.extractCoverToFile( - item.filePath, - coverOutput, - ); - if (coverResult['error'] == null) coverPath = coverOutput; - } catch (_) {} - - final isSaf = isContentUri(item.filePath); - String workingPath = item.filePath; - String? safTempPath; - - if (isSaf) { - safTempPath = await PlatformBridge.copyContentUriToTemp( - item.filePath, - ); - if (safTempPath == null) continue; - workingPath = safTempPath; - } - - final newPath = await FFmpegService.convertAudioFormat( - inputPath: workingPath, - targetFormat: targetFormat.toLowerCase(), - bitrate: bitrate, - metadata: metadata, - coverPath: coverPath, - artistTagMode: settings.artistTagMode, - deleteOriginal: !isSaf, - sourceBitDepth: item.bitDepth, - losslessQuality: losslessQuality, - losslessProcessing: losslessProcessing, - ); - - if (coverPath != null) { - try { - await File(coverPath).delete(); - } catch (_) {} - } - - if (newPath == null) { - if (safTempPath != null) { - try { - await File(safTempPath).delete(); - } catch (_) {} - } - continue; - } - - final isLosslessOutput = isLosslessConversionTarget(targetFormat); - int? convertedBitDepth; - int? convertedSampleRate; - if (isLosslessOutput) { - try { - final convertedMetadata = await PlatformBridge.readFileMetadata( - newPath, - ); - if (convertedMetadata['error'] == null) { - convertedBitDepth = readPositiveAudioInt( - convertedMetadata['bit_depth'], - ); - convertedSampleRate = readPositiveAudioInt( - convertedMetadata['sample_rate'], - ); - } - } catch (_) {} - convertedBitDepth ??= losslessQuality.effectiveBitDepth( - item.bitDepth, - ); - convertedSampleRate ??= losslessQuality.effectiveSampleRate( - item.sampleRate, - ); - } - - if (isSaf) { - final uri = Uri.parse(item.filePath); - final pathSegments = uri.pathSegments; - - String? treeUri; - String relativeDir = ''; - String oldFileName = ''; - - // Typical SAF document URI pattern: - // content://authority/tree//document/ - final treeIdx = pathSegments.indexOf('tree'); - final docIdx = pathSegments.indexOf('document'); - if (treeIdx >= 0 && treeIdx + 1 < pathSegments.length) { - final treeId = pathSegments[treeIdx + 1]; - treeUri = - 'content://${uri.authority}/tree/${Uri.encodeComponent(treeId)}'; - } - - if (docIdx >= 0 && docIdx + 1 < pathSegments.length) { - final docPath = Uri.decodeFull(pathSegments[docIdx + 1]); - final slashIdx = docPath.lastIndexOf('/'); - if (slashIdx >= 0) { - oldFileName = docPath.substring(slashIdx + 1); - // Relative dir is everything after the tree id's directory base - final treeId = treeIdx >= 0 && treeIdx + 1 < pathSegments.length - ? Uri.decodeFull(pathSegments[treeIdx + 1]) - : ''; - if (treeId.isNotEmpty && docPath.startsWith(treeId)) { - final afterTree = docPath.substring(treeId.length); - final trimmed = afterTree.startsWith('/') - ? afterTree.substring(1) - : afterTree; - final lastSlash = trimmed.lastIndexOf('/'); - relativeDir = lastSlash >= 0 - ? trimmed.substring(0, lastSlash) - : ''; - } - } else { - oldFileName = docPath; - } - } - - if (treeUri != null && oldFileName.isNotEmpty) { - final dotIdx = oldFileName.lastIndexOf('.'); - final baseName = dotIdx > 0 - ? oldFileName.substring(0, dotIdx) - : oldFileName; - final convTarget = convertTargetExtAndMime(targetFormat); - final newExt = convTarget.ext; - final mimeType = convTarget.mime; - final newFileName = '$baseName$newExt'; - - final safUri = await PlatformBridge.createSafFileFromPath( - treeUri: treeUri, - relativeDir: relativeDir, - fileName: newFileName, - mimeType: mimeType, - srcPath: newPath, - ); - - if (safUri == null || safUri.isEmpty) { - try { - await File(newPath).delete(); - } catch (_) {} - if (safTempPath != null) { - try { - await File(safTempPath).delete(); - } catch (_) {} - } - continue; - } - - if (!isSameContentUri(item.filePath, safUri)) { - try { - await PlatformBridge.safDelete(item.filePath); - } catch (_) {} - } - await localDb.replaceWithConvertedItem( - item: item, - newFilePath: safUri, - targetFormat: targetFormat, - bitrate: bitrate, - bitDepth: convertedBitDepth, - sampleRate: convertedSampleRate, - ); - } - - try { - await File(newPath).delete(); - } catch (_) {} - if (safTempPath != null) { - try { - await File(safTempPath).delete(); - } catch (_) {} - } - } else { - await localDb.replaceWithConvertedItem( - item: item, - newFilePath: newPath, - targetFormat: targetFormat, - bitrate: bitrate, - bitDepth: convertedBitDepth, - sampleRate: convertedSampleRate, - ); - } - - successCount++; - } catch (_) {} - } - - ref.read(localLibraryProvider.notifier).reloadFromStorage(); - _exitSelectionMode(); - - if (mounted) { - if (!cancelled) { - BatchProgressDialog.dismiss(context); - } - ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.selectionBatchConvertSuccess( - successCount, - total, - targetFormat, - ), - ), - ), - ); - } - } - - Future _runBatchReplayGain(List tracks) async { final tracksById = {for (final t in tracks) t.id: t}; - final selected = []; - for (final id in _selectedIds) { - final item = tracksById[id]; - if (item == null) continue; - selected.add(item); - } - - if (selected.isEmpty) return; - - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(ctx.l10n.replayGainBatchConfirmTitle), - content: Text(ctx.l10n.replayGainBatchConfirmMessage(selected.length)), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: Text(ctx.l10n.dialogCancel), - ), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - child: Text(ctx.l10n.replayGainBatchConfirmTitle), - ), - ], - ), - ); - - if (confirmed != true || !mounted) return; - - var cancelled = false; - int successCount = 0; - final total = selected.length; - - BatchProgressDialog.show( - context: context, - title: context.l10n.replayGainBatchAnalyzing, - total: total, - icon: Icons.graphic_eq, - onCancel: () { - cancelled = true; - BatchProgressDialog.dismiss(context); - }, - ); - - for (int i = 0; i < total; i++) { - if (!mounted || cancelled) break; - final item = selected[i]; - BatchProgressDialog.update(current: i + 1, detail: item.trackName); - try { - final ok = await ReplayGainService.applyToFile(item.filePath); - if (ok) successCount++; - } catch (_) {} - } - - _exitSelectionMode(); - - if (!mounted) return; - if (!cancelled) { - BatchProgressDialog.dismiss(context); - } - ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.replayGainBatchSuccess(successCount, total)), - ), - ); + return [ + for (final t in _selectedIds.map((id) => tracksById[id])) + if (t != null) UnifiedLibraryItem.fromLocalLibrary(t), + ]; } Widget _buildSelectionBottomBar( @@ -1759,247 +1090,115 @@ class _LocalAlbumScreenState extends ConsumerState { final flacEligibleCount = _selectedFlacEligibleItems(tracks).length; final allSelected = selectedCount == tracks.length && tracks.isNotEmpty; - return Container( - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHigh, - borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.15), - blurRadius: 12, - offset: const Offset(0, -4), - ), - ], - ), - child: SafeArea( - top: false, - child: Padding( - padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 32, - height: 4, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: colorScheme.outlineVariant, - borderRadius: BorderRadius.circular(2), + return SelectionBottomBar( + selectedCount: selectedCount, + allSelected: allSelected, + onClose: _exitSelectionMode, + onToggleSelectAll: () { + if (allSelected) { + _exitSelectionMode(); + } else { + _selectAll(tracks); + } + }, + bottomPadding: bottomPadding, + children: [ + LayoutBuilder( + builder: (context, constraints) { + const spacing = 8.0; + final itemWidth = (constraints.maxWidth - spacing) / 2; + final actions = []; + + if (flacEligibleCount > 0) { + actions.add( + SelectionActionButton( + icon: Icons.download_for_offline_outlined, + label: '${context.l10n.queueFlacAction} ($flacEligibleCount)', + onPressed: () => _queueSelectedAsFlac(tracks), + colorScheme: colorScheme, ), + ); + } + + actions.add( + SelectionActionButton( + icon: Icons.auto_fix_high_outlined, + label: '${context.l10n.trackReEnrich} ($selectedCount)', + onPressed: selectedCount > 0 + ? () => _reEnrichSelected(tracks) + : null, + colorScheme: colorScheme, ), - Row( - children: [ - IconButton.filledTonal( - onPressed: _exitSelectionMode, - tooltip: MaterialLocalizations.of( - context, - ).closeButtonTooltip, - icon: const Icon(Icons.close), - style: IconButton.styleFrom( - backgroundColor: colorScheme.surfaceContainerHighest, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.downloadedAlbumSelectedCount( - selectedCount, - ), - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.bold), - ), - Text( - allSelected - ? context.l10n.downloadedAlbumAllSelected - : context.l10n.downloadedAlbumTapToSelect, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: colorScheme.onSurfaceVariant), - ), - ], - ), - ), - TextButton.icon( - onPressed: () { - if (allSelected) { - _exitSelectionMode(); - } else { - _selectAll(tracks); - } - }, - icon: Icon( - allSelected ? Icons.deselect : Icons.select_all, - size: 20, - ), - label: Text( - allSelected - ? context.l10n.actionDeselect - : context.l10n.actionSelectAll, - ), - style: TextButton.styleFrom( - foregroundColor: colorScheme.primary, - ), - ), - ], + ); + + actions.add( + SelectionActionButton( + icon: Icons.swap_horiz, + label: context.l10n.selectionConvertCount(selectedCount), + onPressed: selectedCount > 0 + ? () => showBatchConvertSheet( + context, + ref, + _selectedUnifiedItems(tracks), + onExitSelectionMode: _exitSelectionMode, + ) + : null, + colorScheme: colorScheme, ), - const SizedBox(height: 12), + ); - LayoutBuilder( - builder: (context, constraints) { - const spacing = 8.0; - final itemWidth = (constraints.maxWidth - spacing) / 2; - final actions = []; - - if (flacEligibleCount > 0) { - actions.add( - _LocalAlbumSelectionActionButton( - icon: Icons.download_for_offline_outlined, - label: - '${context.l10n.queueFlacAction} ($flacEligibleCount)', - onPressed: () => _queueSelectedAsFlac(tracks), - colorScheme: colorScheme, - ), - ); - } - - actions.add( - _LocalAlbumSelectionActionButton( - icon: Icons.auto_fix_high_outlined, - label: '${context.l10n.trackReEnrich} ($selectedCount)', - onPressed: selectedCount > 0 - ? () => _reEnrichSelected(tracks) - : null, - colorScheme: colorScheme, - ), - ); - - actions.add( - _LocalAlbumSelectionActionButton( - icon: Icons.swap_horiz, - label: context.l10n.selectionConvertCount(selectedCount), - onPressed: selectedCount > 0 - ? () => _showBatchConvertSheet(context, tracks) - : null, - colorScheme: colorScheme, - ), - ); - - actions.add( - _LocalAlbumSelectionActionButton( - icon: Icons.graphic_eq, - label: context.l10n.selectionReplayGainCount( - selectedCount, - ), - onPressed: selectedCount > 0 - ? () => _runBatchReplayGain(tracks) - : null, - colorScheme: colorScheme, - ), - ); - - return Wrap( - spacing: spacing, - runSpacing: spacing, - children: [ - for (final action in actions) - SizedBox(width: itemWidth, child: action), - ], - ); - }, + actions.add( + SelectionActionButton( + icon: Icons.graphic_eq, + label: context.l10n.selectionReplayGainCount(selectedCount), + onPressed: selectedCount > 0 + ? () => runBatchReplayGain( + context, + _selectedUnifiedItems(tracks), + onExitSelectionMode: _exitSelectionMode, + ) + : null, + colorScheme: colorScheme, ), + ); - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: selectedCount > 0 - ? () => _deleteSelected(tracks) - : null, - icon: const Icon(Icons.delete_outline), - label: Text( - selectedCount > 0 - ? context.l10n.downloadedAlbumDeleteCount(selectedCount) - : context.l10n.downloadedAlbumSelectToDelete, - ), - style: FilledButton.styleFrom( - backgroundColor: selectedCount > 0 - ? colorScheme.error - : colorScheme.surfaceContainerHighest, - foregroundColor: selectedCount > 0 - ? colorScheme.onError - : colorScheme.onSurfaceVariant, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), - ), + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: [ + for (final action in actions) + SizedBox(width: itemWidth, child: action), + ], + ); + }, + ), + + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null, + icon: const Icon(Icons.delete_outline), + label: Text( + selectedCount > 0 + ? context.l10n.downloadedAlbumDeleteCount(selectedCount) + : context.l10n.downloadedAlbumSelectToDelete, + ), + style: FilledButton.styleFrom( + backgroundColor: selectedCount > 0 + ? colorScheme.error + : colorScheme.surfaceContainerHighest, + foregroundColor: selectedCount > 0 + ? colorScheme.onError + : colorScheme.onSurfaceVariant, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), ), - ], + ), ), ), - ), - ); - } -} - -class _LocalAlbumSelectionActionButton extends StatelessWidget { - final IconData icon; - final String label; - final VoidCallback? onPressed; - final ColorScheme colorScheme; - - const _LocalAlbumSelectionActionButton({ - required this.icon, - required this.label, - required this.onPressed, - required this.colorScheme, - }); - - @override - Widget build(BuildContext context) { - final isDisabled = onPressed == null; - return Material( - color: isDisabled - ? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5) - : colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(14), - child: InkWell( - onTap: onPressed, - borderRadius: BorderRadius.circular(14), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - icon, - size: 18, - color: isDisabled - ? colorScheme.onSurfaceVariant.withValues(alpha: 0.5) - : colorScheme.onSecondaryContainer, - ), - const SizedBox(width: 6), - Flexible( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: isDisabled - ? colorScheme.onSurfaceVariant.withValues(alpha: 0.5) - : colorScheme.onSecondaryContainer, - ), - ), - ), - ], - ), - ), - ), + ], ); } } diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 7d4366f2..760da535 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -14,6 +14,7 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/lyrics_parser.dart'; import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; final _log = AppLogger('NowPlaying'); @@ -98,12 +99,6 @@ class _NowPlayingScreenState extends ConsumerState { } } - String _fmt(Duration d) { - final m = d.inMinutes; - final s = d.inSeconds % 60; - return '$m:${s.toString().padLeft(2, '0')}'; - } - String? _qualityLabel() { final meta = _metadata; if (meta == null) return null; @@ -337,7 +332,7 @@ class _NowPlayingScreenState extends ConsumerState { child: Row( children: [ Text( - _fmt(position), + formatClock(position.inSeconds), style: Theme.of(context).textTheme.bodySmall ?.copyWith( color: colorScheme.onSurfaceVariant, @@ -352,7 +347,7 @@ class _NowPlayingScreenState extends ConsumerState { ), ), Text( - _fmt(duration), + formatClock(duration.inSeconds), style: Theme.of(context).textTheme.bodySmall ?.copyWith( color: colorScheme.onSurfaceVariant, diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index d77532e5..7447e48e 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -9,6 +9,7 @@ import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/utils/image_cache_utils.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; +import 'package:spotiflac_android/utils/cover_art_utils.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/utils/provider_resource_ids.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; @@ -228,21 +229,6 @@ class _PlaylistScreenState extends ConsumerState { return (mediaSize.height * 0.6).clamp(400.0, 580.0); } - 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; - } - @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -322,7 +308,7 @@ class _PlaylistScreenState extends ConsumerState { videoUrl: motionUrl, fallback: _coverUrl != null ? CachedCoverImage( - imageUrl: _highResCoverUrl(_coverUrl) ?? _coverUrl!, + imageUrl: highResCoverUrl(_coverUrl) ?? _coverUrl!, fit: BoxFit.cover, memCacheWidth: cacheWidth, placeholder: (_, _) => @@ -336,7 +322,7 @@ class _PlaylistScreenState extends ConsumerState { ImageFiltered( imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32), child: CachedCoverImage( - imageUrl: _highResCoverUrl(_coverUrl) ?? _coverUrl!, + imageUrl: highResCoverUrl(_coverUrl) ?? _coverUrl!, fit: BoxFit.cover, memCacheWidth: cacheWidth, placeholder: (_, _) => @@ -406,7 +392,7 @@ class _PlaylistScreenState extends ConsumerState { child: _coverUrl != null ? CachedCoverImage( imageUrl: - _highResCoverUrl(_coverUrl) ?? + highResCoverUrl(_coverUrl) ?? _coverUrl!, fit: BoxFit.cover, memCacheWidth: cacheWidth, diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index c21caa78..5b4e6dfb 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -5,19 +5,17 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:share_plus/share_plus.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; -import 'package:spotiflac_android/services/replaygain_service.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; -import 'package:spotiflac_android/utils/audio_conversion_utils.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/models/unified_library_item.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; @@ -28,14 +26,13 @@ import 'package:spotiflac_android/providers/music_player_provider.dart'; import 'package:spotiflac_android/services/music_player_service.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/local_track_redownload_service.dart'; -import 'package:spotiflac_android/services/history_database.dart'; +import 'package:spotiflac_android/services/batch_track_actions.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/favorite_artists_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart'; import 'package:spotiflac_android/widgets/batch_progress_dialog.dart'; -import 'package:spotiflac_android/widgets/batch_convert_sheet.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:cached_network_image/cached_network_image.dart'; @@ -47,6 +44,8 @@ import 'package:spotiflac_android/utils/path_match_keys.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; +import 'package:spotiflac_android/widgets/selection_action_button.dart'; +import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; part 'queue_tab_helpers.dart'; part 'queue_tab_widgets.dart'; @@ -2380,7 +2379,6 @@ class _QueueTabState extends ConsumerState { } return false; } - } class _AnimatedLibrarySliverGrid extends StatefulWidget { diff --git a/lib/screens/queue_tab_batch_actions.dart b/lib/screens/queue_tab_batch_actions.dart index a27ea35b..2118ca99 100644 --- a/lib/screens/queue_tab_batch_actions.dart +++ b/lib/screens/queue_tab_batch_actions.dart @@ -486,626 +486,72 @@ extension _QueueTabBatchActions on _QueueTabState { Future _showBatchConvertSheet( BuildContext context, List allItems, - ) async { - final itemsById = {for (final item in allItems) item.id: item}; - final sourceFormats = {}; - final sourceBitDepths = []; - final sourceSampleRates = []; - for (final id in _selectedIds) { - final item = itemsById[id]; - if (item == null) continue; - final sourceFormat = convertibleAudioSourceFormat( - storedFormat: item.localItem?.format ?? item.historyItem?.format, - filePath: item.filePath, - fileName: item.historyItem?.safFileName, - ); - if (sourceFormat != null) sourceFormats.add(sourceFormat); - sourceBitDepths.add( - item.historyItem?.bitDepth ?? item.localItem?.bitDepth, - ); - sourceSampleRates.add( - item.historyItem?.sampleRate ?? item.localItem?.sampleRate, - ); - } - - final formats = audioConversionTargetFormats - .where( - (target) => sourceFormats.any( - (source) => canConvertAudioFormat( - sourceFormat: source, - targetFormat: target, - ), - ), - ) - .toList(); - - if (formats.isEmpty) return; - - var didStartConversion = false; - - // Resolve localized strings up front; the builder must not look up - // Localizations via the (possibly deactivated) State context. - final sheetTitle = context.l10n.selectionBatchConvertConfirmTitle; - final sheetConfirmLabel = context.l10n.selectionConvertCount( - _selectedIds.length, - ); - - _suppressSelectionOverlay = true; - _hideSelectionOverlay(); - _hidePlaylistSelectionOverlay(); - - await showModalBottomSheet( - context: context, - useRootNavigator: true, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (sheetContext) => BatchConvertSheet( - formats: formats, - title: sheetTitle, - confirmLabel: sheetConfirmLabel, - sourceBitDepth: lowestKnownPositiveInt(sourceBitDepths), - sourceSampleRate: lowestKnownPositiveInt(sourceSampleRates), - onConvert: (format, bitrate, losslessQuality, losslessProcessing) { - didStartConversion = true; - Navigator.pop(sheetContext); - _performBatchConversion( - allItems: allItems, - targetFormat: format, - bitrate: bitrate, - losslessQuality: losslessQuality, - losslessProcessing: losslessProcessing, + ) { + return showBatchConvertSheet( + context, + ref, + _selectedItemsFromAll(allItems), + onExitSelectionMode: _exitSelectionMode, + onSheetOpen: () { + _suppressSelectionOverlay = true; + _hideSelectionOverlay(); + _hidePlaylistSelectionOverlay(); + }, + onSheetClosed: (didStartConversion) async { + // Wait out the sheet's exit animation before restoring the toolbar so + // it doesn't pop in front of the still-closing sheet. + await Future.delayed(const Duration(milliseconds: 260)); + if (!mounted) { + _suppressSelectionOverlay = false; + return; + } + _suppressSelectionOverlay = false; + if (didStartConversion) return; + if (_isSelectionMode) { + _syncSelectionOverlay( + items: allItems, + bottomPadding: MediaQuery.of(this.context).padding.bottom, ); - }, - ), - ); - - // Wait out the sheet's exit animation before restoring the toolbar so it - // doesn't pop in front of the still-closing sheet. - await Future.delayed(const Duration(milliseconds: 260)); - if (!mounted) { - _suppressSelectionOverlay = false; - return; - } - _suppressSelectionOverlay = false; - if (didStartConversion) return; - if (_isSelectionMode) { - _syncSelectionOverlay( - items: allItems, - bottomPadding: MediaQuery.of(this.context).padding.bottom, - ); - } else if (_isPlaylistSelectionMode) { - _syncPlaylistSelectionOverlay( - playlists: ref.read(libraryCollectionsProvider).playlists, - bottomPadding: MediaQuery.of(this.context).padding.bottom, - ); - } - } - - /// Perform batch conversion on selected tracks - Future _performBatchConversion({ - required List allItems, - required String targetFormat, - required String bitrate, - LosslessConversionQuality losslessQuality = - const LosslessConversionQuality(), - LosslessConversionProcessing losslessProcessing = - const LosslessConversionProcessing(), - }) async { - final itemsById = {for (final item in allItems) item.id: item}; - final selectedItems = []; - for (final id in _selectedIds) { - final item = itemsById[id]; - if (item == null) continue; - final sourceFormat = convertibleAudioSourceFormat( - storedFormat: item.localItem?.format ?? item.historyItem?.format, - filePath: item.filePath, - fileName: item.historyItem?.safFileName, - ); - if (sourceFormat == null || - !canConvertAudioFormat( - sourceFormat: sourceFormat, - targetFormat: targetFormat, - )) { - continue; - } - selectedItems.add(item); - } - - if (selectedItems.isEmpty) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.selectionConvertNoConvertible)), - ); - } - return; - } - - final isLossless = isLosslessConversionTarget(targetFormat); - final losslessLabels = context.l10n.losslessConversionLabels; - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(context.l10n.selectionBatchConvertConfirmTitle), - content: Text( - isLossless && losslessQuality.hasCaps - ? context.l10n.selectionBatchConvertConfirmMessageLosslessCapped( - selectedItems.length, - targetFormat, - losslessQualityLabel( - losslessQuality, - originalLabel: losslessLabels.original, - originalQualityLabel: losslessLabels.originalQuality, - ), - ) - : isLossless - ? context.l10n.selectionBatchConvertConfirmMessageLossless( - selectedItems.length, - targetFormat, - ) - : context.l10n.selectionBatchConvertConfirmMessage( - selectedItems.length, - targetFormat, - bitrate, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: Text(context.l10n.dialogCancel), - ), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - child: Text(context.l10n.trackConvertFormat), - ), - ], - ), - ); - - if (confirmed != true || !mounted) return; - - int successCount = 0; - final total = selectedItems.length; - final historyDb = HistoryDatabase.instance; - final settings = ref.read(settingsProvider); - final shouldEmbedLyrics = - settings.embedLyrics && settings.lyricsMode != 'external'; - - var cancelled = false; - BatchProgressDialog.show( - context: context, - title: context.l10n.trackConvertConverting, - total: total, - icon: Icons.transform, - onCancel: () { - cancelled = true; - BatchProgressDialog.dismiss(context); + } else if (_isPlaylistSelectionMode) { + _syncPlaylistSelectionOverlay( + playlists: ref.read(libraryCollectionsProvider).playlists, + bottomPadding: MediaQuery.of(this.context).padding.bottom, + ); + } }, ); - - for (int i = 0; i < total; i++) { - if (!mounted || cancelled) break; - final item = selectedItems[i]; - - BatchProgressDialog.update(current: i + 1, detail: item.trackName); - - try { - final metadata = { - 'TITLE': item.trackName, - 'ARTIST': item.artistName, - 'ALBUM': item.albumName, - }; - try { - final result = await PlatformBridge.readFileMetadata(item.filePath); - if (result['error'] == null) { - mergePlatformMetadataForTagEmbed(target: metadata, source: result); - } - } catch (_) {} - await ensureLyricsMetadataForConversion( - metadata: metadata, - sourcePath: item.filePath, - shouldEmbedLyrics: shouldEmbedLyrics, - trackName: item.trackName, - artistName: item.artistName, - spotifyId: item.historyItem?.spotifyId ?? '', - durationMs: - ((item.historyItem?.duration ?? item.localItem?.duration) ?? 0) * - 1000, - ); - - String? coverPath; - try { - final tempDir = await getTemporaryDirectory(); - final coverOutput = - '${tempDir.path}${Platform.pathSeparator}batch_cover_${DateTime.now().millisecondsSinceEpoch}.jpg'; - final coverResult = await PlatformBridge.extractCoverToFile( - item.filePath, - coverOutput, - ); - if (coverResult['error'] == null) { - coverPath = coverOutput; - } - } catch (_) {} - - String workingPath = item.filePath; - final isSaf = isContentUri(item.filePath); - String? safTempPath; - - if (isSaf) { - safTempPath = await PlatformBridge.copyContentUriToTemp( - item.filePath, - ); - if (safTempPath == null) continue; - workingPath = safTempPath; - } - - final newPath = await FFmpegService.convertAudioFormat( - inputPath: workingPath, - targetFormat: targetFormat.toLowerCase(), - bitrate: bitrate, - metadata: metadata, - coverPath: coverPath, - artistTagMode: settings.artistTagMode, - deleteOriginal: !isSaf, - sourceBitDepth: - item.historyItem?.bitDepth ?? item.localItem?.bitDepth, - losslessQuality: losslessQuality, - losslessProcessing: losslessProcessing, - ); - - if (coverPath != null) { - try { - await File(coverPath).delete(); - } catch (_) {} - } - - if (newPath == null) { - if (safTempPath != null) { - try { - await File(safTempPath).delete(); - } catch (_) {} - } - continue; - } - - final sourceBitDepth = - item.historyItem?.bitDepth ?? item.localItem?.bitDepth; - final sourceSampleRate = - item.historyItem?.sampleRate ?? item.localItem?.sampleRate; - final isLosslessOutput = isLosslessConversionTarget(targetFormat); - int? convertedBitDepth; - int? convertedSampleRate; - if (isLosslessOutput) { - try { - final convertedMetadata = await PlatformBridge.readFileMetadata( - newPath, - ); - if (convertedMetadata['error'] == null) { - convertedBitDepth = readPositiveAudioInt( - convertedMetadata['bit_depth'], - ); - convertedSampleRate = readPositiveAudioInt( - convertedMetadata['sample_rate'], - ); - } - } catch (_) {} - convertedBitDepth ??= losslessQuality.effectiveBitDepth( - sourceBitDepth, - ); - convertedSampleRate ??= losslessQuality.effectiveSampleRate( - sourceSampleRate, - ); - } - final newQuality = convertedAudioQualityLabel( - targetFormat: targetFormat, - bitrate: bitrate, - labels: losslessLabels, - losslessQuality: losslessQuality, - actualBitDepth: convertedBitDepth, - actualSampleRate: convertedSampleRate, - ); - - if (isSaf && item.historyItem != null) { - final hi = item.historyItem!; - final treeUri = hi.downloadTreeUri; - final relativeDir = hi.safRelativeDir ?? ''; - if (treeUri != null && treeUri.isNotEmpty) { - final oldFileName = hi.safFileName ?? ''; - final dotIdx = oldFileName.lastIndexOf('.'); - final baseName = dotIdx > 0 - ? oldFileName.substring(0, dotIdx) - : oldFileName; - final convTarget = convertTargetExtAndMime(targetFormat); - final newExt = convTarget.ext; - final mimeType = convTarget.mime; - final newFileName = '$baseName$newExt'; - - final safUri = await PlatformBridge.createSafFileFromPath( - treeUri: treeUri, - relativeDir: relativeDir, - fileName: newFileName, - mimeType: mimeType, - srcPath: newPath, - ); - - if (safUri == null || safUri.isEmpty) { - try { - await File(newPath).delete(); - } catch (_) {} - if (safTempPath != null) { - try { - await File(safTempPath).delete(); - } catch (_) {} - } - continue; - } - - if (!isSameContentUri(item.filePath, safUri)) { - try { - await PlatformBridge.safDelete(item.filePath); - } catch (_) {} - } - - await historyDb.updateFilePath( - hi.id, - safUri, - newSafFileName: newFileName, - newQuality: newQuality, - newFormat: normalizedConvertedAudioFormat(targetFormat), - newBitrate: convertedAudioBitrateKbps( - targetFormat: targetFormat, - bitrate: bitrate, - ), - newBitDepth: convertedBitDepth, - newSampleRate: convertedSampleRate, - clearAudioSpecs: !isLosslessOutput, - ); - } - try { - await File(newPath).delete(); - } catch (_) {} - if (safTempPath != null) { - try { - await File(safTempPath).delete(); - } catch (_) {} - } - } else if (isSaf && item.localItem != null) { - final uri = Uri.parse(item.filePath); - final pathSegments = uri.pathSegments; - - String? treeUri; - String relativeDir = ''; - String oldFileName = ''; - - final treeIdx = pathSegments.indexOf('tree'); - final docIdx = pathSegments.indexOf('document'); - if (treeIdx >= 0 && treeIdx + 1 < pathSegments.length) { - final treeId = pathSegments[treeIdx + 1]; - treeUri = - 'content://${uri.authority}/tree/${Uri.encodeComponent(treeId)}'; - } - if (docIdx >= 0 && docIdx + 1 < pathSegments.length) { - final docPath = Uri.decodeFull(pathSegments[docIdx + 1]); - final slashIdx = docPath.lastIndexOf('/'); - if (slashIdx >= 0) { - oldFileName = docPath.substring(slashIdx + 1); - final treeId = treeIdx >= 0 && treeIdx + 1 < pathSegments.length - ? Uri.decodeFull(pathSegments[treeIdx + 1]) - : ''; - if (treeId.isNotEmpty && docPath.startsWith(treeId)) { - final afterTree = docPath.substring(treeId.length); - final trimmed = afterTree.startsWith('/') - ? afterTree.substring(1) - : afterTree; - final lastSlash = trimmed.lastIndexOf('/'); - relativeDir = lastSlash >= 0 - ? trimmed.substring(0, lastSlash) - : ''; - } - } else { - oldFileName = docPath; - } - } - - if (treeUri != null && oldFileName.isNotEmpty) { - final dotIdx = oldFileName.lastIndexOf('.'); - final baseName = dotIdx > 0 - ? oldFileName.substring(0, dotIdx) - : oldFileName; - final convTarget = convertTargetExtAndMime(targetFormat); - final newExt = convTarget.ext; - final mimeType = convTarget.mime; - final newFileName = '$baseName$newExt'; - - final safUri = await PlatformBridge.createSafFileFromPath( - treeUri: treeUri, - relativeDir: relativeDir, - fileName: newFileName, - mimeType: mimeType, - srcPath: newPath, - ); - - if (safUri == null || safUri.isEmpty) { - try { - await File(newPath).delete(); - } catch (_) {} - if (safTempPath != null) { - try { - await File(safTempPath).delete(); - } catch (_) {} - } - continue; - } - - if (!isSameContentUri(item.filePath, safUri)) { - try { - await PlatformBridge.safDelete(item.filePath); - } catch (_) {} - } - await LibraryDatabase.instance.replaceWithConvertedItem( - item: item.localItem!, - newFilePath: safUri, - targetFormat: targetFormat, - bitrate: bitrate, - bitDepth: convertedBitDepth, - sampleRate: convertedSampleRate, - ); - } - - try { - await File(newPath).delete(); - } catch (_) {} - if (safTempPath != null) { - try { - await File(safTempPath).delete(); - } catch (_) {} - } - } else if (item.historyItem != null) { - await historyDb.updateFilePath( - item.historyItem!.id, - newPath, - newQuality: newQuality, - newFormat: normalizedConvertedAudioFormat(targetFormat), - newBitrate: convertedAudioBitrateKbps( - targetFormat: targetFormat, - bitrate: bitrate, - ), - newBitDepth: convertedBitDepth, - newSampleRate: convertedSampleRate, - clearAudioSpecs: !isLosslessOutput, - ); - } else if (item.localItem != null) { - await LibraryDatabase.instance.replaceWithConvertedItem( - item: item.localItem!, - newFilePath: newPath, - targetFormat: targetFormat, - bitrate: bitrate, - bitDepth: convertedBitDepth, - sampleRate: convertedSampleRate, - ); - } - - successCount++; - } catch (_) {} - } - - ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); - ref.read(localLibraryProvider.notifier).reloadFromStorage(); - - _exitSelectionMode(); - - if (mounted) { - if (!cancelled) { - BatchProgressDialog.dismiss(context); - } - ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.selectionBatchConvertSuccess( - successCount, - total, - targetFormat, - ), - ), - ), - ); - } } - /// Batch-scan loudness and write ReplayGain tags to the selected tracks. - Future _runBatchReplayGain(List allItems) async { - final itemsById = {for (final item in allItems) item.id: item}; - final selectedItems = []; - for (final id in _selectedIds) { - final item = itemsById[id]; - if (item == null) continue; - selectedItems.add(item); - } - - if (selectedItems.isEmpty) return; - - _suppressSelectionOverlay = true; - _hideSelectionOverlay(); - _hidePlaylistSelectionOverlay(); - - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(ctx.l10n.replayGainBatchConfirmTitle), - content: Text( - ctx.l10n.replayGainBatchConfirmMessage(selectedItems.length), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: Text(ctx.l10n.dialogCancel), - ), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - child: Text(ctx.l10n.replayGainBatchConfirmTitle), - ), - ], - ), - ); - - if (!mounted) { - _suppressSelectionOverlay = false; - return; - } - if (confirmed != true) { - // Restore after the dialog's exit animation. - await Future.delayed(const Duration(milliseconds: 220)); - _suppressSelectionOverlay = false; - if (!mounted) return; - if (_isSelectionMode) { - _syncSelectionOverlay( - items: allItems, - bottomPadding: MediaQuery.paddingOf(context).bottom, - ); - } - return; - } - _suppressSelectionOverlay = false; - - var cancelled = false; - int successCount = 0; - final total = selectedItems.length; - - BatchProgressDialog.show( - context: context, - title: context.l10n.replayGainBatchAnalyzing, - total: total, - icon: Icons.graphic_eq, - onCancel: () { - cancelled = true; - BatchProgressDialog.dismiss(context); + Future _runBatchReplayGain(List allItems) { + return runBatchReplayGain( + context, + _selectedItemsFromAll(allItems), + onExitSelectionMode: _exitSelectionMode, + onConfirmOpen: () { + _suppressSelectionOverlay = true; + _hideSelectionOverlay(); + _hidePlaylistSelectionOverlay(); + }, + onConfirmClosed: (confirmed) async { + if (!mounted) { + _suppressSelectionOverlay = false; + return; + } + if (confirmed) { + _suppressSelectionOverlay = false; + return; + } + // Restore after the dialog's exit animation. + await Future.delayed(const Duration(milliseconds: 220)); + _suppressSelectionOverlay = false; + if (!mounted) return; + if (_isSelectionMode) { + _syncSelectionOverlay( + items: allItems, + bottomPadding: MediaQuery.paddingOf(context).bottom, + ); + } }, ); - - for (int i = 0; i < total; i++) { - if (!mounted || cancelled) break; - final item = selectedItems[i]; - BatchProgressDialog.update(current: i + 1, detail: item.trackName); - try { - final ok = await ReplayGainService.applyToFile(item.filePath); - if (ok) successCount++; - } catch (_) {} - } - - _exitSelectionMode(); - - if (!mounted) return; - if (!cancelled) { - BatchProgressDialog.dismiss(context); - } - ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.replayGainBatchSuccess(successCount, total)), - ), - ); } - } diff --git a/lib/screens/queue_tab_helpers.dart b/lib/screens/queue_tab_helpers.dart index 23cfc54a..ab20e86f 100644 --- a/lib/screens/queue_tab_helpers.dart +++ b/lib/screens/queue_tab_helpers.dart @@ -1,188 +1,5 @@ part of 'queue_tab.dart'; -enum LibraryItemSource { downloaded, local } - -class UnifiedLibraryItem { - final String id; - final String trackName; - final String artistName; - final String albumName; - final String? coverUrl; - final String? localCoverPath; - final String filePath; - final String? quality; - final DateTime addedAt; - final LibraryItemSource source; - - final DownloadHistoryItem? historyItem; - final LocalLibraryItem? localItem; - - UnifiedLibraryItem({ - required this.id, - required this.trackName, - required this.artistName, - required this.albumName, - this.coverUrl, - this.localCoverPath, - required this.filePath, - this.quality, - required this.addedAt, - required this.source, - this.historyItem, - this.localItem, - }); - - factory UnifiedLibraryItem.fromDownloadHistory(DownloadHistoryItem item) { - String? quality; - if (item.bitrate != null && item.bitrate! > 0) { - quality = buildDisplayAudioQuality( - bitrateKbps: item.bitrate, - format: item.format, - ); - } else if (item.bitDepth != null && - item.bitDepth! > 0 && - item.sampleRate != null) { - quality = buildDisplayAudioQuality( - bitDepth: item.bitDepth, - sampleRate: item.sampleRate, - ); - } - quality ??= item.quality; - return UnifiedLibraryItem( - id: 'dl_${item.id}', - trackName: item.trackName, - artistName: item.artistName, - albumName: item.albumName, - coverUrl: item.coverUrl, - filePath: item.filePath, - quality: quality, - addedAt: item.downloadedAt, - source: LibraryItemSource.downloaded, - historyItem: item, - ); - } - - factory UnifiedLibraryItem.fromLocalLibrary(LocalLibraryItem item) { - String? quality; - if (item.bitrate != null && item.bitrate! > 0) { - quality = buildDisplayAudioQuality( - bitrateKbps: item.bitrate, - format: item.format, - ); - } else if (item.bitDepth != null && - item.bitDepth! > 0 && - item.sampleRate != null) { - quality = buildDisplayAudioQuality( - bitDepth: item.bitDepth, - sampleRate: item.sampleRate, - ); - } - return UnifiedLibraryItem( - id: 'local_${item.id}', - trackName: item.trackName, - artistName: item.artistName, - albumName: item.albumName, - coverUrl: null, - localCoverPath: item.coverPath, - filePath: item.filePath, - quality: quality, - addedAt: item.fileModTime != null - ? DateTime.fromMillisecondsSinceEpoch(item.fileModTime!) - : item.scannedAt, - source: LibraryItemSource.local, - localItem: item, - ); - } - - bool get hasCover => - coverUrl != null || - (localCoverPath != null && localCoverPath!.isNotEmpty); - - String? get albumArtist => historyItem?.albumArtist ?? localItem?.albumArtist; - - String? get releaseDate => historyItem?.releaseDate ?? localItem?.releaseDate; - - String? get genre => historyItem?.genre ?? localItem?.genre; - - int? get trackNumber => historyItem?.trackNumber ?? localItem?.trackNumber; - - int? get discNumber => historyItem?.discNumber ?? localItem?.discNumber; - - String? get isrc => historyItem?.isrc ?? localItem?.isrc; - - String? get label => historyItem?.label ?? localItem?.label; - - String get searchKey => - '${trackName.toLowerCase()}|${artistName.toLowerCase()}|${albumName.toLowerCase()}'; - String get albumKey => - '${albumName.toLowerCase()}|${artistName.toLowerCase()}'; - - /// Returns the collection key used to match this item against playlist - /// entries. Uses the same logic as [trackCollectionKey] from the collections - /// provider: prefer ISRC, fall back to source:id. - String get collectionKey { - if (historyItem != null) { - final isrc = historyItem!.isrc?.trim(); - if (isrc != null && isrc.isNotEmpty) return 'isrc:${isrc.toUpperCase()}'; - final source = historyItem!.service.trim().isNotEmpty - ? historyItem!.service.trim() - : 'builtin'; - return '$source:${historyItem!.id}'; - } - if (localItem != null) { - final isrc = localItem!.isrc?.trim(); - if (isrc != null && isrc.isNotEmpty) return 'isrc:${isrc.toUpperCase()}'; - return 'local:${localItem!.id}'; - } - return 'builtin:$id'; - } - - Track toTrack() { - if (historyItem != null) { - final h = historyItem!; - return Track( - id: h.id, - name: h.trackName, - artistName: h.artistName, - albumName: h.albumName, - albumArtist: h.albumArtist, - coverUrl: h.coverUrl, - isrc: h.isrc, - duration: h.duration ?? 0, - trackNumber: h.trackNumber, - discNumber: h.discNumber, - releaseDate: h.releaseDate, - source: h.service, - ); - } - if (localItem != null) { - final l = localItem!; - return Track( - id: l.id, - name: l.trackName, - artistName: l.artistName, - albumName: l.albumName, - albumArtist: l.albumArtist, - coverUrl: l.coverPath, - isrc: l.isrc, - duration: l.duration ?? 0, - trackNumber: l.trackNumber, - discNumber: l.discNumber, - releaseDate: l.releaseDate, - source: 'local', - ); - } - return Track( - id: id, - name: trackName, - artistName: artistName, - albumName: albumName, - coverUrl: coverUrl, - duration: 0, - ); - } -} - class _GroupedAlbum { final String albumName; final String artistName; diff --git a/lib/screens/queue_tab_item_widgets.dart b/lib/screens/queue_tab_item_widgets.dart index 0870074a..2aaf7481 100644 --- a/lib/screens/queue_tab_item_widgets.dart +++ b/lib/screens/queue_tab_item_widgets.dart @@ -15,198 +15,115 @@ extension _QueueTabItemWidgets on _QueueTabState { unifiedItems, ).length; - return Container( - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHigh, - borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.15), - blurRadius: 12, - offset: const Offset(0, -4), - ), - ], - ), - child: SafeArea( - top: false, - child: Padding( - padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 32, - height: 4, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: colorScheme.outlineVariant, - borderRadius: BorderRadius.circular(2), + return SelectionBottomBar( + selectedCount: selectedCount, + allSelected: allSelected, + onClose: _exitSelectionMode, + onToggleSelectAll: () { + if (allSelected) { + _exitSelectionMode(); + } else { + _selectAll(unifiedItems); + } + }, + bottomPadding: bottomPadding, + children: [ + LayoutBuilder( + builder: (context, constraints) { + const spacing = 8.0; + final itemWidth = (constraints.maxWidth - spacing) / 2; + final actions = []; + + if (localOnlySelection && flacEligibleCount > 0) { + actions.add( + SelectionActionButton( + icon: Icons.download_for_offline_outlined, + label: '${context.l10n.queueFlacAction} ($flacEligibleCount)', + onPressed: () => _queueSelectedLocalAsFlac(unifiedItems), + colorScheme: colorScheme, ), + ); + } + + actions.add( + SelectionActionButton( + icon: localOnlySelection + ? Icons.auto_fix_high_outlined + : Icons.share_outlined, + label: localOnlySelection + ? '${context.l10n.trackReEnrich} ($selectedCount)' + : context.l10n.selectionShareCount(selectedCount), + onPressed: selectedCount > 0 + ? () => localOnlySelection + ? _reEnrichSelectedLocalFromQueue(unifiedItems) + : _shareSelected(unifiedItems) + : null, + colorScheme: colorScheme, ), + ); - Row( - children: [ - IconButton.filledTonal( - onPressed: _exitSelectionMode, - tooltip: MaterialLocalizations.of( - context, - ).closeButtonTooltip, - icon: const Icon(Icons.close), - style: IconButton.styleFrom( - backgroundColor: colorScheme.surfaceContainerHighest, - ), - ), - const SizedBox(width: 12), - - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.selectionSelected(selectedCount), - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.bold), - ), - Text( - allSelected - ? context.l10n.selectionAllSelected - : context.l10n.downloadedAlbumTapToSelect, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: colorScheme.onSurfaceVariant), - ), - ], - ), - ), - - TextButton.icon( - onPressed: () { - if (allSelected) { - _exitSelectionMode(); - } else { - _selectAll(unifiedItems); - } - }, - icon: Icon( - allSelected ? Icons.deselect : Icons.select_all, - size: 20, - ), - label: Text( - allSelected - ? context.l10n.actionDeselect - : context.l10n.actionSelectAll, - ), - style: TextButton.styleFrom( - foregroundColor: colorScheme.primary, - ), - ), - ], + actions.add( + SelectionActionButton( + icon: Icons.swap_horiz, + label: context.l10n.selectionConvertCount(selectedCount), + onPressed: selectedCount > 0 + ? () => _showBatchConvertSheet(context, unifiedItems) + : null, + colorScheme: colorScheme, ), + ); - const SizedBox(height: 12), - - LayoutBuilder( - builder: (context, constraints) { - const spacing = 8.0; - final itemWidth = (constraints.maxWidth - spacing) / 2; - final actions = []; - - if (localOnlySelection && flacEligibleCount > 0) { - actions.add( - _SelectionActionButton( - icon: Icons.download_for_offline_outlined, - label: - '${context.l10n.queueFlacAction} ($flacEligibleCount)', - onPressed: () => - _queueSelectedLocalAsFlac(unifiedItems), - colorScheme: colorScheme, - ), - ); - } - - actions.add( - _SelectionActionButton( - icon: localOnlySelection - ? Icons.auto_fix_high_outlined - : Icons.share_outlined, - label: localOnlySelection - ? '${context.l10n.trackReEnrich} ($selectedCount)' - : context.l10n.selectionShareCount(selectedCount), - onPressed: selectedCount > 0 - ? () => localOnlySelection - ? _reEnrichSelectedLocalFromQueue(unifiedItems) - : _shareSelected(unifiedItems) - : null, - colorScheme: colorScheme, - ), - ); - - actions.add( - _SelectionActionButton( - icon: Icons.swap_horiz, - label: context.l10n.selectionConvertCount(selectedCount), - onPressed: selectedCount > 0 - ? () => _showBatchConvertSheet(context, unifiedItems) - : null, - colorScheme: colorScheme, - ), - ); - - actions.add( - _SelectionActionButton( - icon: Icons.graphic_eq, - label: context.l10n.selectionReplayGainCount( - selectedCount, - ), - onPressed: selectedCount > 0 - ? () => _runBatchReplayGain(unifiedItems) - : null, - colorScheme: colorScheme, - ), - ); - - return Wrap( - spacing: spacing, - runSpacing: spacing, - children: [ - for (final action in actions) - SizedBox(width: itemWidth, child: action), - ], - ); - }, + actions.add( + SelectionActionButton( + icon: Icons.graphic_eq, + label: context.l10n.selectionReplayGainCount(selectedCount), + onPressed: selectedCount > 0 + ? () => _runBatchReplayGain(unifiedItems) + : null, + colorScheme: colorScheme, ), + ); - const SizedBox(height: 8), + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: [ + for (final action in actions) + SizedBox(width: itemWidth, child: action), + ], + ); + }, + ), - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: selectedCount > 0 - ? () => _deleteSelected(unifiedItems) - : null, - icon: const Icon(Icons.delete_outline), - label: Text( - selectedCount > 0 - ? context.l10n.selectionDeleteTracksCount(selectedCount) - : context.l10n.selectionSelectToDelete, - ), - style: FilledButton.styleFrom( - backgroundColor: selectedCount > 0 - ? colorScheme.error - : colorScheme.surfaceContainerHighest, - foregroundColor: selectedCount > 0 - ? colorScheme.onError - : colorScheme.onSurfaceVariant, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), - ), + const SizedBox(height: 8), + + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: selectedCount > 0 + ? () => _deleteSelected(unifiedItems) + : null, + icon: const Icon(Icons.delete_outline), + label: Text( + selectedCount > 0 + ? context.l10n.selectionDeleteTracksCount(selectedCount) + : context.l10n.selectionSelectToDelete, + ), + style: FilledButton.styleFrom( + backgroundColor: selectedCount > 0 + ? colorScheme.error + : colorScheme.surfaceContainerHighest, + foregroundColor: selectedCount > 0 + ? colorScheme.onError + : colorScheme.onSurfaceVariant, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), ), - ], + ), ), ), - ), + ], ); } diff --git a/lib/screens/queue_tab_widgets.dart b/lib/screens/queue_tab_widgets.dart index d4034276..99749471 100644 --- a/lib/screens/queue_tab_widgets.dart +++ b/lib/screens/queue_tab_widgets.dart @@ -95,65 +95,6 @@ class _FilterChip extends StatelessWidget { } } -class _SelectionActionButton extends StatelessWidget { - final IconData icon; - final String label; - final VoidCallback? onPressed; - final ColorScheme colorScheme; - - const _SelectionActionButton({ - required this.icon, - required this.label, - required this.onPressed, - required this.colorScheme, - }); - - @override - Widget build(BuildContext context) { - final isDisabled = onPressed == null; - return Material( - color: isDisabled - ? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5) - : colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(14), - child: InkWell( - onTap: onPressed, - borderRadius: BorderRadius.circular(14), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - icon, - size: 18, - color: isDisabled - ? colorScheme.onSurfaceVariant.withValues(alpha: 0.5) - : colorScheme.onSecondaryContainer, - ), - const SizedBox(width: 6), - Flexible( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: isDisabled - ? colorScheme.onSurfaceVariant.withValues(alpha: 0.5) - : colorScheme.onSecondaryContainer, - ), - ), - ), - ], - ), - ), - ), - ); - } -} - class _AnimatedOverlayBottomBar extends StatefulWidget { final Widget child; diff --git a/lib/screens/track_metadata_actions.dart b/lib/screens/track_metadata_actions.dart index 71a9ac77..420401d1 100644 --- a/lib/screens/track_metadata_actions.dart +++ b/lib/screens/track_metadata_actions.dart @@ -261,15 +261,6 @@ extension _TrackMetadataFileActions on _TrackMetadataScreenState { '${date.minute.toString().padLeft(2, '0')}'; } - String _formatFileSize(int bytes) { - if (bytes < 1024) return '$bytes B'; - if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; - if (bytes < 1024 * 1024 * 1024) { - return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; - } - return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB'; - } - IconData _getServiceIcon(String service) { switch (service.toLowerCase()) { case MusicServices.tidal: diff --git a/lib/screens/track_metadata_cards.dart b/lib/screens/track_metadata_cards.dart index 11898e54..f58f60a8 100644 --- a/lib/screens/track_metadata_cards.dart +++ b/lib/screens/track_metadata_cards.dart @@ -222,7 +222,7 @@ extension _TrackMetadataCards on _TrackMetadataScreenState { borderRadius: BorderRadius.circular(20), ), child: Text( - _formatDuration(duration!), + formatClock(duration!), style: const TextStyle( color: Colors.white, fontWeight: FontWeight.w600, @@ -494,7 +494,7 @@ extension _TrackMetadataCards on _TrackMetadataScreenState { totalDiscs.toString(), ), if (duration != null) - _MetadataItem(context.l10n.trackDuration, _formatDuration(duration!)), + _MetadataItem(context.l10n.trackDuration, formatClock(duration!)), if (audioQualityStr != null) _MetadataItem(context.l10n.trackAudioQuality, audioQualityStr), if (releaseDate != null && releaseDate!.isNotEmpty) @@ -589,12 +589,6 @@ extension _TrackMetadataCards on _TrackMetadataScreenState { ); } - String _formatDuration(int seconds) { - final minutes = seconds ~/ 60; - final secs = seconds % 60; - return '$minutes:${secs.toString().padLeft(2, '0')}'; - } - String _formatLabelForRaw(String raw) { final normalized = raw.toLowerCase().replaceAll('-', '_'); return switch (normalized) { @@ -708,7 +702,7 @@ extension _TrackMetadataCards on _TrackMetadataScreenState { borderRadius: BorderRadius.circular(20), ), child: Text( - _formatFileSize(fileSize), + formatBytes(fileSize), style: TextStyle( color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, diff --git a/lib/screens/track_metadata_convert.dart b/lib/screens/track_metadata_convert.dart index 8dfbfdc5..505d7a29 100644 --- a/lib/screens/track_metadata_convert.dart +++ b/lib/screens/track_metadata_convert.dart @@ -1331,10 +1331,10 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { newPath, ); if (convertedMetadata['error'] == null) { - convertedBitDepth = readPositiveAudioInt( + convertedBitDepth = readPositiveInt( convertedMetadata['bit_depth'], ); - convertedSampleRate = readPositiveAudioInt( + convertedSampleRate = readPositiveInt( convertedMetadata['sample_rate'], ); } diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 185db9e1..83fb8c2b 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -20,6 +20,7 @@ import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/replaygain_service.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/audio_conversion_utils.dart'; +import 'package:spotiflac_android/utils/cover_art_utils.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/utils/mime_utils.dart'; @@ -768,22 +769,7 @@ class _TrackMetadataScreenState extends ConsumerState { (_isLocalItem ? 'cover_lib_$_itemId' : 'cover_$_itemId'); String? get _coverUrl => _isLocalItem ? null - : _highResCoverUrl(normalizeRemoteHttpUrl(_downloadItem!.coverUrl)); - - 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; - } + : highResCoverUrl(normalizeRemoteHttpUrl(_downloadItem!.coverUrl)); String? get _localCoverPath => _isLocalItem ? _localLibraryItem!.coverPath : null; diff --git a/lib/utils/audio_conversion_utils.dart b/lib/utils/audio_conversion_utils.dart index e352225c..170eacd0 100644 --- a/lib/utils/audio_conversion_utils.dart +++ b/lib/utils/audio_conversion_utils.dart @@ -301,15 +301,6 @@ String convertedAudioQualityLabel({ return '$upper ${bitrate.trim().toLowerCase()}'; } -int? readPositiveAudioInt(Object? value) { - if (value is num) { - final intValue = value.toInt(); - return intValue > 0 ? intValue : null; - } - final parsed = int.tryParse(value?.toString() ?? ''); - return parsed != null && parsed > 0 ? parsed : null; -} - String normalizedConvertedAudioFormat(String targetFormat) { return targetFormat.trim().toLowerCase(); } diff --git a/lib/widgets/audio_analysis_widget.dart b/lib/widgets/audio_analysis_widget.dart index ea62c13a..09bbffcc 100644 --- a/lib/widgets/audio_analysis_widget.dart +++ b/lib/widgets/audio_analysis_widget.dart @@ -14,6 +14,7 @@ import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; class AudioAnalysisData { static const cacheVersion = 4; @@ -1378,7 +1379,7 @@ class _AudioInfoCard extends StatelessWidget { _MetricChip( icon: Icons.timer_outlined, label: context.l10n.audioAnalysisDuration, - value: _formatDuration(data.duration), + value: formatClock(data.duration), cs: cs, ), _MetricChip( @@ -1391,7 +1392,7 @@ class _AudioInfoCard extends StatelessWidget { _MetricChip( icon: Icons.storage, label: context.l10n.audioAnalysisFileSize, - value: _formatFileSize(data.fileSize), + value: formatBytes(data.fileSize), cs: cs, ), ], @@ -1488,12 +1489,6 @@ class _AudioInfoCard extends StatelessWidget { ); } - String _formatDuration(double seconds) { - final mins = seconds ~/ 60; - final secs = (seconds % 60).floor(); - return '$mins:${secs.toString().padLeft(2, '0')}'; - } - String _formatChannels(BuildContext context, AudioAnalysisData data) { final layout = data.channelLayout.trim(); if (layout.isNotEmpty && layout != 'unknown') { @@ -1504,14 +1499,6 @@ class _AudioInfoCard extends StatelessWidget { return data.channels > 0 ? '${data.channels}' : 'N/A'; } - String _formatFileSize(int bytes) { - if (bytes == 0) return '0 B'; - const units = ['B', 'KB', 'MB', 'GB']; - final i = (math.log(bytes) / math.log(1024)).floor(); - final size = bytes / math.pow(1024, i); - return '${size.toStringAsFixed(1)} ${units[i]}'; - } - String _formatFrequency(double hz) { if (hz >= 1000) return '${(hz / 1000).toStringAsFixed(1)} kHz'; return '${hz.round()} Hz'; @@ -1766,7 +1753,7 @@ class _SpectrogramPainter extends CustomPainter { canvas.drawLine(Offset(x, plot.top), Offset(x, plot.bottom), gridPaint); _drawText( canvas, - _fmtTime(ts), + formatClock(ts), Offset(x, plot.bottom + 3), align: _TextAlignV.topCenter, ); @@ -1818,13 +1805,6 @@ class _SpectrogramPainter extends CustomPainter { return 1200.0; } - static String _fmtTime(double sec) { - final s = sec.round(); - final m = s ~/ 60; - final r = s % 60; - return '$m:${r.toString().padLeft(2, '0')}'; - } - @override bool shouldRepaint(covariant _SpectrogramPainter old) => old.image != image ||