From ba00807786d1335538afd8bb5eee3dd76f78b918 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 6 Sep 2026 21:55:38 +0700 Subject: [PATCH] fix(download): compare size estimates at each quality tier --- docs/EXTENSION_DEVELOPMENT.md | 10 ++-- lib/l10n/arb/app_en.arb | 16 +++++- lib/l10n/arb/app_id.arb | 3 +- lib/utils/download_size_estimate.dart | 29 ++++++----- lib/widgets/download_service_picker.dart | 12 +++-- test/download_service_picker_test.dart | 26 ++++++++-- test/download_size_estimate_test.dart | 65 ++++++++++++------------ 7 files changed, 102 insertions(+), 59 deletions(-) diff --git a/docs/EXTENSION_DEVELOPMENT.md b/docs/EXTENSION_DEVELOPMENT.md index 7ff4f263..0a15ebdb 100644 --- a/docs/EXTENSION_DEVELOPMENT.md +++ b/docs/EXTENSION_DEVELOPMENT.md @@ -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 diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 9e904360..d0766413 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -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", diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index c70754b1..618d9e09 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -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}" } diff --git a/lib/utils/download_size_estimate.dart b/lib/utils/download_size_estimate.dart index d8b961a8..1da31a4d 100644 --- a/lib/utils/download_size_estimate.dart +++ b/lib/utils/download_size_estimate.dart @@ -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, ); } diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index 49fe70cb..1fac4ba7 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -260,9 +260,15 @@ class _DownloadServicePickerState extends ConsumerState { 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); } diff --git a/test/download_service_picker_test.dart b/test/download_service_picker_test.dart index abb8f4b1..2ebfa985 100644 --- a/test/download_service_picker_test.dart +++ b/test/download_service_picker_test.dart @@ -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', ); diff --git a/test/download_size_estimate_test.dart b/test/download_size_estimate_test.dart index f9f7d1bf..0faad3cb 100644 --- a/test/download_size_estimate_test.dart +++ b/test/download_size_estimate_test.dart @@ -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); }, );