mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-24 18:40:43 +02:00
fix(download): remove legacy HIGH quality conversion
This commit is contained in:
@@ -74,8 +74,6 @@ class AppSettings {
|
||||
extensionVerificationBrowserMode; // 'external_first' or 'in_app_first'
|
||||
final String locale;
|
||||
final String lyricsMode;
|
||||
final String
|
||||
tidalHighFormat; // Legacy key for 320kbps lossy output format: 'mp3_320', 'aac_320', 'opus_256', or 'opus_128'
|
||||
final bool autoConvertDownloads;
|
||||
final String autoConvertFormat; // 'mp3', 'aac' (M4A), or 'opus'
|
||||
final String autoConvertBitrate; // '128k', '192k', '256k', or '320k'
|
||||
@@ -169,7 +167,6 @@ class AppSettings {
|
||||
this.extensionVerificationBrowserMode = 'in_app_first',
|
||||
this.locale = 'system',
|
||||
this.lyricsMode = 'embed',
|
||||
this.tidalHighFormat = 'mp3_320',
|
||||
this.autoConvertDownloads = false,
|
||||
this.autoConvertFormat = 'mp3',
|
||||
this.autoConvertBitrate = '320k',
|
||||
@@ -247,7 +244,6 @@ class AppSettings {
|
||||
String? extensionVerificationBrowserMode,
|
||||
String? locale,
|
||||
String? lyricsMode,
|
||||
String? tidalHighFormat,
|
||||
bool? autoConvertDownloads,
|
||||
String? autoConvertFormat,
|
||||
String? autoConvertBitrate,
|
||||
@@ -339,7 +335,6 @@ class AppSettings {
|
||||
this.extensionVerificationBrowserMode,
|
||||
locale: locale ?? this.locale,
|
||||
lyricsMode: lyricsMode ?? this.lyricsMode,
|
||||
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
|
||||
autoConvertDownloads: autoConvertDownloads ?? this.autoConvertDownloads,
|
||||
autoConvertFormat: autoConvertFormat ?? this.autoConvertFormat,
|
||||
autoConvertBitrate: autoConvertBitrate ?? this.autoConvertBitrate,
|
||||
|
||||
@@ -60,7 +60,6 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
json['extensionVerificationBrowserMode'] as String? ?? 'in_app_first',
|
||||
locale: json['locale'] as String? ?? 'system',
|
||||
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
|
||||
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
|
||||
autoConvertDownloads: json['autoConvertDownloads'] as bool? ?? false,
|
||||
autoConvertFormat: json['autoConvertFormat'] as String? ?? 'mp3',
|
||||
autoConvertBitrate: json['autoConvertBitrate'] as String? ?? '320k',
|
||||
@@ -148,7 +147,6 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'extensionVerificationBrowserMode': instance.extensionVerificationBrowserMode,
|
||||
'locale': instance.locale,
|
||||
'lyricsMode': instance.lyricsMode,
|
||||
'tidalHighFormat': instance.tidalHighFormat,
|
||||
'autoConvertDownloads': instance.autoConvertDownloads,
|
||||
'autoConvertFormat': instance.autoConvertFormat,
|
||||
'autoConvertBitrate': instance.autoConvertBitrate,
|
||||
|
||||
@@ -873,7 +873,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
!_shouldSkipLyrics(extensionState, track.source, item.service),
|
||||
embedReplayGain: settings.embedReplayGain,
|
||||
postProcessingEnabled: postProcessingEnabled,
|
||||
tidalHighFormat: settings.tidalHighFormat,
|
||||
autoConvertDownloads: settings.autoConvertDownloads,
|
||||
autoConvertFormat: normalizeAutoConvertFormat(settings.autoConvertFormat),
|
||||
autoConvertBitrate: normalizeAutoConvertBitrate(
|
||||
|
||||
@@ -992,114 +992,6 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _finalizeNativeWorkerHighConversion({
|
||||
required _NativeWorkerRequestContext context,
|
||||
required Map<String, dynamic> result,
|
||||
required AppSettings settings,
|
||||
required Track track,
|
||||
required String filePath,
|
||||
}) async {
|
||||
if (context.quality != 'HIGH') {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
final lowerPath = filePath.toLowerCase();
|
||||
final resultFileName = (result['file_name'] as String?)?.toLowerCase();
|
||||
final looksLikeM4a =
|
||||
lowerPath.endsWith('.m4a') ||
|
||||
lowerPath.endsWith('.mp4') ||
|
||||
(resultFileName != null &&
|
||||
(resultFileName.endsWith('.m4a') ||
|
||||
resultFileName.endsWith('.mp4')));
|
||||
if (!looksLikeM4a) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
final tidalHighFormat = settings.autoConvertDownloads
|
||||
? autoConvertLossySetting(
|
||||
format: settings.autoConvertFormat,
|
||||
bitrate: settings.autoConvertBitrate,
|
||||
)
|
||||
: settings.tidalHighFormat;
|
||||
final format = lossyFormatForSetting(tidalHighFormat);
|
||||
final newExt = lossyExtensionForFormat(format);
|
||||
final displayFormat = displayFormatForLossyFormat(format);
|
||||
final bitrateDisplay = tidalHighFormat.contains('_')
|
||||
? '${tidalHighFormat.split('_').last}kbps'
|
||||
: '320kbps';
|
||||
|
||||
Future<void> embedConvertedMetadata(String convertedPath) async {
|
||||
if (!settings.embedMetadata) return;
|
||||
await _embedMetadataToFile(
|
||||
convertedPath,
|
||||
track,
|
||||
format: metadataFormatForLossyFormat(format),
|
||||
genre: result['genre'] as String?,
|
||||
label: result['label'] as String?,
|
||||
copyright: result['copyright'] as String?,
|
||||
comment: result['comment'] as String?,
|
||||
downloadService: context.item.service,
|
||||
);
|
||||
}
|
||||
|
||||
if (context.storageMode == 'saf' && isContentUri(filePath)) {
|
||||
final treeUri = context.downloadTreeUri;
|
||||
if (treeUri == null || treeUri.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final rawFileName =
|
||||
(result['file_name'] as String?) ?? context.safFileName ?? 'track';
|
||||
final baseName = rawFileName.replaceFirst(RegExp(r'\.[^.]+$'), '');
|
||||
final newFileName = '$baseName$newExt';
|
||||
final newUri = await _replaceSafFileVia(
|
||||
uri: filePath,
|
||||
treeUri: treeUri,
|
||||
relativeDir: context.safRelativeDir ?? '',
|
||||
op: (tempPath, addCleanup) async {
|
||||
final convertedPath = await FFmpegService.convertM4aToLossy(
|
||||
tempPath,
|
||||
format: format,
|
||||
bitrate: tidalHighFormat,
|
||||
deleteOriginal: false,
|
||||
);
|
||||
if (convertedPath == null) return null;
|
||||
addCleanup(convertedPath);
|
||||
await embedConvertedMetadata(convertedPath);
|
||||
return (convertedPath, newFileName);
|
||||
},
|
||||
);
|
||||
if (newUri == null) {
|
||||
return null;
|
||||
}
|
||||
result['file_name'] = newFileName;
|
||||
result['_native_actual_quality'] = '$displayFormat $bitrateDisplay';
|
||||
result['audio_codec'] = format;
|
||||
result['format'] = format;
|
||||
result['bitrate'] = int.tryParse(tidalHighFormat.split('_').last);
|
||||
result.remove('actual_bit_depth');
|
||||
result.remove('actual_sample_rate');
|
||||
return newUri;
|
||||
}
|
||||
|
||||
final convertedPath = await FFmpegService.convertM4aToLossy(
|
||||
filePath,
|
||||
format: format,
|
||||
bitrate: tidalHighFormat,
|
||||
deleteOriginal: true,
|
||||
);
|
||||
if (convertedPath == null) {
|
||||
return null;
|
||||
}
|
||||
await embedConvertedMetadata(convertedPath);
|
||||
result['_native_actual_quality'] = '$displayFormat $bitrateDisplay';
|
||||
result['audio_codec'] = format;
|
||||
result['format'] = format;
|
||||
result['bitrate'] = int.tryParse(tidalHighFormat.split('_').last);
|
||||
result.remove('actual_bit_depth');
|
||||
result.remove('actual_sample_rate');
|
||||
return convertedPath;
|
||||
}
|
||||
|
||||
Future<String?> _finalizeNativeWorkerContainerConversion({
|
||||
required _NativeWorkerRequestContext context,
|
||||
required Map<String, dynamic> result,
|
||||
@@ -1107,7 +999,7 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
|
||||
required Track track,
|
||||
required String filePath,
|
||||
}) async {
|
||||
if (context.quality == 'HIGH' || context.outputExt != '.flac') {
|
||||
if (context.outputExt != '.flac') {
|
||||
return filePath;
|
||||
}
|
||||
final resultAudioFormat = normalizeAudioFormatValue(
|
||||
|
||||
@@ -1341,28 +1341,6 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
|
||||
result,
|
||||
resolvedAlbumArtist,
|
||||
);
|
||||
final convertedHighPath = await _finalizeNativeWorkerHighConversion(
|
||||
context: context,
|
||||
result: result,
|
||||
settings: settings,
|
||||
track: trackToDownload,
|
||||
filePath: filePath,
|
||||
);
|
||||
if (convertedHighPath == null) {
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.failed,
|
||||
error: 'Failed to convert HIGH quality download',
|
||||
errorType: DownloadErrorType.unknown,
|
||||
);
|
||||
_failedInSession++;
|
||||
return;
|
||||
}
|
||||
filePath = convertedHighPath;
|
||||
final nativeActualQuality = result['_native_actual_quality'] as String?;
|
||||
if (nativeActualQuality != null && nativeActualQuality.isNotEmpty) {
|
||||
actualQuality = nativeActualQuality;
|
||||
}
|
||||
final convertedContainerPath =
|
||||
await _finalizeNativeWorkerContainerConversion(
|
||||
context: context,
|
||||
@@ -1413,6 +1391,46 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
|
||||
actualSampleRate = null;
|
||||
actualFormat = normalizeAutoConvertFormat(settings.autoConvertFormat);
|
||||
actualBitrate = autoConvertBitrateKbps(settings.autoConvertBitrate);
|
||||
} else if (settings.embedMetadata &&
|
||||
_isMp4Container(result['file_name']?.toString() ?? filePath)) {
|
||||
// Native lossy containers still need tags and lyrics even though their
|
||||
// audio is no longer passed through the retired quality conversion.
|
||||
Future<void> embedNativeMetadata(String path) async {
|
||||
final lyrics = await _embedMetadataToFile(
|
||||
path,
|
||||
trackToDownload,
|
||||
format: 'm4a',
|
||||
genre: result['genre'] as String?,
|
||||
label: result['label'] as String?,
|
||||
copyright: result['copyright'] as String?,
|
||||
comment: result['comment'] as String?,
|
||||
downloadService: context.item.service,
|
||||
writeExternalLrc: context.storageMode != 'saf',
|
||||
);
|
||||
if (lyrics != null && lyrics.isNotEmpty) result['lyrics_lrc'] = lyrics;
|
||||
}
|
||||
|
||||
if (context.storageMode == 'saf' && isContentUri(filePath)) {
|
||||
final treeUri = context.downloadTreeUri;
|
||||
if (treeUri != null && treeUri.isNotEmpty) {
|
||||
final fileName =
|
||||
result['file_name']?.toString() ?? context.safFileName;
|
||||
if (fileName != null && fileName.isNotEmpty) {
|
||||
final updatedUri = await _replaceSafFileVia(
|
||||
uri: filePath,
|
||||
treeUri: treeUri,
|
||||
relativeDir: context.safRelativeDir ?? '',
|
||||
op: (tempPath, _) async {
|
||||
await embedNativeMetadata(tempPath);
|
||||
return (tempPath, fileName);
|
||||
},
|
||||
);
|
||||
if (updatedUri != null) filePath = updatedUri;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await embedNativeMetadata(filePath);
|
||||
}
|
||||
}
|
||||
await _writeNativeWorkerReplayGain(
|
||||
settings: settings,
|
||||
@@ -1479,7 +1497,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
|
||||
|
||||
final resultSafFileName = result['file_name'] as String?;
|
||||
final lowerFilePath = filePath.toLowerCase();
|
||||
// Recompute from the FINAL file path/result: the HIGH and container
|
||||
// Recompute from the FINAL file path/result: automatic and container
|
||||
// conversions above may have changed the format since actualFormat was
|
||||
// derived from the pre-conversion output.
|
||||
final historyFormat =
|
||||
|
||||
@@ -933,16 +933,12 @@ class _DownloadRun {
|
||||
if (isContentUriPath && effectiveSafMode) {
|
||||
if (shouldPreserveNativeM4a) {
|
||||
await _preserveSafNativeM4a(path);
|
||||
} else if (quality == 'HIGH') {
|
||||
await _convertSafM4aToLossy(path);
|
||||
} else {
|
||||
await _convertSafM4aToFlac(path);
|
||||
}
|
||||
} else {
|
||||
if (shouldPreserveNativeM4a) {
|
||||
await _preserveLocalNativeM4a(path);
|
||||
} else if (quality == 'HIGH') {
|
||||
await _convertLocalM4aToLossy(path);
|
||||
} else {
|
||||
await _convertLocalM4aToFlac(path);
|
||||
}
|
||||
@@ -1110,91 +1106,6 @@ class _DownloadRun {
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _convertSafM4aToLossy(String currentFilePath) async {
|
||||
final tidalHighFormat = settings.autoConvertDownloads
|
||||
? autoConvertLossySetting(
|
||||
format: settings.autoConvertFormat,
|
||||
bitrate: settings.autoConvertBitrate,
|
||||
)
|
||||
: settings.tidalHighFormat;
|
||||
_log.i(
|
||||
'Lossy 320kbps quality (SAF), converting M4A to $tidalHighFormat...',
|
||||
);
|
||||
|
||||
final format = lossyFormatForSetting(tidalHighFormat);
|
||||
final displayFormat = displayFormatForLossyFormat(format);
|
||||
final newExt = lossyExtensionForFormat(format);
|
||||
final newFileName = '${safBaseName ?? 'track'}$newExt';
|
||||
var opStarted = false;
|
||||
var convertFailed = false;
|
||||
try {
|
||||
final newUri = await n._replaceSafFileVia(
|
||||
uri: currentFilePath,
|
||||
treeUri: settings.downloadTreeUri,
|
||||
relativeDir: effectiveOutputDir,
|
||||
op: (tempPath, addCleanup) async {
|
||||
opStarted = true;
|
||||
n.updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.finalizing,
|
||||
progress: 0.95,
|
||||
);
|
||||
final convertedPath = await FFmpegService.convertM4aToLossy(
|
||||
tempPath,
|
||||
format: format,
|
||||
bitrate: tidalHighFormat,
|
||||
deleteOriginal: false,
|
||||
);
|
||||
if (convertedPath == null) {
|
||||
convertFailed = true;
|
||||
return null;
|
||||
}
|
||||
addCleanup(convertedPath);
|
||||
_log.i(
|
||||
'Successfully converted M4A to $format (temp): $convertedPath',
|
||||
);
|
||||
_log.i('Embedding metadata to $format...');
|
||||
n.updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.finalizing,
|
||||
progress: 0.99,
|
||||
);
|
||||
|
||||
await _embedFinalMetadata(
|
||||
convertedPath,
|
||||
format: metadataFormatForLossyFormat(format),
|
||||
rebuildTrack: false,
|
||||
);
|
||||
|
||||
return (convertedPath, newFileName);
|
||||
},
|
||||
);
|
||||
|
||||
if (newUri != null) {
|
||||
filePath = newUri;
|
||||
finalSafFileName = newFileName;
|
||||
final bitrateDisplay = tidalHighFormat.contains('_')
|
||||
? '${tidalHighFormat.split('_').last}kbps'
|
||||
: '320kbps';
|
||||
actualQuality = '$displayFormat $bitrateDisplay';
|
||||
result['audio_codec'] = format;
|
||||
result['format'] = format;
|
||||
result['bitrate'] = int.tryParse(tidalHighFormat.split('_').last);
|
||||
result.remove('actual_bit_depth');
|
||||
result.remove('actual_sample_rate');
|
||||
} else if (convertFailed) {
|
||||
_log.w('M4A to $format conversion failed, keeping M4A file');
|
||||
actualQuality = 'AAC 320kbps';
|
||||
} else if (opStarted) {
|
||||
_log.w('Failed to write converted $format to SAF, keeping M4A');
|
||||
actualQuality = 'AAC 320kbps';
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('SAF M4A conversion failed: $e');
|
||||
actualQuality = 'AAC 320kbps';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _preserveSafNativeM4a(String currentFilePath) async {
|
||||
// Decrypted streams are already in their final format.
|
||||
// Converting e.g. eac3 M4A to FLAC would produce fake upscaled output.
|
||||
@@ -1328,61 +1239,6 @@ class _DownloadRun {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _convertLocalM4aToLossy(String currentFilePath) async {
|
||||
final tidalHighFormat = settings.autoConvertDownloads
|
||||
? autoConvertLossySetting(
|
||||
format: settings.autoConvertFormat,
|
||||
bitrate: settings.autoConvertBitrate,
|
||||
)
|
||||
: settings.tidalHighFormat;
|
||||
_log.i(
|
||||
'Lossy 320kbps quality download, converting M4A to $tidalHighFormat...',
|
||||
);
|
||||
|
||||
try {
|
||||
n.updateItemStatus(item.id, DownloadStatus.finalizing, progress: 0.95);
|
||||
|
||||
final format = lossyFormatForSetting(tidalHighFormat);
|
||||
final displayFormat = displayFormatForLossyFormat(format);
|
||||
final convertedPath = await FFmpegService.convertM4aToLossy(
|
||||
currentFilePath,
|
||||
format: format,
|
||||
bitrate: tidalHighFormat,
|
||||
deleteOriginal: true,
|
||||
);
|
||||
|
||||
if (convertedPath != null) {
|
||||
filePath = convertedPath;
|
||||
final bitrateDisplay = tidalHighFormat.contains('_')
|
||||
? '${tidalHighFormat.split('_').last}kbps'
|
||||
: '320kbps';
|
||||
actualQuality = '$displayFormat $bitrateDisplay';
|
||||
result['audio_codec'] = format;
|
||||
result['format'] = format;
|
||||
result['bitrate'] = int.tryParse(tidalHighFormat.split('_').last);
|
||||
result.remove('actual_bit_depth');
|
||||
result.remove('actual_sample_rate');
|
||||
_log.i('Successfully converted M4A to $format: $convertedPath');
|
||||
|
||||
_log.i('Embedding metadata to $format...');
|
||||
n.updateItemStatus(item.id, DownloadStatus.finalizing, progress: 0.99);
|
||||
|
||||
await _embedFinalMetadata(
|
||||
convertedPath,
|
||||
format: metadataFormatForLossyFormat(format),
|
||||
rebuildTrack: false,
|
||||
);
|
||||
_log.d('Metadata embedded successfully');
|
||||
} else {
|
||||
_log.w('M4A to $format conversion failed, keeping M4A file');
|
||||
actualQuality = 'AAC 320kbps';
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('M4A conversion process failed: $e, keeping M4A file');
|
||||
actualQuality = 'AAC 320kbps';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _preserveLocalNativeM4a(String currentFilePath) async {
|
||||
_log.d('M4A/MP4 file detected, preserving native container...');
|
||||
|
||||
@@ -1608,21 +1464,16 @@ class _DownloadRun {
|
||||
|
||||
/// Final metadata embed shared by every publish branch. Backend-provided
|
||||
/// genre/label/copyright win over the Deezer extended-metadata lookup.
|
||||
/// [rebuildTrack] is false only for the lossy-HIGH branches, which embed
|
||||
/// the track as-is instead of re-merging the download result into it.
|
||||
Future<String?> _embedFinalMetadata(
|
||||
String path, {
|
||||
required String format,
|
||||
bool writeExternalLrc = true,
|
||||
bool rebuildTrack = true,
|
||||
}) async {
|
||||
final track = rebuildTrack
|
||||
? buildTrackForMetadataEmbedding(
|
||||
trackToDownload,
|
||||
result,
|
||||
resolvedAlbumArtist,
|
||||
)
|
||||
: trackToDownload;
|
||||
final track = buildTrackForMetadataEmbedding(
|
||||
trackToDownload,
|
||||
result,
|
||||
resolvedAlbumArtist,
|
||||
);
|
||||
final lrcContent = await n._embedMetadataToFile(
|
||||
path,
|
||||
track,
|
||||
|
||||
@@ -768,11 +768,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setTidalHighFormat(String format) {
|
||||
state = state.copyWith(tidalHighFormat: format);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setAutoConvertDownloads(bool enabled) {
|
||||
state = state.copyWith(autoConvertDownloads: enabled);
|
||||
_saveSettings();
|
||||
|
||||
@@ -42,14 +42,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
final qualityOptions =
|
||||
selectedDownloadExtension?.qualityOptions ?? const <QualityOption>[];
|
||||
final canSelectQuality = qualityOptions.isNotEmpty;
|
||||
final usesTidalCompatibilityOptions = selectedDownloadService.isNotEmpty
|
||||
? ref
|
||||
.read(extensionProvider.notifier)
|
||||
.downloadProviderReplacesLegacyProvider(
|
||||
selectedDownloadService,
|
||||
'tidal',
|
||||
)
|
||||
: false;
|
||||
final nativeWorkerAvailable = Platform.isAndroid && hasDownloadExtensions;
|
||||
|
||||
return PopScope(
|
||||
@@ -113,22 +105,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
.setAudioQuality(quality.id),
|
||||
showDivider: true,
|
||||
),
|
||||
if (usesTidalCompatibilityOptions &&
|
||||
settings.audioQuality == 'HIGH')
|
||||
SettingsItem(
|
||||
icon: Icons.tune,
|
||||
title: context.l10n.downloadLossyFormat,
|
||||
subtitle: _getLossyCompatibilityFormatLabel(
|
||||
context,
|
||||
settings.tidalHighFormat,
|
||||
),
|
||||
onTap: () => _showLossyCompatibilityFormatPicker(
|
||||
context,
|
||||
ref,
|
||||
settings.tidalHighFormat,
|
||||
),
|
||||
showDivider: true,
|
||||
),
|
||||
],
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.auto_fix_high_outlined,
|
||||
@@ -391,8 +367,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
return context.l10n.qualityHiResFlac;
|
||||
case 'HI_RES_LOSSLESS':
|
||||
return context.l10n.qualityHiResFlacMax;
|
||||
case 'HIGH':
|
||||
return context.l10n.downloadLossy320;
|
||||
default:
|
||||
return quality.label;
|
||||
}
|
||||
@@ -409,130 +383,11 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
return context.l10n.qualityHiResFlacSubtitle;
|
||||
case 'HI_RES_LOSSLESS':
|
||||
return context.l10n.qualityHiResFlacMaxSubtitle;
|
||||
case 'HIGH':
|
||||
return _getLossyCompatibilityFormatLabel(
|
||||
context,
|
||||
ref.read(settingsProvider).tidalHighFormat,
|
||||
);
|
||||
default:
|
||||
return quality.description ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
String _getLossyCompatibilityFormatLabel(
|
||||
BuildContext context,
|
||||
String format,
|
||||
) {
|
||||
switch (format) {
|
||||
case 'mp3_320':
|
||||
return context.l10n.downloadLossyMp3;
|
||||
case 'aac_320':
|
||||
return context.l10n.downloadLossyAac;
|
||||
case 'opus_256':
|
||||
return context.l10n.downloadLossyOpus256;
|
||||
case 'opus_128':
|
||||
return context.l10n.downloadLossyOpus128;
|
||||
default:
|
||||
return context.l10n.downloadLossyMp3;
|
||||
}
|
||||
}
|
||||
|
||||
void _showLossyCompatibilityFormatPicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String current,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
context.l10n.downloadLossy320Format,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Text(
|
||||
context.l10n.downloadLossy320FormatDesc,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.audiotrack),
|
||||
title: Text(context.l10n.downloadLossyMp3),
|
||||
subtitle: Text(context.l10n.downloadLossyMp3Subtitle),
|
||||
trailing: current == 'mp3_320'
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setTidalHighFormat('mp3_320');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.album_outlined),
|
||||
title: Text(context.l10n.downloadLossyAac),
|
||||
subtitle: Text(context.l10n.downloadLossyAacSubtitle),
|
||||
trailing: current == 'aac_320'
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setTidalHighFormat('aac_320');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.graphic_eq),
|
||||
title: Text(context.l10n.downloadLossyOpus256),
|
||||
subtitle: Text(context.l10n.downloadLossyOpus256Subtitle),
|
||||
trailing: current == 'opus_256'
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setTidalHighFormat('opus_256');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.graphic_eq),
|
||||
title: Text(context.l10n.downloadLossyOpus128),
|
||||
subtitle: Text(context.l10n.downloadLossyOpus128Subtitle),
|
||||
trailing: current == 'opus_128'
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setTidalHighFormat('opus_128');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAutoConvertFormatPicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
|
||||
@@ -21,7 +21,6 @@ class DownloadRequestPayload {
|
||||
final bool embedLyrics;
|
||||
final bool embedReplayGain;
|
||||
final bool postProcessingEnabled;
|
||||
final String tidalHighFormat;
|
||||
final bool autoConvertDownloads;
|
||||
final String autoConvertFormat;
|
||||
final String autoConvertBitrate;
|
||||
@@ -84,7 +83,6 @@ class DownloadRequestPayload {
|
||||
this.embedLyrics = true,
|
||||
this.embedReplayGain = false,
|
||||
this.postProcessingEnabled = false,
|
||||
this.tidalHighFormat = 'mp3_320',
|
||||
this.autoConvertDownloads = false,
|
||||
this.autoConvertFormat = 'mp3',
|
||||
this.autoConvertBitrate = '320k',
|
||||
@@ -149,7 +147,6 @@ class DownloadRequestPayload {
|
||||
'embed_lyrics': embedLyrics,
|
||||
'embed_replaygain': embedReplayGain,
|
||||
'post_processing_enabled': postProcessingEnabled,
|
||||
'tidal_high_format': tidalHighFormat,
|
||||
'auto_convert_downloads': autoConvertDownloads,
|
||||
'auto_convert_format': autoConvertFormat,
|
||||
'auto_convert_bitrate': autoConvertBitrate,
|
||||
@@ -218,7 +215,6 @@ class DownloadRequestPayload {
|
||||
embedLyrics: embedLyrics,
|
||||
embedReplayGain: embedReplayGain,
|
||||
postProcessingEnabled: postProcessingEnabled,
|
||||
tidalHighFormat: tidalHighFormat,
|
||||
autoConvertDownloads: autoConvertDownloads,
|
||||
autoConvertFormat: autoConvertFormat,
|
||||
autoConvertBitrate: autoConvertBitrate,
|
||||
|
||||
@@ -717,60 +717,6 @@ class FFmpegService {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String?> convertM4aToLossy(
|
||||
String inputPath, {
|
||||
required String format,
|
||||
String? bitrate,
|
||||
bool deleteOriginal = true,
|
||||
}) async {
|
||||
final normalizedFormat = format.toLowerCase();
|
||||
String bitrateValue = normalizedFormat == 'opus' ? '128k' : '320k';
|
||||
if (bitrate != null && bitrate.contains('_')) {
|
||||
final parts = bitrate.split('_');
|
||||
if (parts.length == 2) {
|
||||
bitrateValue = '${parts[1]}k';
|
||||
}
|
||||
}
|
||||
|
||||
final extension = switch (normalizedFormat) {
|
||||
'opus' => '.opus',
|
||||
'aac' || 'm4a' => '.m4a',
|
||||
_ => '.mp3',
|
||||
};
|
||||
final outputPlan = await _conversionOutputPlan(
|
||||
inputPath,
|
||||
extension,
|
||||
deleteOriginal: deleteOriginal,
|
||||
);
|
||||
final outputPath = outputPlan.workingPath;
|
||||
|
||||
String command;
|
||||
if (normalizedFormat == 'opus') {
|
||||
command =
|
||||
'-v error -hide_banner -i "$inputPath" -codec:a libopus -b:a $bitrateValue -vbr on -compression_level 10 -map 0:a "$outputPath" -y';
|
||||
} else if (normalizedFormat == 'aac' || normalizedFormat == 'm4a') {
|
||||
command =
|
||||
'-v error -hide_banner -i "$inputPath" -codec:a aac -b:a $bitrateValue -map 0:a -f mp4 "$outputPath" -y';
|
||||
} else {
|
||||
command =
|
||||
'-v error -hide_banner -i "$inputPath" -codec:a libmp3lame -b:a $bitrateValue -map 0:a -id3v2_version 3 "$outputPath" -y';
|
||||
}
|
||||
|
||||
final result = await _execute(command);
|
||||
|
||||
if (result.success) {
|
||||
return _finalizeConversionOutput(
|
||||
plan: outputPlan,
|
||||
inputPath: inputPath,
|
||||
deleteOriginal: deleteOriginal,
|
||||
);
|
||||
}
|
||||
|
||||
_log.e('M4A to $normalizedFormat conversion failed: ${result.output}');
|
||||
await _cleanupConversionOutput(outputPlan);
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<String?> decryptWithDescriptor({
|
||||
required String inputPath,
|
||||
required DownloadDecryptionDescriptor descriptor,
|
||||
|
||||
@@ -304,23 +304,6 @@ String resolveQualityVariantFilename({
|
||||
);
|
||||
}
|
||||
|
||||
String lossyFormatForSetting(String value) {
|
||||
final normalized = value.trim().toLowerCase();
|
||||
if (normalized.startsWith('opus')) return 'opus';
|
||||
if (normalized.startsWith('aac') || normalized.startsWith('m4a')) {
|
||||
return 'aac';
|
||||
}
|
||||
return 'mp3';
|
||||
}
|
||||
|
||||
String lossyExtensionForFormat(String format) {
|
||||
return switch (format) {
|
||||
'opus' => '.opus',
|
||||
'aac' => '.m4a',
|
||||
_ => '.mp3',
|
||||
};
|
||||
}
|
||||
|
||||
String metadataFormatForLossyFormat(String format) {
|
||||
return format == 'aac' ? 'm4a' : format;
|
||||
}
|
||||
@@ -348,15 +331,6 @@ int autoConvertBitrateKbps(String value) {
|
||||
return int.parse(normalizeAutoConvertBitrate(value).replaceAll('k', ''));
|
||||
}
|
||||
|
||||
String autoConvertLossySetting({
|
||||
required String format,
|
||||
required String bitrate,
|
||||
}) {
|
||||
final normalizedFormat = normalizeAutoConvertFormat(format);
|
||||
final normalizedBitrate = autoConvertBitrateKbps(bitrate);
|
||||
return '${normalizedFormat}_$normalizedBitrate';
|
||||
}
|
||||
|
||||
String autoConvertFormatLabel(String format) {
|
||||
return switch (normalizeAutoConvertFormat(format)) {
|
||||
'aac' => 'M4A (AAC)',
|
||||
|
||||
Reference in New Issue
Block a user