mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-28 23:08:59 +02:00
refactor(screens): unify album header, selection UI, and batch engine
The local/downloaded album screens had drifted copies of the album header, selection bottom bar, disc chip, batch convert/ReplayGain trio, and assorted small helpers. All now delegate to the shared widgets, with the online album screen's design as the reference, and both album screens run batch actions through the queue_tab engine via UnifiedLibraryItem (strict-superset implementation covering both DB writebacks and SAF paths). Also folds the remaining per-screen helper copies (cover URL, error card, byte/clock formatting, readPositiveInt) into their shared homes. Intentional deltas: selection-bar strings follow queue_tab's l10n keys, both providers reload after a conversion, and audio-analysis durations round instead of floor.
This commit is contained in:
+101
-326
@@ -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<AlbumScreen> {
|
||||
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<AlbumScreen> {
|
||||
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<AlbumScreen> {
|
||||
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<AlbumScreen> {
|
||||
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<AlbumScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ArtistScreen> {
|
||||
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<ArtistScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<HomeTab>
|
||||
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<HomeTab>
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+177
-978
File diff suppressed because it is too large
Load Diff
@@ -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<NowPlayingScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
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<NowPlayingScreen> {
|
||||
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<NowPlayingScreen> {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_fmt(duration),
|
||||
formatClock(duration.inSeconds),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
|
||||
@@ -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<PlaylistScreen> {
|
||||
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<PlaylistScreen> {
|
||||
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<PlaylistScreen> {
|
||||
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<PlaylistScreen> {
|
||||
child: _coverUrl != null
|
||||
? CachedCoverImage(
|
||||
imageUrl:
|
||||
_highResCoverUrl(_coverUrl) ??
|
||||
highResCoverUrl(_coverUrl) ??
|
||||
_coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheWidth,
|
||||
|
||||
@@ -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<QueueTab> {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class _AnimatedLibrarySliverGrid extends StatefulWidget {
|
||||
|
||||
@@ -486,626 +486,72 @@ extension _QueueTabBatchActions on _QueueTabState {
|
||||
Future<void> _showBatchConvertSheet(
|
||||
BuildContext context,
|
||||
List<UnifiedLibraryItem> allItems,
|
||||
) async {
|
||||
final itemsById = {for (final item in allItems) item.id: item};
|
||||
final sourceFormats = <String>{};
|
||||
final sourceBitDepths = <int?>[];
|
||||
final sourceSampleRates = <int?>[];
|
||||
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<void>(
|
||||
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<void>.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<void>.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<void> _performBatchConversion({
|
||||
required List<UnifiedLibraryItem> 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 = <UnifiedLibraryItem>[];
|
||||
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<bool>(
|
||||
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 = <String, String>{
|
||||
'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<void> _runBatchReplayGain(List<UnifiedLibraryItem> allItems) async {
|
||||
final itemsById = {for (final item in allItems) item.id: item};
|
||||
final selectedItems = <UnifiedLibraryItem>[];
|
||||
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<bool>(
|
||||
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<void>.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<void> _runBatchReplayGain(List<UnifiedLibraryItem> 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<void>.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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = <Widget>[];
|
||||
|
||||
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 = <Widget>[];
|
||||
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<TrackMetadataScreen> {
|
||||
(_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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
Reference in New Issue
Block a user