mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 13:19:44 +02:00
Add a selectable Mornye theme with shared glass controls, dialogs, sheets, notifications, and collection headers while retaining Material components. Update player artwork, lyrics transitions and highlighting, synchronized rolling time labels, volume controls, and compact navigation. Unify metadata, Library, search, and extension surfaces; preserve restored artwork and report extension load failures. Validation: full Flutter analyzer clean; 713 tests passed and 4 skipped; pinned Dart formatting and diff checks passed; signed ARM64 and ARM32 release APKs built and verified.
1126 lines
37 KiB
Dart
1126 lines
37 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!,
|
|
if (isExplicit) 'ITUNESADVISORY': '1',
|
|
};
|
|
}
|
|
|
|
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']);
|
|
if (parseExplicitFlag(source['explicit']) == true) {
|
|
mapped['ITUNESADVISORY'] = '1';
|
|
}
|
|
|
|
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 sourcePath = cleanFilePath;
|
|
final generation = _metadataLoadGeneration;
|
|
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(sourcePath);
|
|
} catch (e) {
|
|
_log.w('ReplayGain rescan failed: $e');
|
|
}
|
|
if (!mounted) return;
|
|
if (ok &&
|
|
generation == _metadataLoadGeneration &&
|
|
sourcePath == cleanFilePath) {
|
|
_hasLoadedResolvedAudioMetadata = false;
|
|
await _refreshResolvedAudioMetadataFromFile();
|
|
}
|
|
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,
|
|
backgroundColor: context.isMornye ? Colors.transparent : null,
|
|
elevation: context.isMornye ? 0 : null,
|
|
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,
|
|
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: [
|
|
const AppSheetHandle(),
|
|
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,
|
|
}) {
|
|
showAppDialog<void>(
|
|
context: context,
|
|
builder: (dialogContext) {
|
|
return AppAlertDialog(
|
|
title: Text(dialogContext.l10n.cueSplitConfirmTitle),
|
|
content: Text(
|
|
dialogContext.l10n.cueSplitConfirmMessage(album, tracks.length),
|
|
),
|
|
actions: [
|
|
AppDialogAction(
|
|
onPressed: () => Navigator.pop(dialogContext),
|
|
child: Text(dialogContext.l10n.dialogCancel),
|
|
),
|
|
AppDialogAction(
|
|
filled: true,
|
|
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);
|
|
showAppDialog<void>(
|
|
context: context,
|
|
builder: (dialogContext) {
|
|
return AppAlertDialog(
|
|
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: [
|
|
AppDialogAction(
|
|
onPressed: () => Navigator.pop(dialogContext),
|
|
child: Text(dialogContext.l10n.dialogCancel),
|
|
),
|
|
AppDialogAction(
|
|
filled: true,
|
|
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 location = resolveSafDocumentLocation(cleanFilePath);
|
|
if (location != null) {
|
|
treeUri = location.treeUri;
|
|
relativeDir = location.relativeDir;
|
|
oldFileName = location.fileName;
|
|
}
|
|
} 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(context.friendlyError(e)),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
} 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');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|