mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-13 21:38:58 +02:00
fix(download): compare size estimates at each quality tier
This commit is contained in:
@@ -173,15 +173,19 @@ for custom IDs without resolving streams or triggering verification:
|
||||
- For compressed lossless audio, supply `bitDepth` and `sampleRate` in Hz.
|
||||
`channels` defaults to 2. Do not use this model for uncompressed PCM.
|
||||
- Set `isMaximum: true` for a lossless tier that can return lower quality. Its
|
||||
estimate spans CD quality through the declared maximum. A capped bitrate
|
||||
alone cannot supply a useful range and is shown as unavailable.
|
||||
estimate uses the declared depth/rate and explicitly labels that assumption
|
||||
(for example, “≈ 171.4 MB if 24-bit/192kHz”). It does not predict which quality
|
||||
the provider will actually return. A capped bitrate alone remains unavailable.
|
||||
- Omitted parameters remain unknown. Older extensions retain estimates for
|
||||
the picker's legacy `LOSSLESS`, `HI_RES`, and `HI_RES_LOSSLESS` tiers and
|
||||
explicit codec/bitrate IDs or labels such as `opus_256` or `Opus 256kbps`.
|
||||
Generic `best`, `high`, `low`, and spatial tiers need explicit parameters.
|
||||
|
||||
Lossy estimates use duration × bitrate / 8. Lossless estimates use a rough
|
||||
50–80% of uncompressed PCM size; this is a heuristic, not a guaranteed range.
|
||||
65% of uncompressed PCM size at the declared quality. This is a comparison
|
||||
heuristic, not measured compression for the recording or a guaranteed size.
|
||||
Do not combine a CD-quality minimum with a hi-res maximum: that range hides
|
||||
the distinction between tiers and provides little useful size information.
|
||||
Estimates exclude artwork, tags, and container overhead. They describe the
|
||||
selected quality before automatic conversion, with a separate converted-size
|
||||
estimate when enabled. Fallback quality and intermediate transfers can change
|
||||
|
||||
+14
-2
@@ -2129,7 +2129,7 @@
|
||||
},
|
||||
"downloadEstimatedSize": "≈ {size}",
|
||||
"@downloadEstimatedSize": {
|
||||
"description": "Approximate audio file size, or total for all selected tracks; size includes units and may be a range",
|
||||
"description": "Approximate audio file size, or total for all selected tracks; size includes units",
|
||||
"placeholders": {
|
||||
"size": {
|
||||
"type": "String"
|
||||
@@ -2137,7 +2137,19 @@
|
||||
}
|
||||
},
|
||||
"downloadSizeUnavailable": "Size estimate unavailable",
|
||||
"downloadSizeEstimateNote": "Estimated audio size before conversion, excluding artwork and tags. Actual size varies with quality and compression; data usage may be higher.",
|
||||
"downloadEstimatedSizeAtQuality": "≈ {size} if {quality}",
|
||||
"@downloadEstimatedSizeAtQuality": {
|
||||
"description": "Conditional size estimate for a tier with an unknown actual quality; quality is the assumed bit depth and sample rate",
|
||||
"placeholders": {
|
||||
"size": {
|
||||
"type": "String"
|
||||
},
|
||||
"quality": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadSizeEstimateNote": "Estimates assume the quality shown. Lower quality can produce smaller files; compression also affects size. Excludes artwork and tags. Data usage may be higher.",
|
||||
"downloadConvertedSizeEstimate": "After conversion to {format}: {size}",
|
||||
"@downloadConvertedSizeEstimate": {
|
||||
"description": "Estimated size after the user's automatic conversion, excluding tags and artwork",
|
||||
|
||||
@@ -6200,6 +6200,7 @@
|
||||
"libraryFilterMetadataMissingIsrc": "Missing ISRC",
|
||||
"downloadEstimatedSize": "≈ {size}",
|
||||
"downloadSizeUnavailable": "Estimasi ukuran belum tersedia",
|
||||
"downloadSizeEstimateNote": "Perkiraan ukuran audio sebelum konversi, tanpa sampul dan tag. Ukuran sebenarnya bergantung pada kualitas dan kompresi; penggunaan data bisa lebih besar.",
|
||||
"downloadEstimatedSizeAtQuality": "≈ {size} jika {quality}",
|
||||
"downloadSizeEstimateNote": "Estimasi memakai kualitas yang tertera. Kualitas lebih rendah bisa menghasilkan file lebih kecil; kompresi juga memengaruhi ukuran. Tanpa sampul dan tag. Penggunaan data bisa lebih besar.",
|
||||
"downloadConvertedSizeEstimate": "Setelah konversi ke {format}: {size}"
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
|
||||
class DownloadSizeEstimate {
|
||||
final int minBytes;
|
||||
final int maxBytes;
|
||||
final int bytes;
|
||||
final int? assumedBitDepth;
|
||||
final int? assumedSampleRate;
|
||||
|
||||
const DownloadSizeEstimate({required this.minBytes, required this.maxBytes});
|
||||
const DownloadSizeEstimate({
|
||||
required this.bytes,
|
||||
this.assumedBitDepth,
|
||||
this.assumedSampleRate,
|
||||
});
|
||||
}
|
||||
|
||||
/// Unknown durations must not make a batch estimate look like a complete total.
|
||||
@@ -32,7 +35,7 @@ DownloadSizeEstimate? estimateDownloadSize({
|
||||
if (bitrate != null) {
|
||||
if (bitrate <= 0 || parameters.isMaximum) return null;
|
||||
final bytes = (seconds * bitrate * 1000 / 8).round();
|
||||
return DownloadSizeEstimate(minBytes: bytes, maxBytes: bytes);
|
||||
return DownloadSizeEstimate(bytes: bytes);
|
||||
}
|
||||
|
||||
final depth = parameters.bitDepth;
|
||||
@@ -44,15 +47,15 @@ DownloadSizeEstimate? estimateDownloadSize({
|
||||
parameters.channels <= 0) {
|
||||
return null;
|
||||
}
|
||||
final minDepth = parameters.isMaximum ? math.min(depth, 16) : depth;
|
||||
final minRate = parameters.isMaximum ? math.min(rate, 44100) : rate;
|
||||
// A rough compressed-lossless range (50–80% of PCM), not a bound or a
|
||||
// guarantee. Capped tiers can deliver CD quality instead of their maximum.
|
||||
// Use one comparison estimate at the selected tier, assuming 65% of PCM.
|
||||
// This is a heuristic, not a measurement of this recording's compression.
|
||||
// For capped tiers the UI must show the assumed depth/rate: a provider can
|
||||
// return lower quality, so the tier's maximum is not the track's actual size.
|
||||
// Artwork, tags, container overhead and later conversion are excluded.
|
||||
return DownloadSizeEstimate(
|
||||
minBytes: (seconds * minDepth * minRate * parameters.channels / 8 * 0.5)
|
||||
.round(),
|
||||
maxBytes: (seconds * depth * rate * parameters.channels / 8 * 0.8).round(),
|
||||
bytes: (seconds * depth * rate * parameters.channels / 8 * 0.65).round(),
|
||||
assumedBitDepth: parameters.isMaximum ? depth : null,
|
||||
assumedSampleRate: parameters.isMaximum ? rate : null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -260,9 +260,15 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
|
||||
String _sizeLabel(BuildContext context, DownloadSizeEstimate? estimate) {
|
||||
if (estimate == null) return context.l10n.downloadSizeUnavailable;
|
||||
final size = estimate.minBytes == estimate.maxBytes
|
||||
? formatBytes(estimate.maxBytes)
|
||||
: '${formatBytes(estimate.minBytes)}–${formatBytes(estimate.maxBytes)}';
|
||||
final size = formatBytes(estimate.bytes);
|
||||
final depth = estimate.assumedBitDepth;
|
||||
final rate = estimate.assumedSampleRate;
|
||||
if (depth != null && rate != null) {
|
||||
return context.l10n.downloadEstimatedSizeAtQuality(
|
||||
size,
|
||||
'$depth-bit/${formatSampleRateKHz(rate)}',
|
||||
);
|
||||
}
|
||||
return context.l10n.downloadEstimatedSize(size);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ class _PickerExtensions extends ExtensionNotifier {
|
||||
hasDownloadProvider: true,
|
||||
qualityOptions: [
|
||||
QualityOption(id: 'LOSSLESS', label: 'Lossless'),
|
||||
QualityOption(id: 'HI_RES', label: 'Hi-Res'),
|
||||
QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res'),
|
||||
],
|
||||
),
|
||||
@@ -106,13 +107,16 @@ void main() {
|
||||
duration: const Duration(minutes: 4),
|
||||
onSelect: (quality, service) => selected = (quality, service),
|
||||
);
|
||||
expect(find.text('≈ 20.2 MB–32.3 MB'), findsOneWidget);
|
||||
expect(find.text('≈ 20.2 MB–210.9 MB'), findsOneWidget);
|
||||
expect(find.text('≈ 26.2 MB'), findsOneWidget);
|
||||
expect(find.text('≈ 85.7 MB if 24-bit/96kHz'), findsOneWidget);
|
||||
expect(find.text('≈ 171.4 MB if 24-bit/192kHz'), findsOneWidget);
|
||||
expect(find.textContaining('MB–'), findsNothing);
|
||||
await tester.tap(find.text('Audio B'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('≈ 7.3 MB'), findsOneWidget);
|
||||
expect(find.text('Size estimate unavailable'), findsOneWidget);
|
||||
expect(find.text('≈ 20.2 MB–32.3 MB'), findsNothing);
|
||||
expect(find.text('≈ 26.2 MB'), findsNothing);
|
||||
expect(find.textContaining('if 24-bit'), findsNothing);
|
||||
await tester.tap(find.text('Opus 256kbps'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(selected, ('opus_256', 'provider-b'));
|
||||
@@ -123,7 +127,7 @@ void main() {
|
||||
'missing duration stays unknown and downloads remain selectable',
|
||||
(tester) async {
|
||||
await _openPicker(tester, locale: const Locale('id'));
|
||||
expect(find.text('Estimasi ukuran belum tersedia'), findsNWidgets(2));
|
||||
expect(find.text('Estimasi ukuran belum tersedia'), findsNWidgets(3));
|
||||
expect(find.textContaining('≈'), findsNothing);
|
||||
await tester.tap(find.text('FLAC Lossless'));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -131,6 +135,18 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('capped estimates show their quality assumption in Indonesian', (
|
||||
tester,
|
||||
) async {
|
||||
await _openPicker(
|
||||
tester,
|
||||
duration: const Duration(minutes: 4),
|
||||
locale: const Locale('id'),
|
||||
);
|
||||
expect(find.text('≈ 85.7 MB jika 24-bit/96kHz'), findsOneWidget);
|
||||
expect(find.text('≈ 171.4 MB jika 24-bit/192kHz'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'conversion estimate is separate and fits narrow, enlarged text',
|
||||
(tester) async {
|
||||
@@ -148,7 +164,7 @@ void main() {
|
||||
autoConvertBitrate: '256k',
|
||||
),
|
||||
);
|
||||
expect(find.text('≈ 20.2 MB–32.3 MB'), findsOneWidget);
|
||||
expect(find.text('≈ 26.2 MB'), findsOneWidget);
|
||||
final conversionNote = find.textContaining(
|
||||
'After conversion to OPUS: ≈ 7.3 MB',
|
||||
);
|
||||
|
||||
@@ -29,38 +29,37 @@ void main() {
|
||||
duration: duration,
|
||||
quality: quality,
|
||||
)!;
|
||||
expect(estimate.minBytes, 7680000);
|
||||
expect(estimate.maxBytes, 7680000);
|
||||
expect(estimate.bytes, 7680000);
|
||||
expect(estimate.assumedBitDepth, isNull);
|
||||
expect(estimate.assumedSampleRate, isNull);
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
'lossless range scales with duration and includes lower capped tiers',
|
||||
() {
|
||||
const cd = QualityOption(id: 'LOSSLESS', label: 'Lossless');
|
||||
final cdSize = estimateDownloadSize(duration: duration, quality: cd)!;
|
||||
expect(cdSize.minBytes, 21168000);
|
||||
expect(cdSize.maxBytes, 33868800);
|
||||
final batchSize = estimateDownloadSize(
|
||||
duration: totalDownloadDuration([_track(240), _track(240)]),
|
||||
quality: cd,
|
||||
)!;
|
||||
expect(batchSize.minBytes, cdSize.minBytes * 2);
|
||||
expect(batchSize.maxBytes, cdSize.maxBytes * 2);
|
||||
test('lossless estimates compare each tier at its own quality', () {
|
||||
const cd = QualityOption(id: 'LOSSLESS', label: 'Lossless');
|
||||
final cdSize = estimateDownloadSize(duration: duration, quality: cd)!;
|
||||
expect(cdSize.bytes, 27518400);
|
||||
expect(cdSize.assumedSampleRate, isNull);
|
||||
final batchSize = estimateDownloadSize(
|
||||
duration: totalDownloadDuration([_track(240), _track(240)]),
|
||||
quality: cd,
|
||||
)!;
|
||||
expect(batchSize.bytes, cdSize.bytes * 2);
|
||||
|
||||
for (final (id, maximum) in [
|
||||
('HI_RES', 110592000),
|
||||
('HI_RES_LOSSLESS', 221184000),
|
||||
]) {
|
||||
final size = estimateDownloadSize(
|
||||
duration: duration,
|
||||
quality: QualityOption(id: id, label: ''),
|
||||
)!;
|
||||
expect(size.minBytes, cdSize.minBytes);
|
||||
expect(size.maxBytes, maximum);
|
||||
}
|
||||
},
|
||||
);
|
||||
for (final (id, rate, bytes) in [
|
||||
('HI_RES', 96000, 89856000),
|
||||
('HI_RES_LOSSLESS', 192000, 179712000),
|
||||
]) {
|
||||
final size = estimateDownloadSize(
|
||||
duration: duration,
|
||||
quality: QualityOption(id: id, label: ''),
|
||||
)!;
|
||||
expect(size.bytes, bytes);
|
||||
expect(size.bytes, greaterThan(cdSize.bytes));
|
||||
expect(size.assumedBitDepth, 24);
|
||||
expect(size.assumedSampleRate, rate);
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
'explicit parameters override legacy assumptions and preserve channels',
|
||||
@@ -74,8 +73,9 @@ void main() {
|
||||
duration: duration,
|
||||
quality: quality,
|
||||
)!;
|
||||
expect(estimate.minBytes, 17280000);
|
||||
expect(estimate.maxBytes, 27648000);
|
||||
expect(estimate.bytes, 22464000);
|
||||
expect(estimate.assumedBitDepth, isNull);
|
||||
expect(estimate.assumedSampleRate, isNull);
|
||||
final capped = estimateDownloadSize(
|
||||
duration: duration,
|
||||
quality: QualityOption.fromJson({
|
||||
@@ -88,8 +88,9 @@ void main() {
|
||||
},
|
||||
}),
|
||||
)!;
|
||||
expect(capped.minBytes, 21168000);
|
||||
expect(capped.maxBytes, 221184000);
|
||||
expect(capped.bytes, 179712000);
|
||||
expect(capped.assumedBitDepth, 24);
|
||||
expect(capped.assumedSampleRate, 192000);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user