mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-29 23:38:50 +02:00
1147 lines
39 KiB
Dart
1147 lines
39 KiB
Dart
part of 'track_metadata_screen.dart';
|
|
|
|
extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState {
|
|
/// Whether the current file format supports conversion
|
|
bool get _isConvertibleFormat {
|
|
final lower = cleanFilePath.toLowerCase();
|
|
return lower.endsWith('.flac') ||
|
|
lower.endsWith('.m4a') ||
|
|
lower.endsWith('.aac') ||
|
|
lower.endsWith('.wav') ||
|
|
lower.endsWith('.aiff') ||
|
|
lower.endsWith('.aif') ||
|
|
lower.endsWith('.mp3') ||
|
|
lower.endsWith('.opus') ||
|
|
lower.endsWith('.ogg');
|
|
}
|
|
|
|
/// Whether the current file is a CUE sheet (or CUE-referenced)
|
|
bool get _isCueFile {
|
|
if (isCueVirtualPath(rawFilePath)) return true;
|
|
final lower = cleanFilePath.toLowerCase();
|
|
if (lower.endsWith('.cue')) return true;
|
|
if (_isLocalItem && _localLibraryItem != null) {
|
|
final format = _localLibraryItem!.format ?? '';
|
|
if (format.startsWith('cue+')) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
String get _currentFileFormat {
|
|
// For CUE tracks, use the format from the library item (e.g. "cue+flac")
|
|
if (_isCueFile && _isLocalItem && _localLibraryItem != null) {
|
|
final format = _localLibraryItem!.format ?? '';
|
|
if (format.startsWith('cue+')) {
|
|
final audioFmt = format.substring(4).toUpperCase();
|
|
return 'CUE+$audioFmt';
|
|
}
|
|
}
|
|
if (_isLocalItem && _localLibraryItem != null) {
|
|
final format = normalizeOptionalString(
|
|
_localLibraryItem!.format,
|
|
)?.toLowerCase().replaceAll('-', '_');
|
|
switch (format) {
|
|
case 'flac':
|
|
return 'FLAC';
|
|
case 'alac':
|
|
return 'ALAC';
|
|
case 'm4a':
|
|
return 'M4A';
|
|
case 'aac':
|
|
case 'mp4a':
|
|
return 'AAC';
|
|
case 'mp3':
|
|
return 'MP3';
|
|
case 'opus':
|
|
case 'ogg':
|
|
return 'Opus';
|
|
case 'wav':
|
|
case 'wave':
|
|
return 'WAV';
|
|
case 'aiff':
|
|
case 'aif':
|
|
case 'aifc':
|
|
return 'AIFF';
|
|
}
|
|
}
|
|
final lower = cleanFilePath.toLowerCase();
|
|
if (lower.endsWith('.flac')) return 'FLAC';
|
|
if (lower.endsWith('.m4a')) return 'M4A';
|
|
if (lower.endsWith('.aac')) return 'AAC';
|
|
if (lower.endsWith('.wav')) return 'WAV';
|
|
if (lower.endsWith('.aiff') || lower.endsWith('.aif')) return 'AIFF';
|
|
if (lower.endsWith('.mp3')) return 'MP3';
|
|
if (lower.endsWith('.opus') || lower.endsWith('.ogg')) return 'Opus';
|
|
if (lower.endsWith('.cue')) return 'CUE';
|
|
return 'Unknown';
|
|
}
|
|
|
|
Map<String, String> _buildFallbackMetadata() {
|
|
String formatIndexTag(int number, int? total) {
|
|
if (total != null && total > 0) {
|
|
return '$number/$total';
|
|
}
|
|
return number.toString();
|
|
}
|
|
|
|
return {
|
|
'TITLE': trackName,
|
|
'ARTIST': artistName,
|
|
'ALBUM': albumName,
|
|
if (albumArtist != null && albumArtist!.isNotEmpty)
|
|
'ALBUMARTIST': albumArtist!,
|
|
if (trackNumber != null)
|
|
'TRACKNUMBER': formatIndexTag(trackNumber!, totalTracks),
|
|
if (discNumber != null)
|
|
'DISCNUMBER': formatIndexTag(discNumber!, totalDiscs),
|
|
if (releaseDate != null && releaseDate!.isNotEmpty) 'DATE': releaseDate!,
|
|
if (isrc != null && isrc!.isNotEmpty) 'ISRC': isrc!,
|
|
if (genre != null && genre!.isNotEmpty) 'GENRE': genre!,
|
|
if (label != null && label!.isNotEmpty) 'LABEL': label!,
|
|
if (copyright != null && copyright!.isNotEmpty) 'COPYRIGHT': copyright!,
|
|
if (composer != null && composer!.isNotEmpty) 'COMPOSER': composer!,
|
|
};
|
|
}
|
|
|
|
Map<String, String> _mapMetadataForTagEmbed(Map<String, dynamic> source) {
|
|
final mapped = <String, String>{};
|
|
|
|
void put(String key, dynamic value) {
|
|
final normalized = value?.toString().trim();
|
|
if (normalized == null || normalized.isEmpty) return;
|
|
mapped[key] = normalized;
|
|
}
|
|
|
|
put('TITLE', source['title']);
|
|
put('ARTIST', source['artist']);
|
|
put('ALBUM', source['album']);
|
|
put('ALBUMARTIST', source['album_artist']);
|
|
put('DATE', source['date']);
|
|
put('ISRC', source['isrc']);
|
|
put('GENRE', source['genre']);
|
|
put('ORGANIZATION', source['label']);
|
|
put('COPYRIGHT', source['copyright']);
|
|
put('COMPOSER', source['composer']);
|
|
put('COMMENT', source['comment']);
|
|
put('LYRICS', source['lyrics']);
|
|
put('UNSYNCEDLYRICS', source['lyrics']);
|
|
|
|
final trackNumber = source['track_number'];
|
|
final totalTracks = source['total_tracks'];
|
|
if (trackNumber != null && trackNumber.toString() != '0') {
|
|
final trackTag =
|
|
totalTracks != null &&
|
|
totalTracks.toString().isNotEmpty &&
|
|
totalTracks.toString() != '0'
|
|
? '${trackNumber.toString()}/${totalTracks.toString()}'
|
|
: trackNumber;
|
|
put('TRACKNUMBER', trackTag);
|
|
}
|
|
final discNumber = source['disc_number'];
|
|
final totalDiscs = source['total_discs'];
|
|
if (discNumber != null && discNumber.toString() != '0') {
|
|
final discTag =
|
|
totalDiscs != null &&
|
|
totalDiscs.toString().isNotEmpty &&
|
|
totalDiscs.toString() != '0'
|
|
? '${discNumber.toString()}/${totalDiscs.toString()}'
|
|
: discNumber;
|
|
put('DISCNUMBER', discTag);
|
|
}
|
|
|
|
return mapped;
|
|
}
|
|
|
|
String? _extractLossyBitrateLabel(String? quality) {
|
|
if (quality == null || quality.isEmpty) return null;
|
|
final match = RegExp(
|
|
r'(\d+)\s*k(?:bps)?',
|
|
caseSensitive: false,
|
|
).firstMatch(quality);
|
|
if (match == null) return null;
|
|
return '${match.group(1)}kbps';
|
|
}
|
|
|
|
String _extractFileNameFromPathOrUri(String pathOrUri) {
|
|
if (pathOrUri.isEmpty) return '';
|
|
try {
|
|
if (pathOrUri.startsWith('content://')) {
|
|
final uri = Uri.parse(pathOrUri);
|
|
if (uri.pathSegments.isNotEmpty) {
|
|
var last = Uri.decodeComponent(uri.pathSegments.last);
|
|
if (last.contains('/')) {
|
|
last = last.split('/').last;
|
|
}
|
|
if (last.contains(':')) {
|
|
last = last.split(':').last;
|
|
}
|
|
if (last.isNotEmpty) return last;
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
|
|
final normalized = pathOrUri.replaceAll('\\', '/');
|
|
if (normalized.contains('/')) {
|
|
return normalized.split('/').last;
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
Future<void> _rescanReplayGain() async {
|
|
if (!_fileExists) return;
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
messenger.clearSnackBars();
|
|
messenger.showSnackBar(
|
|
SnackBar(
|
|
content: Text(context.l10n.trackReplayGainScanning),
|
|
duration: const Duration(seconds: 30),
|
|
),
|
|
);
|
|
bool ok = false;
|
|
try {
|
|
ok = await ReplayGainService.applyToFile(cleanFilePath);
|
|
} catch (e) {
|
|
_log.w('ReplayGain rescan failed: $e');
|
|
}
|
|
if (!mounted) return;
|
|
messenger.hideCurrentSnackBar();
|
|
messenger.showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
ok
|
|
? context.l10n.trackReplayGainSuccess
|
|
: context.l10n.trackReplayGainFailed,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showConvertSheet(BuildContext context) {
|
|
final currentFormat = _currentFileFormat;
|
|
final isLosslessSource = isLosslessConversionSource(currentFormat);
|
|
|
|
final formats = audioConversionTargetFormats
|
|
.where(
|
|
(target) => canConvertAudioFormat(
|
|
sourceFormat: currentFormat,
|
|
targetFormat: target,
|
|
),
|
|
)
|
|
.toList(growable: false);
|
|
|
|
final labels = context.l10n.losslessConversionLabels;
|
|
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
useRootNavigator: true,
|
|
isScrollControlled: true,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
builder: (sheetContext) => BatchConvertSheet(
|
|
formats: formats,
|
|
title: context.l10n.trackConvertTitle,
|
|
subtitle: currentFormat,
|
|
sourceIsLossless: isLosslessSource,
|
|
sourceBitDepth: bitDepth,
|
|
sourceSampleRate: sampleRate,
|
|
confirmLabelBuilder:
|
|
(format, bitrate, isLosslessTarget, losslessQuality) {
|
|
return isLosslessTarget
|
|
? context.l10n.trackConvertActionLabelLossless(
|
|
currentFormat,
|
|
format,
|
|
losslessQualityLabel(
|
|
losslessQuality,
|
|
originalLabel: labels.original,
|
|
originalQualityLabel: labels.originalQuality,
|
|
),
|
|
)
|
|
: context.l10n.trackConvertActionLabelLossy(
|
|
currentFormat,
|
|
format,
|
|
bitrate,
|
|
);
|
|
},
|
|
onConvert:
|
|
(
|
|
format,
|
|
bitrate,
|
|
losslessQuality,
|
|
losslessProcessing,
|
|
keepOriginal,
|
|
) {
|
|
Navigator.pop(sheetContext);
|
|
_confirmAndConvert(
|
|
context: this.context,
|
|
sourceFormat: currentFormat,
|
|
targetFormat: format,
|
|
bitrate: bitrate,
|
|
losslessQuality: losslessQuality,
|
|
losslessProcessing: losslessProcessing,
|
|
keepOriginal: keepOriginal,
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showCueSplitSheet(BuildContext context) async {
|
|
var cuePath = cleanFilePath;
|
|
final unknownAlbum = context.l10n.unknownAlbum;
|
|
final unknownArtist = context.l10n.unknownArtist;
|
|
final trackSuffix = RegExp(r'#track\d+$');
|
|
if (trackSuffix.hasMatch(cuePath)) {
|
|
cuePath = cuePath.replaceFirst(trackSuffix, '');
|
|
}
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.snackbarLoadingCueSheet)),
|
|
);
|
|
|
|
try {
|
|
final cueInfo = await PlatformBridge.parseCueSheet(cuePath);
|
|
|
|
if (!mounted) return;
|
|
_hideCurrentSnackBar();
|
|
|
|
if (cueInfo.containsKey('error')) {
|
|
_showSnackBarMessage(_l10nCueSplitNoAudioFile);
|
|
return;
|
|
}
|
|
|
|
final album = cueInfo['album'] as String? ?? unknownAlbum;
|
|
final artist = cueInfo['artist'] as String? ?? unknownArtist;
|
|
final audioPath = cueInfo['audio_path'] as String? ?? '';
|
|
final genre = cueInfo['genre'] as String? ?? '';
|
|
final date = cueInfo['date'] as String? ?? '';
|
|
final tracksRaw = cueInfo['tracks'] as List<dynamic>? ?? [];
|
|
|
|
if (audioPath.isEmpty) {
|
|
_showSnackBarMessage(_l10nCueSplitNoAudioFile);
|
|
return;
|
|
}
|
|
|
|
final tracks = tracksRaw
|
|
.map((t) => CueSplitTrackInfo.fromJson(t as Map<String, dynamic>))
|
|
.toList();
|
|
|
|
if (tracks.isEmpty) {
|
|
_showSnackBarMessage(_l10nCueSplitFailed);
|
|
return;
|
|
}
|
|
|
|
if (!mounted) return;
|
|
|
|
showModalBottomSheet<void>(
|
|
context: this.context,
|
|
useRootNavigator: true,
|
|
isScrollControlled: true,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
builder: (sheetContext) {
|
|
final colorScheme = Theme.of(sheetContext).colorScheme;
|
|
return SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Center(
|
|
child: Container(
|
|
width: 40,
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.onSurfaceVariant.withValues(
|
|
alpha: 0.4,
|
|
),
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
sheetContext.l10n.cueSplitTitle,
|
|
style: Theme.of(sheetContext).textTheme.titleLarge
|
|
?.copyWith(fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
sheetContext.l10n.cueSplitAlbum(album),
|
|
style: Theme.of(sheetContext).textTheme.bodyMedium
|
|
?.copyWith(color: colorScheme.onSurfaceVariant),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
sheetContext.l10n.cueSplitArtist(artist),
|
|
style: Theme.of(sheetContext).textTheme.bodyMedium
|
|
?.copyWith(color: colorScheme.onSurfaceVariant),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
sheetContext.l10n.cueSplitTrackCount(tracks.length),
|
|
style: Theme.of(sheetContext).textTheme.bodyMedium
|
|
?.copyWith(
|
|
color: colorScheme.primary,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
ConstrainedBox(
|
|
constraints: const BoxConstraints(maxHeight: 200),
|
|
child: ListView.builder(
|
|
shrinkWrap: true,
|
|
itemCount: tracks.length,
|
|
itemBuilder: (context, index) {
|
|
final track = tracks[index];
|
|
final duration = track.endSec > 0
|
|
? track.endSec - track.startSec
|
|
: 0.0;
|
|
final durationStr = duration > 0
|
|
? '${(duration ~/ 60).toString().padLeft(2, '0')}:${(duration.toInt() % 60).toString().padLeft(2, '0')}'
|
|
: '';
|
|
return ListTile(
|
|
dense: true,
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: CircleAvatar(
|
|
radius: 14,
|
|
backgroundColor: colorScheme.primaryContainer,
|
|
child: Text(
|
|
'${track.number}',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: colorScheme.onPrimaryContainer,
|
|
),
|
|
),
|
|
),
|
|
title: Text(
|
|
track.title,
|
|
style: const TextStyle(fontSize: 13),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
subtitle: track.artist.isNotEmpty
|
|
? Text(
|
|
track.artist,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
)
|
|
: null,
|
|
trailing: durationStr.isNotEmpty
|
|
? Text(
|
|
durationStr,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
)
|
|
: null,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: FilledButton.icon(
|
|
onPressed: () {
|
|
Navigator.pop(sheetContext);
|
|
_confirmAndSplitCue(
|
|
context: this.context,
|
|
audioPath: audioPath,
|
|
album: album,
|
|
artist: artist,
|
|
genre: genre,
|
|
date: date,
|
|
tracks: tracks,
|
|
);
|
|
},
|
|
icon: const Icon(Icons.call_split),
|
|
label: Text(sheetContext.l10n.cueSplitButton),
|
|
style: FilledButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
_hideCurrentSnackBar();
|
|
_showSnackBarMessage(_l10nCueSplitFailed);
|
|
_log.e('Failed to parse CUE sheet: $e');
|
|
}
|
|
}
|
|
|
|
void _confirmAndSplitCue({
|
|
required BuildContext context,
|
|
required String audioPath,
|
|
required String album,
|
|
required String artist,
|
|
required String genre,
|
|
required String date,
|
|
required List<CueSplitTrackInfo> tracks,
|
|
}) {
|
|
showDialog<void>(
|
|
context: context,
|
|
builder: (dialogContext) {
|
|
return AlertDialog(
|
|
title: Text(dialogContext.l10n.cueSplitConfirmTitle),
|
|
content: Text(
|
|
dialogContext.l10n.cueSplitConfirmMessage(album, tracks.length),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext),
|
|
child: Text(dialogContext.l10n.dialogCancel),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
Navigator.pop(dialogContext);
|
|
_performCueSplit(
|
|
audioPath: audioPath,
|
|
album: album,
|
|
artist: artist,
|
|
genre: genre,
|
|
date: date,
|
|
tracks: tracks,
|
|
);
|
|
},
|
|
child: Text(dialogContext.l10n.cueSplitButton),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<Directory> _resolvePersistentCueSplitOutputDir() async {
|
|
final settings = ref.read(settingsProvider);
|
|
final queueState = ref.read(downloadQueueProvider);
|
|
final configuredOutputDir = queueState.outputDir.trim();
|
|
if (settings.storageMode != 'saf' &&
|
|
configuredOutputDir.isNotEmpty &&
|
|
!isContentUri(configuredOutputDir)) {
|
|
final dir = Directory(configuredOutputDir);
|
|
await dir.create(recursive: true);
|
|
return dir;
|
|
}
|
|
|
|
if (Platform.isAndroid) {
|
|
final externalDir = await getExternalStorageDirectory();
|
|
if (externalDir != null) {
|
|
final musicDir = Directory(
|
|
'${externalDir.parent.parent.parent.parent.path}'
|
|
'${Platform.pathSeparator}Music'
|
|
'${Platform.pathSeparator}SpotiFLAC',
|
|
);
|
|
await musicDir.create(recursive: true);
|
|
return musicDir;
|
|
}
|
|
}
|
|
|
|
final docsDir = await getApplicationDocumentsDirectory();
|
|
final fallbackDir = Directory(
|
|
'${docsDir.path}${Platform.pathSeparator}SpotiFLAC',
|
|
);
|
|
await fallbackDir.create(recursive: true);
|
|
return fallbackDir;
|
|
}
|
|
|
|
Future<List<String>?> _exportCueSplitOutputsToSaf({
|
|
required List<String> outputPaths,
|
|
required String treeUri,
|
|
required String relativeDir,
|
|
}) async {
|
|
final exportedUris = <String>[];
|
|
for (final path in outputPaths) {
|
|
final fileName = path.split(Platform.pathSeparator).last;
|
|
final safUri = await PlatformBridge.createSafFileFromPath(
|
|
treeUri: treeUri,
|
|
relativeDir: relativeDir,
|
|
fileName: fileName,
|
|
mimeType: audioMimeTypeForPath(path),
|
|
srcPath: path,
|
|
);
|
|
if (safUri != null && safUri.isNotEmpty) {
|
|
exportedUris.add(safUri);
|
|
}
|
|
}
|
|
return exportedUris.isEmpty ? null : exportedUris;
|
|
}
|
|
|
|
Future<void> _performCueSplit({
|
|
required String audioPath,
|
|
required String album,
|
|
required String artist,
|
|
required String genre,
|
|
required String date,
|
|
required List<CueSplitTrackInfo> tracks,
|
|
}) async {
|
|
if (_isConverting) return;
|
|
_setState(() => _isConverting = true);
|
|
|
|
String? safTempAudioPath;
|
|
Directory? tempSplitDir;
|
|
try {
|
|
// For SAF content:// audio paths, copy to temp for FFmpeg processing
|
|
String workingAudioPath = audioPath;
|
|
final isSafSource = isContentUri(audioPath);
|
|
if (isSafSource) {
|
|
final tempPath = await PlatformBridge.copyContentUriToTemp(audioPath);
|
|
if (tempPath == null || tempPath.isEmpty) {
|
|
throw Exception('Failed to copy SAF audio file to temp');
|
|
}
|
|
safTempAudioPath = tempPath;
|
|
workingAudioPath = tempPath;
|
|
}
|
|
|
|
final String outputDir;
|
|
final treeUri = !_isLocalItem
|
|
? (_downloadItem?.downloadTreeUri ?? '')
|
|
: '';
|
|
final relativeDir = !_isLocalItem
|
|
? (_downloadItem?.safRelativeDir ?? '')
|
|
: '';
|
|
final writeBackToSaf = isSafSource && treeUri.isNotEmpty;
|
|
if (writeBackToSaf) {
|
|
final tempDir = await getTemporaryDirectory();
|
|
tempSplitDir = Directory(
|
|
'${tempDir.path}${Platform.pathSeparator}'
|
|
'cue_split_${DateTime.now().millisecondsSinceEpoch}',
|
|
);
|
|
await tempSplitDir.create(recursive: true);
|
|
outputDir = tempSplitDir.path;
|
|
} else if (isSafSource) {
|
|
final persistentDir = await _resolvePersistentCueSplitOutputDir();
|
|
outputDir = persistentDir.path;
|
|
} else {
|
|
outputDir = File(audioPath).parent.path;
|
|
}
|
|
|
|
if (!mounted) return;
|
|
_showLongSnackBarMessage(_l10nCueSplitSplitting(1, tracks.length));
|
|
|
|
String? coverPath;
|
|
try {
|
|
final tempDir = await getTemporaryDirectory();
|
|
final coverOutput =
|
|
'${tempDir.path}${Platform.pathSeparator}cue_cover_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
|
final coverResult = await PlatformBridge.extractCoverToFile(
|
|
workingAudioPath,
|
|
coverOutput,
|
|
);
|
|
if (coverResult['error'] == null) {
|
|
coverPath = coverOutput;
|
|
}
|
|
} catch (_) {}
|
|
|
|
final albumMetadata = <String, String>{
|
|
'artist': artist,
|
|
'album': album,
|
|
'genre': genre,
|
|
'date': date,
|
|
};
|
|
|
|
final outputPaths = await FFmpegService.splitCueToTracks(
|
|
audioPath: workingAudioPath,
|
|
outputDir: outputDir,
|
|
tracks: tracks,
|
|
albumMetadata: albumMetadata,
|
|
coverPath: coverPath,
|
|
onProgress: (current, total) {
|
|
if (mounted) {
|
|
_hideCurrentSnackBar();
|
|
_showLongSnackBarMessage(_l10nCueSplitSplitting(current, total));
|
|
}
|
|
},
|
|
);
|
|
|
|
var finalOutputPaths = outputPaths;
|
|
|
|
if (coverPath != null && finalOutputPaths != null) {
|
|
for (final path in finalOutputPaths) {
|
|
if (path.toLowerCase().endsWith('.flac')) {
|
|
try {
|
|
// Only send the cover_path field — EditFlacFields uses
|
|
// field-presence semantics, so omitting artist/album_artist
|
|
// means those keys won't be rewritten. This preserves any
|
|
// existing split artist Vorbis Comments.
|
|
await PlatformBridge.editFileMetadata(path, {
|
|
'cover_path': coverPath,
|
|
});
|
|
} catch (e) {
|
|
_log.w('Failed to embed cover to split track: $e');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (writeBackToSaf && finalOutputPaths != null) {
|
|
final exportedUris = await _exportCueSplitOutputsToSaf(
|
|
outputPaths: finalOutputPaths,
|
|
treeUri: treeUri,
|
|
relativeDir: relativeDir,
|
|
);
|
|
finalOutputPaths = exportedUris;
|
|
}
|
|
|
|
if (coverPath != null) {
|
|
try {
|
|
await File(coverPath).delete();
|
|
} catch (_) {}
|
|
}
|
|
|
|
if (mounted) {
|
|
_hideCurrentSnackBar();
|
|
if (finalOutputPaths != null && finalOutputPaths.isNotEmpty) {
|
|
_showSnackBarMessage(_l10nCueSplitSuccess(finalOutputPaths.length));
|
|
} else {
|
|
_showSnackBarMessage(_l10nCueSplitFailed);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
_log.e('CUE split failed: $e');
|
|
if (mounted) {
|
|
_hideCurrentSnackBar();
|
|
_showSnackBarMessage(_l10nCueSplitFailed);
|
|
}
|
|
} finally {
|
|
if (safTempAudioPath != null) {
|
|
try {
|
|
await File(safTempAudioPath).delete();
|
|
} catch (_) {}
|
|
}
|
|
if (tempSplitDir != null) {
|
|
try {
|
|
await tempSplitDir.delete(recursive: true);
|
|
} catch (_) {}
|
|
}
|
|
if (mounted) {
|
|
_setState(() => _isConverting = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _confirmAndConvert({
|
|
required BuildContext context,
|
|
required String sourceFormat,
|
|
required String targetFormat,
|
|
required String bitrate,
|
|
LosslessConversionQuality losslessQuality =
|
|
const LosslessConversionQuality(),
|
|
LosslessConversionProcessing losslessProcessing =
|
|
const LosslessConversionProcessing(),
|
|
bool keepOriginal = false,
|
|
}) {
|
|
final isLossless = isLosslessConversionTarget(targetFormat);
|
|
showDialog<void>(
|
|
context: context,
|
|
builder: (dialogContext) {
|
|
return AlertDialog(
|
|
title: Text(dialogContext.l10n.trackConvertConfirmTitle),
|
|
content: Text(
|
|
keepOriginal
|
|
? dialogContext.l10n.trackConvertConfirmKeepOriginal(
|
|
sourceFormat,
|
|
targetFormat,
|
|
)
|
|
: isLossless && losslessQuality.hasCaps
|
|
? dialogContext.l10n.trackConvertConfirmMessageLosslessCapped(
|
|
sourceFormat,
|
|
targetFormat,
|
|
losslessQualityLabel(
|
|
losslessQuality,
|
|
originalLabel:
|
|
dialogContext.l10n.losslessConversionLabels.original,
|
|
originalQualityLabel: dialogContext
|
|
.l10n
|
|
.losslessConversionLabels
|
|
.originalQuality,
|
|
),
|
|
)
|
|
: isLossless
|
|
? dialogContext.l10n.trackConvertConfirmMessageLossless(
|
|
sourceFormat,
|
|
targetFormat,
|
|
)
|
|
: dialogContext.l10n.trackConvertConfirmMessage(
|
|
sourceFormat,
|
|
targetFormat,
|
|
bitrate,
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext),
|
|
child: Text(dialogContext.l10n.dialogCancel),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
Navigator.pop(dialogContext);
|
|
_performConversion(
|
|
targetFormat: targetFormat,
|
|
bitrate: bitrate,
|
|
losslessQuality: losslessQuality,
|
|
losslessProcessing: losslessProcessing,
|
|
keepOriginal: keepOriginal,
|
|
);
|
|
},
|
|
child: Text(dialogContext.l10n.trackConvertFormat),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _performConversion({
|
|
required String targetFormat,
|
|
required String bitrate,
|
|
LosslessConversionQuality losslessQuality =
|
|
const LosslessConversionQuality(),
|
|
LosslessConversionProcessing losslessProcessing =
|
|
const LosslessConversionProcessing(),
|
|
bool keepOriginal = false,
|
|
}) async {
|
|
if (_isConverting) return;
|
|
_setState(() => _isConverting = true);
|
|
final losslessLabels = context.l10n.losslessConversionLabels;
|
|
String? coverPath;
|
|
String? safTempPath;
|
|
String? convertedSafTempPath;
|
|
|
|
try {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.trackConvertConverting)),
|
|
);
|
|
|
|
final settings = ref.read(settingsProvider);
|
|
final shouldEmbedLyrics =
|
|
settings.embedLyrics && settings.lyricsMode != 'external';
|
|
final metadata = _buildFallbackMetadata();
|
|
try {
|
|
final result = await PlatformBridge.readFileMetadata(cleanFilePath);
|
|
if (result['error'] == null) {
|
|
mergePlatformMetadataForTagEmbed(target: metadata, source: result);
|
|
} else {
|
|
_log.w('readFileMetadata returned error, using fallback metadata');
|
|
}
|
|
} catch (e) {
|
|
_log.w('readFileMetadata threw, using fallback metadata: $e');
|
|
}
|
|
await ensureLyricsMetadataForConversion(
|
|
metadata: metadata,
|
|
sourcePath: cleanFilePath,
|
|
shouldEmbedLyrics: shouldEmbedLyrics,
|
|
trackName: trackName,
|
|
artistName: artistName,
|
|
spotifyId: _spotifyId ?? '',
|
|
durationMs: (duration ?? 0) * 1000,
|
|
);
|
|
|
|
try {
|
|
final tempDir = await getTemporaryDirectory();
|
|
final coverOutput =
|
|
'${tempDir.path}${Platform.pathSeparator}convert_cover_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
|
final coverResult = await PlatformBridge.extractCoverToFile(
|
|
cleanFilePath,
|
|
coverOutput,
|
|
);
|
|
if (coverResult['error'] == null) {
|
|
coverPath = coverOutput;
|
|
}
|
|
} catch (_) {}
|
|
|
|
String workingPath = cleanFilePath;
|
|
final isSaf = _isSafFile;
|
|
|
|
if (isSaf) {
|
|
safTempPath = await ConversionLibraryService.copySafSourceToTemp(
|
|
cleanFilePath,
|
|
);
|
|
if (safTempPath == null) {
|
|
if (mounted) {
|
|
_setState(() => _isConverting = false);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.trackConvertFailed)),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
workingPath = safTempPath;
|
|
}
|
|
|
|
final newPath = await FFmpegService.convertAudioFormat(
|
|
inputPath: workingPath,
|
|
targetFormat: targetFormat.toLowerCase(),
|
|
bitrate: bitrate,
|
|
metadata: metadata,
|
|
coverPath: coverPath,
|
|
artistTagMode: ref.read(settingsProvider).artistTagMode,
|
|
deleteOriginal: !isSaf && !keepOriginal,
|
|
sourceBitDepth: 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 (_) {}
|
|
}
|
|
if (mounted) {
|
|
_setState(() => _isConverting = false);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.trackConvertFailed)),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
if (isSaf) convertedSafTempPath = newPath;
|
|
|
|
final isLosslessOutput = isLosslessConversionTarget(targetFormat);
|
|
int? convertedBitDepth;
|
|
int? convertedSampleRate;
|
|
if (isLosslessOutput) {
|
|
try {
|
|
final convertedMetadata = await PlatformBridge.readFileMetadata(
|
|
newPath,
|
|
);
|
|
if (convertedMetadata['error'] == null) {
|
|
convertedBitDepth = readPositiveInt(convertedMetadata['bit_depth']);
|
|
convertedSampleRate = readPositiveInt(
|
|
convertedMetadata['sample_rate'],
|
|
);
|
|
}
|
|
} catch (_) {}
|
|
convertedBitDepth ??= losslessQuality.effectiveBitDepth(bitDepth);
|
|
convertedSampleRate ??= losslessQuality.effectiveSampleRate(sampleRate);
|
|
}
|
|
final newQuality = convertedAudioQualityLabel(
|
|
targetFormat: targetFormat,
|
|
bitrate: bitrate,
|
|
labels: losslessLabels,
|
|
losslessQuality: losslessQuality,
|
|
actualBitDepth: convertedBitDepth,
|
|
actualSampleRate: convertedSampleRate,
|
|
);
|
|
|
|
if (isSaf) {
|
|
String? treeUri;
|
|
String relativeDir = '';
|
|
String oldFileName = '';
|
|
if (_isLocalItem) {
|
|
final uri = Uri.parse(cleanFilePath);
|
|
final pathSegments = uri.pathSegments;
|
|
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;
|
|
}
|
|
}
|
|
} else {
|
|
treeUri = _downloadItem?.downloadTreeUri;
|
|
relativeDir = _downloadItem?.safRelativeDir ?? '';
|
|
oldFileName =
|
|
(_downloadItem?.safFileName != null &&
|
|
_downloadItem!.safFileName!.isNotEmpty)
|
|
? _downloadItem!.safFileName!
|
|
: _extractFileNameFromPathOrUri(cleanFilePath);
|
|
}
|
|
if (treeUri == null || treeUri.isEmpty) {
|
|
try {
|
|
await File(newPath).delete();
|
|
} catch (_) {}
|
|
if (safTempPath != null) {
|
|
try {
|
|
await File(safTempPath).delete();
|
|
} catch (_) {}
|
|
}
|
|
if (mounted) {
|
|
_setState(() => _isConverting = false);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.trackConvertFailed)),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
final published = await ConversionLibraryService.publishSafConversion(
|
|
treeUri: treeUri,
|
|
relativeDir: relativeDir,
|
|
originalFileName: oldFileName,
|
|
targetFormat: targetFormat,
|
|
sourcePath: newPath,
|
|
keepOriginal: keepOriginal,
|
|
);
|
|
final safUri = published?.uri;
|
|
|
|
if (safUri == null || safUri.isEmpty) {
|
|
try {
|
|
await File(newPath).delete();
|
|
} catch (_) {}
|
|
if (safTempPath != null) {
|
|
try {
|
|
await File(safTempPath).delete();
|
|
} catch (_) {}
|
|
}
|
|
if (mounted) {
|
|
_setState(() => _isConverting = false);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.trackConvertFailed)),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!keepOriginal && !isSameContentUri(cleanFilePath, safUri)) {
|
|
final deletedOriginal = await PlatformBridge.safDelete(
|
|
cleanFilePath,
|
|
).catchError((_) => false);
|
|
if (deletedOriginal != true) {
|
|
_log.w(
|
|
'Converted SAF file created but failed deleting original URI',
|
|
);
|
|
}
|
|
}
|
|
|
|
if (!_isLocalItem) {
|
|
await ConversionLibraryService.persistHistoryConversion(
|
|
source: _downloadItem!,
|
|
newFilePath: safUri,
|
|
newQuality: newQuality,
|
|
targetFormat: targetFormat,
|
|
bitrate: bitrate,
|
|
bitDepth: convertedBitDepth,
|
|
sampleRate: convertedSampleRate,
|
|
keepOriginal: keepOriginal,
|
|
newSafFileName: published!.fileName,
|
|
);
|
|
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
|
|
} else {
|
|
await LibraryDatabase.instance.replaceWithConvertedItem(
|
|
item: _localLibraryItem!,
|
|
newFilePath: safUri,
|
|
targetFormat: targetFormat,
|
|
bitrate: bitrate,
|
|
bitDepth: convertedBitDepth,
|
|
sampleRate: convertedSampleRate,
|
|
keepOriginal: keepOriginal,
|
|
);
|
|
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
|
|
}
|
|
|
|
try {
|
|
await File(newPath).delete();
|
|
} catch (_) {}
|
|
if (safTempPath != null) {
|
|
try {
|
|
await File(safTempPath).delete();
|
|
} catch (_) {}
|
|
}
|
|
} else {
|
|
if (!_isLocalItem) {
|
|
await ConversionLibraryService.persistHistoryConversion(
|
|
source: _downloadItem!,
|
|
newFilePath: newPath,
|
|
newQuality: newQuality,
|
|
targetFormat: targetFormat,
|
|
bitrate: bitrate,
|
|
bitDepth: convertedBitDepth,
|
|
sampleRate: convertedSampleRate,
|
|
keepOriginal: keepOriginal,
|
|
);
|
|
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
|
|
} else {
|
|
await LibraryDatabase.instance.replaceWithConvertedItem(
|
|
item: _localLibraryItem!,
|
|
newFilePath: newPath,
|
|
targetFormat: targetFormat,
|
|
bitrate: bitrate,
|
|
bitDepth: convertedBitDepth,
|
|
sampleRate: convertedSampleRate,
|
|
keepOriginal: keepOriginal,
|
|
);
|
|
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
|
|
}
|
|
}
|
|
|
|
if (mounted) {
|
|
_setState(() => _isConverting = false);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(context.l10n.trackConvertSuccess(targetFormat)),
|
|
),
|
|
);
|
|
Navigator.pop(context, true);
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
_setState(() => _isConverting = false);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.trackSaveFailed(e.toString()))),
|
|
);
|
|
}
|
|
} finally {
|
|
for (final path in <String?>[
|
|
coverPath,
|
|
safTempPath,
|
|
convertedSafTempPath,
|
|
]) {
|
|
if (path == null || path.isEmpty) continue;
|
|
try {
|
|
final file = File(path);
|
|
if (await file.exists()) await file.delete();
|
|
} catch (error) {
|
|
_log.w('Failed to clean conversion temp file $path: $error');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|