Files
SpotiFLAC-Mobile/lib/screens/local_album_screen.dart
T

1865 lines
60 KiB
Dart

import 'dart:io';
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/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/widgets/animation_utils.dart';
class LocalAlbumScreen extends ConsumerStatefulWidget {
final String albumName;
final String artistName;
final String? coverPath;
final List<LocalLibraryItem> tracks;
const LocalAlbumScreen({
super.key,
required this.albumName,
required this.artistName,
this.coverPath,
required this.tracks,
});
@override
ConsumerState<LocalAlbumScreen> createState() => _LocalAlbumScreenState();
}
class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
bool _isSelectionMode = false;
final Set<String> _selectedIds = {};
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
late List<LocalLibraryItem> _sortedTracksCache;
late Map<int, List<LocalLibraryItem>> _discGroupsCache;
void _showCueVirtualTrackSnackBar() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text(cueVirtualTrackRequiresSplitMessage)),
);
}
late List<int> _sortedDiscNumbersCache;
late bool _hasMultipleDiscsCache;
String? _commonQualityCache;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_rebuildTrackCaches();
}
@override
void didUpdateWidget(covariant LocalAlbumScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(oldWidget.tracks, widget.tracks) ||
oldWidget.tracks.length != widget.tracks.length) {
_rebuildTrackCaches();
}
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
final expandedHeight = _calculateExpandedHeight(context);
final shouldShow =
_scrollController.offset > (expandedHeight - kToolbarHeight - 20);
if (shouldShow != _showTitleInAppBar) {
setState(() => _showTitleInAppBar = shouldShow);
}
}
double _calculateExpandedHeight(BuildContext context) {
final mediaSize = MediaQuery.of(context).size;
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
}
List<LocalLibraryItem> _buildSortedTracks() {
final tracks = List<LocalLibraryItem>.from(widget.tracks);
tracks.sort((a, b) {
final aDisc = a.discNumber ?? 1;
final bDisc = b.discNumber ?? 1;
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
final aNum = a.trackNumber ?? 999;
final bNum = b.trackNumber ?? 999;
if (aNum != bNum) return aNum.compareTo(bNum);
return a.trackName.compareTo(b.trackName);
});
return tracks;
}
void _rebuildTrackCaches() {
_sortedTracksCache = _buildSortedTracks();
_discGroupsCache = _groupTracksByDisc(_sortedTracksCache);
_sortedDiscNumbersCache = _discGroupsCache.keys.toList()..sort();
_hasMultipleDiscsCache = _discGroupsCache.length > 1;
_commonQualityCache = _computeCommonQuality(_sortedTracksCache);
}
Map<int, List<LocalLibraryItem>> _groupTracksByDisc(
List<LocalLibraryItem> tracks,
) {
final discMap = <int, List<LocalLibraryItem>>{};
for (final track in tracks) {
final discNumber = track.discNumber ?? 1;
discMap.putIfAbsent(discNumber, () => []).add(track);
}
return discMap;
}
void _enterSelectionMode(String itemId) {
HapticFeedback.mediumImpact();
setState(() {
_isSelectionMode = true;
_selectedIds.add(itemId);
});
}
void _exitSelectionMode() {
setState(() {
_isSelectionMode = false;
_selectedIds.clear();
});
}
void _toggleSelection(String itemId) {
setState(() {
if (_selectedIds.contains(itemId)) {
_selectedIds.remove(itemId);
if (_selectedIds.isEmpty) {
_isSelectionMode = false;
}
} else {
_selectedIds.add(itemId);
}
});
}
void _selectAll(List<LocalLibraryItem> tracks) {
setState(() {
_selectedIds.addAll(tracks.map((e) => e.id));
});
}
Future<void> _deleteSelected(List<LocalLibraryItem> currentTracks) async {
final count = _selectedIds.length;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.downloadedAlbumDeleteSelected),
content: Text(context.l10n.downloadedAlbumDeleteMessage(count)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: Text(context.l10n.dialogDelete),
),
],
),
);
if (confirmed == true && mounted) {
final libraryNotifier = ref.read(localLibraryProvider.notifier);
final idsToDelete = _selectedIds.toList();
final tracksById = {for (final track in currentTracks) track.id: track};
int deletedCount = 0;
for (final id in idsToDelete) {
final item = tracksById[id];
if (item != null) {
if (!isCueVirtualPath(item.filePath)) {
try {
await deleteFile(item.filePath);
} catch (_) {}
}
await libraryNotifier.removeItem(id);
deletedCount++;
}
}
_exitSelectionMode();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarDeletedTracks(deletedCount)),
),
);
if (deletedCount == currentTracks.length) {
Navigator.pop(context);
}
}
}
}
Future<void> _openFile(LocalLibraryItem track) async {
if (isCueVirtualPath(track.filePath)) {
_showCueVirtualTrackSnackBar();
return;
}
try {
await ref
.read(playbackProvider.notifier)
.playLocalPath(
path: track.filePath,
title: track.trackName,
artist: track.artistName,
album: track.albumName,
coverUrl: track.coverPath ?? '',
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarCannotOpenFile(e.toString())),
),
);
}
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final bottomPadding = MediaQuery.of(context).padding.bottom;
final bottomInset = context.navBarBottomInset;
final tracks = _sortedTracksCache;
if (tracks.isEmpty) {
return Scaffold(
appBar: AppBar(title: Text(widget.albumName)),
body: Center(child: Text(context.l10n.noTracksFoundForAlbum)),
);
}
final validIds = tracks.map((t) => t.id).toSet();
_selectedIds.removeWhere((id) => !validIds.contains(id));
if (_selectedIds.isEmpty && _isSelectionMode) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _isSelectionMode = false);
});
}
return PopScope(
canPop: !_isSelectionMode,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _isSelectionMode) {
_exitSelectionMode();
}
},
child: Scaffold(
body: Stack(
children: [
CustomScrollView(
controller: _scrollController,
slivers: [
_buildAppBar(context, colorScheme),
_buildInfoCard(context, colorScheme, tracks),
_buildTrackList(context, colorScheme, tracks),
SliverToBoxAdapter(
child: SizedBox(height: _isSelectionMode ? 120 : 32),
),
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
],
),
AnimatedPositioned(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
left: 0,
right: 0,
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
child: _buildSelectionBottomBar(
context,
colorScheme,
tracks,
bottomPadding,
),
),
],
),
),
);
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final expandedHeight = _calculateExpandedHeight(context);
final commonQuality = _commonQualityCache;
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)
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,
),
),
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: [
Text(
widget.albumName,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
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),
Wrap(
alignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.folder,
size: 14,
color: Colors.white,
),
const SizedBox(width: 4),
Text(
context.l10n.librarySourceLocal,
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.music_note,
size: 14,
color: Colors.white,
),
const SizedBox(width: 4),
Text(
context.l10n.queueTrackCount(
_sortedTracksCache.length,
),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
if (commonQuality != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(20),
),
child: Text(
commonQuality,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
],
),
],
),
),
),
],
),
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),
),
);
}
Widget _buildInfoCard(
BuildContext context,
ColorScheme colorScheme,
List<LocalLibraryItem> tracks,
) {
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
String? _computeCommonQuality(List<LocalLibraryItem> tracks) {
if (tracks.isEmpty) return null;
final first = tracks.first;
if (first.bitrate != null && first.bitrate! > 0) {
final fmt = first.format?.toUpperCase() ?? '';
final firstBitrate = first.bitrate;
for (final track in tracks) {
if (track.bitrate != firstBitrate) {
return null;
}
}
return '$fmt ${firstBitrate}kbps'.trim();
}
if (first.bitDepth == null ||
first.bitDepth == 0 ||
first.sampleRate == null) {
return null;
}
final firstQuality =
'${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz';
for (final track in tracks) {
if (track.bitDepth != first.bitDepth ||
track.sampleRate != first.sampleRate) {
return null;
}
}
return firstQuality;
}
Widget _buildTrackList(
BuildContext context,
ColorScheme colorScheme,
List<LocalLibraryItem> tracks,
) {
final discGroups = _discGroupsCache;
final hasMultipleDiscs = _hasMultipleDiscsCache;
final slivers = <Widget>[];
for (final discNumber in _sortedDiscNumbersCache) {
final discTracks = discGroups[discNumber]!;
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),
),
),
],
),
),
),
);
}
slivers.add(
SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final track = discTracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: StaggeredListItem(
index: index,
child: _buildTrackItem(context, colorScheme, track),
),
);
}, childCount: discTracks.length),
),
);
}
return SliverMainAxisGroup(slivers: slivers);
}
Widget _buildTrackItem(
BuildContext context,
ColorScheme colorScheme,
LocalLibraryItem track,
) {
final isSelected = _selectedIds.contains(track.id);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Card(
elevation: 0,
color: isSelected
? colorScheme.primaryContainer.withValues(alpha: 0.3)
: Colors.transparent,
margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
onTap: _isSelectionMode
? () => _toggleSelection(track.id)
: () => _openFile(track),
onLongPress: _isSelectionMode
? null
: () => _enterSelectionMode(track.id),
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_isSelectionMode) ...[
AnimatedSelectionCheckbox(
visible: true,
selected: isSelected,
colorScheme: colorScheme,
size: 24,
),
const SizedBox(width: 12),
],
SizedBox(
width: 24,
child: Text(
track.trackNumber?.toString() ?? '-',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
),
],
),
title: Text(
track.trackName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
),
subtitle: Row(
children: [
Flexible(
child: Text(
track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
if (track.format != null) ...[
Text(
' • ',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
),
),
Text(
track.format!.toUpperCase(),
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
),
),
],
],
),
trailing: _isSelectionMode
? null
: IconButton(
tooltip: context.l10n.tooltipPlay,
onPressed: () => _openFile(track),
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer.withValues(
alpha: 0.3,
),
),
),
),
),
);
}
bool _hasValue(String? value) => value != null && value.trim().isNotEmpty;
Future<void> _safeDeleteFile(String path) async {
try {
final file = File(path);
if (await file.exists()) {
await file.delete();
}
} catch (_) {}
}
Future<void> _cleanupTempFileAndParent(String path) async {
await _safeDeleteFile(path);
try {
final parent = File(path).parent;
if (await parent.exists()) {
await parent.delete();
}
} catch (_) {}
}
Future<bool> _applyFfmpegReEnrichResult(
LocalLibraryItem item,
Map<String, dynamic> result,
) async {
final tempPath = result['temp_path'] as String?;
final safUri = result['saf_uri'] as String?;
final ffmpegTarget = _hasValue(tempPath) ? tempPath! : item.filePath;
final downloadedCoverPath = result['cover_path'] as String?;
String? effectiveCoverPath = downloadedCoverPath;
String? extractedCoverPath;
if (!_hasValue(effectiveCoverPath)) {
try {
final tempDir = await Directory.systemTemp.createTemp(
'reenrich_cover_',
);
final coverOutput = '${tempDir.path}${Platform.pathSeparator}cover.jpg';
final extracted = await PlatformBridge.extractCoverToFile(
ffmpegTarget,
coverOutput,
);
if (extracted['error'] == null) {
effectiveCoverPath = coverOutput;
extractedCoverPath = coverOutput;
} else {
try {
await tempDir.delete(recursive: true);
} catch (_) {}
}
} catch (_) {}
}
final metadata = (result['metadata'] as Map<String, dynamic>?)?.map(
(k, v) => MapEntry(k, v.toString()),
);
final format = item.format?.toLowerCase();
final lowerPath = item.filePath.toLowerCase();
final isMp3 = format == 'mp3' || lowerPath.endsWith('.mp3');
final isM4A =
format == 'm4a' ||
format == 'aac' ||
lowerPath.endsWith('.m4a') ||
lowerPath.endsWith('.aac');
final isOpus =
format == 'opus' ||
format == 'ogg' ||
lowerPath.endsWith('.opus') ||
lowerPath.endsWith('.ogg');
final artistTagMode = ref.read(settingsProvider).artistTagMode;
String? ffmpegResult;
if (isMp3) {
ffmpegResult = await FFmpegService.embedMetadataToMp3(
mp3Path: ffmpegTarget,
coverPath: effectiveCoverPath,
metadata: metadata,
preserveMetadata: true,
);
} else if (isM4A) {
ffmpegResult = await FFmpegService.embedMetadataToM4a(
m4aPath: ffmpegTarget,
coverPath: effectiveCoverPath,
metadata: metadata,
preserveMetadata: true,
);
} else if (isOpus) {
ffmpegResult = await FFmpegService.embedMetadataToOpus(
opusPath: ffmpegTarget,
coverPath: effectiveCoverPath,
metadata: metadata,
artistTagMode: artistTagMode,
preserveMetadata: true,
);
}
if (ffmpegResult != null && _hasValue(tempPath) && _hasValue(safUri)) {
final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri!);
if (!ok) {
if (_hasValue(downloadedCoverPath)) {
await _safeDeleteFile(downloadedCoverPath!);
}
if (_hasValue(extractedCoverPath)) {
await _cleanupTempFileAndParent(extractedCoverPath!);
}
await _safeDeleteFile(tempPath!);
return false;
}
await writeReEnrichSafSidecarLrc(safUri: safUri, reEnrichResult: result);
}
if (_hasValue(downloadedCoverPath)) {
await _safeDeleteFile(downloadedCoverPath!);
}
if (_hasValue(extractedCoverPath)) {
await _cleanupTempFileAndParent(extractedCoverPath!);
}
if (_hasValue(tempPath)) {
await _safeDeleteFile(tempPath!);
}
if (ffmpegResult != null) {
// Filesystem .lrc sidecar. SAF sidecar is written only after
// writeTempToSaf succeeds.
await writeReEnrichSidecarLrc(
audioFilePath: item.filePath,
reEnrichResult: result,
);
}
return ffmpegResult != null;
}
Future<bool> _reEnrichLocalTrack(
LocalLibraryItem item, {
List<String>? updateFields,
}) async {
final durationMs = (item.duration ?? 0) * 1000;
final settings = ref.read(settingsProvider);
final artistTagMode = settings.artistTagMode;
final request = <String, dynamic>{
'file_path': item.filePath,
'cover_url': '',
'max_quality': true,
'embed_lyrics': settings.embedLyrics,
'lyrics_mode': settings.lyricsMode,
'artist_tag_mode': artistTagMode,
'spotify_id': '',
'track_name': item.trackName,
'artist_name': item.artistName,
'album_name': item.albumName,
'album_artist': item.albumArtist ?? '',
'track_number': item.trackNumber ?? 0,
'disc_number': item.discNumber ?? 0,
'release_date': item.releaseDate ?? '',
'isrc': item.isrc ?? '',
'genre': item.genre ?? '',
'label': '',
'copyright': '',
'duration_ms': durationMs,
'search_online': true,
// ignore: use_null_aware_elements
if (updateFields != null) 'update_fields': updateFields,
};
final result = await PlatformBridge.reEnrichFile(request);
final method = result['method'] as String?;
if (method == 'native') {
// Filesystem .lrc sidecar (SAF sidecar handled natively in Kotlin).
await writeReEnrichSidecarLrc(
audioFilePath: item.filePath,
reEnrichResult: result,
);
return true;
}
if (method == 'ffmpeg') {
return _applyFfmpegReEnrichResult(item, result);
}
return false;
}
List<LocalLibraryItem> _selectedFlacEligibleItems(
List<LocalLibraryItem> allTracks,
) {
final tracksById = {for (final t in allTracks) t.id: t};
return _selectedIds
.map((id) => tracksById[id])
.whereType<LocalLibraryItem>()
.where(LocalTrackRedownloadService.isFlacUpgradeEligible)
.toList(growable: false);
}
Future<void> _queueSelectedAsFlac(List<LocalLibraryItem> allTracks) async {
final selected = _selectedFlacEligibleItems(allTracks);
if (selected.isEmpty) {
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.queueFlacAction),
content: Text(context.l10n.queueFlacConfirmMessage(selected.length)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(context.l10n.queueFlacAction),
),
],
),
);
if (confirmed != true || !mounted) {
return;
}
final settings = ref.read(settingsProvider);
final extensionState = ref.read(extensionProvider);
final includeExtensions =
settings.useExtensionProviders &&
extensionState.extensions.any(
(ext) => ext.enabled && ext.hasMetadataProvider,
);
final targetService = LocalTrackRedownloadService.preferredFlacService(
settings,
extensionState,
);
if (targetService.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)),
);
return;
}
final targetQuality =
LocalTrackRedownloadService.preferredFlacQualityForService(
targetService,
extensionState,
);
final matchedTracks = <Track>[];
var skippedCount = 0;
final total = selected.length;
var cancelled = false;
BatchProgressDialog.show(
context: context,
title: context.l10n.queueFlacAction,
total: total,
icon: Icons.queue_music,
onCancel: () {
cancelled = true;
BatchProgressDialog.dismiss(context);
},
);
for (var i = 0; i < total; i++) {
if (!mounted || cancelled) break;
BatchProgressDialog.update(current: i + 1, detail: selected[i].trackName);
try {
final resolution = await LocalTrackRedownloadService.resolveBestMatch(
selected[i],
includeExtensions: includeExtensions,
);
if (resolution.canQueue && resolution.match != null) {
matchedTracks.add(resolution.match!);
} else {
skippedCount++;
}
} catch (_) {
skippedCount++;
}
}
if (!mounted) {
return;
}
if (!cancelled) {
BatchProgressDialog.dismiss(context);
}
if (matchedTracks.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.queueFlacNoReliableMatches)),
);
return;
}
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(
matchedTracks,
targetService,
qualityOverride: targetQuality,
);
final summary = skippedCount == 0
? context.l10n.snackbarAddedTracksToQueue(matchedTracks.length)
: context.l10n.queueFlacQueuedWithSkipped(
matchedTracks.length,
skippedCount,
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(summary)));
setState(() {
_selectedIds.clear();
_isSelectionMode = false;
});
}
Future<void> _reEnrichSelected(List<LocalLibraryItem> allTracks) async {
final tracksById = {for (final t in allTracks) t.id: t};
final selected = <LocalLibraryItem>[];
for (final id in _selectedIds) {
final item = tracksById[id];
if (item != null) {
selected.add(item);
}
}
if (selected.isEmpty) {
return;
}
// The bar uses AnimatedPositioned (250ms), so wait for the slide-out.
setState(() => _isSelectionMode = false);
await Future<void>.delayed(const Duration(milliseconds: 300));
if (!mounted) return;
final selection = await showReEnrichFieldDialog(
context,
selectedCount: selected.length,
);
if (selection == null || !mounted) {
// Cancelled — restore selection mode (IDs are still intact).
if (mounted) setState(() => _isSelectionMode = true);
return;
}
final updateFields = selection.isAll ? null : selection.fields;
var successCount = 0;
final total = selected.length;
var cancelled = false;
BatchProgressDialog.show(
context: context,
title: context.l10n.trackReEnrichProgress,
total: total,
icon: Icons.auto_fix_high,
onCancel: () {
cancelled = true;
BatchProgressDialog.dismiss(context);
},
);
for (var i = 0; i < total; i++) {
if (!mounted || cancelled) break;
final item = selected[i];
BatchProgressDialog.update(
current: i + 1,
detail: '${item.trackName} - ${item.artistName}',
);
try {
final ok = await _reEnrichLocalTrack(item, updateFields: updateFields);
if (ok) {
successCount++;
}
} catch (_) {}
}
if (!mounted) {
return;
}
final settings = ref.read(settingsProvider);
final localLibraryPath = settings.localLibraryPath.trim();
final iosBookmark = settings.localLibraryBookmark;
try {
if (localLibraryPath.isNotEmpty &&
!ref.read(localLibraryProvider).isScanning) {
await ref
.read(localLibraryProvider.notifier)
.startScan(
localLibraryPath,
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null,
);
} else {
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
}
} catch (_) {
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
}
_exitSelectionMode();
if (!mounted) {
return;
}
if (!cancelled) {
BatchProgressDialog.dismiss(context);
}
ScaffoldMessenger.of(context).clearSnackBars();
final failedCount = total - successCount;
final summary = failedCount <= 0
? '${context.l10n.trackReEnrichSuccess} ($successCount/$total)'
: context.l10n.trackReEnrichSuccessWithFailures(
successCount,
total,
failedCount,
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(summary)));
}
void _showBatchConvertSheet(
BuildContext context,
List<LocalLibraryItem> allTracks,
) {
final tracksById = {for (final t in allTracks) t.id: t};
final sourceFormats = <String>{};
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);
}
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<void>(
context: context,
useRootNavigator: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (sheetContext) => BatchConvertSheet(
formats: formats,
title: sheetTitle,
confirmLabel: sheetConfirmLabel,
onConvert: (format, bitrate) {
Navigator.pop(sheetContext);
_performBatchConversion(
allTracks: allTracks,
targetFormat: format,
bitrate: bitrate,
);
},
),
);
}
Future<void> _performBatchConversion({
required List<LocalLibraryItem> allTracks,
required String targetFormat,
required String bitrate,
}) async {
final tracksById = {for (final t in allTracks) t.id: t};
final selected = <LocalLibraryItem>[];
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<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
content: Text(
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 = <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,
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,
);
if (coverPath != null) {
try {
await File(coverPath).delete();
} catch (_) {}
}
if (newPath == null) {
if (safTempPath != null) {
try {
await File(safTempPath).delete();
} catch (_) {}
}
continue;
}
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/<tree-id>/document/<doc-path>
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,
);
}
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,
);
}
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<void> _runBatchReplayGain(List<LocalLibraryItem> tracks) async {
final tracksById = {for (final t in tracks) t.id: t};
final selected = <LocalLibraryItem>[];
for (final id in _selectedIds) {
final item = tracksById[id];
if (item == null) continue;
selected.add(item);
}
if (selected.isEmpty) return;
final confirmed = await showDialog<bool>(
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),
),
),
);
}
Widget _buildSelectionBottomBar(
BuildContext context,
ColorScheme colorScheme,
List<LocalLibraryItem> tracks,
double bottomPadding,
) {
final selectedCount = _selectedIds.length;
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),
),
),
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,
),
),
],
),
const SizedBox(height: 12),
LayoutBuilder(
builder: (context, constraints) {
const spacing = 8.0;
final itemWidth = (constraints.maxWidth - spacing) / 2;
final actions = <Widget>[];
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),
],
);
},
),
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,
),
),
),
],
),
),
),
);
}
}