mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-29 07:18:49 +02:00
The local/downloaded album screens had drifted copies of the album header, selection bottom bar, disc chip, batch convert/ReplayGain trio, and assorted small helpers. All now delegate to the shared widgets, with the online album screen's design as the reference, and both album screens run batch actions through the queue_tab engine via UnifiedLibraryItem (strict-superset implementation covering both DB writebacks and SAF paths). Also folds the remaining per-screen helper copies (cover URL, error card, byte/clock formatting, readPositiveInt) into their shared homes. Intentional deltas: selection-bar strings follow queue_tab's l10n keys, both providers reload after a conversion, and audio-analysis durations round instead of floor.
338 lines
8.9 KiB
Dart
338 lines
8.9 KiB
Dart
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
|
|
|
const List<String> audioConversionTargetFormats = [
|
|
'ALAC',
|
|
'FLAC',
|
|
'WAV',
|
|
'AIFF',
|
|
'AAC',
|
|
'MP3',
|
|
'Opus',
|
|
];
|
|
|
|
const List<int> losslessConversionSampleRateOptions = [
|
|
192000,
|
|
96000,
|
|
48000,
|
|
44100,
|
|
];
|
|
|
|
const List<int> losslessConversionBitDepthOptions = [16, 24];
|
|
|
|
const List<String> losslessDitherOptions = [
|
|
'none',
|
|
'triangular',
|
|
'triangular_hp',
|
|
];
|
|
|
|
const List<String> losslessResamplerOptions = ['swr', 'soxr'];
|
|
|
|
class LosslessConversionProcessing {
|
|
final String dither;
|
|
final String resampler;
|
|
|
|
const LosslessConversionProcessing({
|
|
this.dither = 'none',
|
|
this.resampler = 'swr',
|
|
});
|
|
|
|
String get normalizedDither {
|
|
final normalized = dither.trim().toLowerCase();
|
|
return losslessDitherOptions.contains(normalized) ? normalized : 'none';
|
|
}
|
|
|
|
String get normalizedResampler {
|
|
final normalized = resampler.trim().toLowerCase();
|
|
return losslessResamplerOptions.contains(normalized) ? normalized : 'swr';
|
|
}
|
|
|
|
bool get hasDither => normalizedDither != 'none';
|
|
}
|
|
|
|
List<int> availableLosslessBitDepthOptions(int? sourceBitDepth) {
|
|
if (sourceBitDepth == null || sourceBitDepth <= 0) {
|
|
return losslessConversionBitDepthOptions;
|
|
}
|
|
return losslessConversionBitDepthOptions
|
|
.where((depth) => depth < sourceBitDepth)
|
|
.toList();
|
|
}
|
|
|
|
List<int> availableLosslessSampleRateOptions(int? sourceSampleRate) {
|
|
if (sourceSampleRate == null || sourceSampleRate <= 0) {
|
|
return losslessConversionSampleRateOptions;
|
|
}
|
|
return losslessConversionSampleRateOptions
|
|
.where((rate) => rate < sourceSampleRate)
|
|
.toList();
|
|
}
|
|
|
|
int? lowestKnownPositiveInt(Iterable<int?> values) {
|
|
int? lowest;
|
|
for (final value in values) {
|
|
if (value == null || value <= 0) continue;
|
|
if (lowest == null || value < lowest) {
|
|
lowest = value;
|
|
}
|
|
}
|
|
return lowest;
|
|
}
|
|
|
|
class LosslessConversionQuality {
|
|
final int? maxBitDepth;
|
|
final int? maxSampleRate;
|
|
|
|
const LosslessConversionQuality({this.maxBitDepth, this.maxSampleRate});
|
|
|
|
bool get hasCaps => maxBitDepth != null || maxSampleRate != null;
|
|
|
|
int? effectiveBitDepth(int? sourceBitDepth) {
|
|
if (maxBitDepth == null) return sourceBitDepth;
|
|
if (sourceBitDepth == null || sourceBitDepth <= 0) return maxBitDepth;
|
|
return sourceBitDepth > maxBitDepth! ? maxBitDepth : sourceBitDepth;
|
|
}
|
|
|
|
int? effectiveSampleRate(int? sourceSampleRate) {
|
|
if (maxSampleRate == null) return sourceSampleRate;
|
|
if (sourceSampleRate == null || sourceSampleRate <= 0) {
|
|
return maxSampleRate;
|
|
}
|
|
return sourceSampleRate > maxSampleRate! ? maxSampleRate : sourceSampleRate;
|
|
}
|
|
}
|
|
|
|
bool isLosslessConversionTarget(String targetFormat) {
|
|
final normalized = targetFormat.trim().toLowerCase();
|
|
return normalized == 'alac' ||
|
|
normalized == 'flac' ||
|
|
normalized == 'wav' ||
|
|
normalized == 'aiff' ||
|
|
normalized == 'aif';
|
|
}
|
|
|
|
bool isLosslessConversionSource(String sourceFormat) {
|
|
switch (sourceFormat.trim().toUpperCase()) {
|
|
case 'FLAC':
|
|
case 'ALAC':
|
|
case 'M4A':
|
|
case 'WAV':
|
|
case 'AIFF':
|
|
case 'AIF':
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
bool canConvertAudioFormat({
|
|
required String sourceFormat,
|
|
required String targetFormat,
|
|
}) {
|
|
if (sourceFormat.trim().toUpperCase() == targetFormat.trim().toUpperCase()) {
|
|
return false;
|
|
}
|
|
if (isLosslessConversionTarget(targetFormat) &&
|
|
!isLosslessConversionSource(sourceFormat)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
String? convertibleAudioSourceFormat({
|
|
String? storedFormat,
|
|
String? filePath,
|
|
String? fileName,
|
|
}) {
|
|
final fromStored = _convertibleAudioFormatLabel(storedFormat);
|
|
if (fromStored != null) return fromStored;
|
|
|
|
final name = (fileName != null && fileName.trim().isNotEmpty)
|
|
? fileName
|
|
: filePath;
|
|
if (name == null || name.trim().isEmpty) return null;
|
|
|
|
final normalizedName = name.trim().toLowerCase();
|
|
final dotIndex = normalizedName.lastIndexOf('.');
|
|
if (dotIndex < 0 || dotIndex == normalizedName.length - 1) {
|
|
return null;
|
|
}
|
|
return _convertibleAudioFormatLabel(normalizedName.substring(dotIndex + 1));
|
|
}
|
|
|
|
String? _convertibleAudioFormatLabel(String? rawFormat) {
|
|
final format = rawFormat?.trim().toLowerCase();
|
|
if (format == null || format.isEmpty) return null;
|
|
|
|
switch (format) {
|
|
case 'flac':
|
|
return 'FLAC';
|
|
case 'alac':
|
|
return 'ALAC';
|
|
case 'wav':
|
|
case 'wave':
|
|
return 'WAV';
|
|
case 'aiff':
|
|
case 'aif':
|
|
case 'aifc':
|
|
return 'AIFF';
|
|
case 'm4a':
|
|
case 'mp4':
|
|
return 'M4A';
|
|
case 'aac':
|
|
case 'mp4a':
|
|
return 'AAC';
|
|
case 'mp3':
|
|
return 'MP3';
|
|
case 'opus':
|
|
case 'ogg':
|
|
return 'Opus';
|
|
case 'eac3':
|
|
case 'ec-3':
|
|
return 'EAC3';
|
|
case 'ac3':
|
|
case 'ac-3':
|
|
return 'AC3';
|
|
case 'ac4':
|
|
case 'ac-4':
|
|
return 'AC4';
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
class LosslessConversionLabels {
|
|
final String original;
|
|
final String originalQuality;
|
|
final String lossless;
|
|
|
|
const LosslessConversionLabels({
|
|
required this.original,
|
|
required this.originalQuality,
|
|
required this.lossless,
|
|
});
|
|
}
|
|
|
|
extension LosslessConversionLabelsL10n on AppLocalizations {
|
|
LosslessConversionLabels get losslessConversionLabels =>
|
|
LosslessConversionLabels(
|
|
original: trackConvertOriginal,
|
|
originalQuality: trackConvertOriginalQuality,
|
|
lossless: trackConvertLosslessSuffix,
|
|
);
|
|
|
|
String losslessDitherOptionLabel(String dither) {
|
|
switch (dither.trim().toLowerCase()) {
|
|
case 'triangular':
|
|
return trackConvertDitherTriangular;
|
|
case 'triangular_hp':
|
|
return trackConvertDitherTriangularHp;
|
|
default:
|
|
return trackConvertDitherNone;
|
|
}
|
|
}
|
|
|
|
String losslessResamplerOptionLabel(String resampler) {
|
|
switch (resampler.trim().toLowerCase()) {
|
|
case 'soxr':
|
|
return trackConvertResamplerSoxr;
|
|
default:
|
|
return trackConvertResamplerSwr;
|
|
}
|
|
}
|
|
}
|
|
|
|
String losslessBitDepthLabel(int? bitDepth, {required String originalLabel}) {
|
|
return bitDepth == null ? originalLabel : '$bitDepth-bit';
|
|
}
|
|
|
|
String losslessSampleRateLabel(
|
|
int? sampleRate, {
|
|
required String originalLabel,
|
|
}) {
|
|
if (sampleRate == null) return originalLabel;
|
|
final khz = sampleRate / 1000;
|
|
final precision = sampleRate % 1000 == 0 ? 0 : 1;
|
|
return '${khz.toStringAsFixed(precision)} kHz';
|
|
}
|
|
|
|
String losslessQualityLabel(
|
|
LosslessConversionQuality quality, {
|
|
required String originalLabel,
|
|
required String originalQualityLabel,
|
|
}) {
|
|
final parts = <String>[];
|
|
if (quality.maxBitDepth != null) {
|
|
parts.add(
|
|
losslessBitDepthLabel(quality.maxBitDepth, originalLabel: originalLabel),
|
|
);
|
|
}
|
|
if (quality.maxSampleRate != null) {
|
|
parts.add(
|
|
losslessSampleRateLabel(
|
|
quality.maxSampleRate,
|
|
originalLabel: originalLabel,
|
|
),
|
|
);
|
|
}
|
|
return parts.isEmpty ? originalQualityLabel : parts.join(' / ');
|
|
}
|
|
|
|
String convertedAudioQualityLabel({
|
|
required String targetFormat,
|
|
required String bitrate,
|
|
required LosslessConversionLabels labels,
|
|
LosslessConversionQuality losslessQuality = const LosslessConversionQuality(),
|
|
int? actualBitDepth,
|
|
int? actualSampleRate,
|
|
}) {
|
|
final upper = targetFormat.toUpperCase();
|
|
if (isLosslessConversionTarget(targetFormat)) {
|
|
if (actualBitDepth != null &&
|
|
actualBitDepth > 0 &&
|
|
actualSampleRate != null &&
|
|
actualSampleRate > 0) {
|
|
return '$upper ${losslessBitDepthLabel(actualBitDepth, originalLabel: labels.original)}/${losslessSampleRateLabel(actualSampleRate, originalLabel: labels.original)}';
|
|
}
|
|
if (losslessQuality.hasCaps) {
|
|
return '$upper ${losslessQualityLabel(losslessQuality, originalLabel: labels.original, originalQualityLabel: labels.originalQuality)}';
|
|
}
|
|
return '$upper ${labels.lossless}';
|
|
}
|
|
return '$upper ${bitrate.trim().toLowerCase()}';
|
|
}
|
|
|
|
String normalizedConvertedAudioFormat(String targetFormat) {
|
|
return targetFormat.trim().toLowerCase();
|
|
}
|
|
|
|
/// Returns the output file extension (with dot) and MIME type for a conversion
|
|
/// target format. Used when creating the converted file via SAF so WAV/AIFF and
|
|
/// the other formats get the correct extension + MIME.
|
|
({String ext, String mime}) convertTargetExtAndMime(String targetFormat) {
|
|
switch (targetFormat.trim().toLowerCase()) {
|
|
case 'opus':
|
|
return (ext: '.opus', mime: 'audio/opus');
|
|
case 'alac':
|
|
case 'aac':
|
|
return (ext: '.m4a', mime: 'audio/mp4');
|
|
case 'flac':
|
|
return (ext: '.flac', mime: 'audio/flac');
|
|
case 'wav':
|
|
return (ext: '.wav', mime: 'audio/wav');
|
|
case 'aiff':
|
|
case 'aif':
|
|
return (ext: '.aiff', mime: 'audio/aiff');
|
|
default:
|
|
return (ext: '.mp3', mime: 'audio/mpeg');
|
|
}
|
|
}
|
|
|
|
int? convertedAudioBitrateKbps({
|
|
required String targetFormat,
|
|
required String bitrate,
|
|
}) {
|
|
if (isLosslessConversionTarget(targetFormat)) return null;
|
|
final match = RegExp(r'(\d+)').firstMatch(bitrate);
|
|
return match != null ? int.tryParse(match.group(1)!) : null;
|
|
}
|