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 ||