mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-16 14:55:27 +02:00
fix(download): estimate sizes from track quality metadata
This commit is contained in:
@@ -172,24 +172,27 @@ for custom IDs without resolving streams or triggering verification:
|
||||
For example, `"sizeEstimate": {"bitrateKbps": 256}`.
|
||||
- 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 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.
|
||||
- Set `isMaximum: true` for a lossless tier that can return lower quality.
|
||||
The picker caps the declared depth/rate using each track's `audio_quality`
|
||||
(for example, `16bit/44.1kHz` or `24bit/96kHz`). Metadata must belong to the
|
||||
selected provider; missing or incomplete quality stays unavailable.
|
||||
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.
|
||||
`best`, `default`, and `flac` options with `kind: "lossless"` can use track
|
||||
quality directly. Other generic and spatial tiers need explicit parameters.
|
||||
|
||||
Lossy estimates use duration × bitrate / 8. Lossless estimates use a rough
|
||||
65% of uncompressed PCM size at the declared quality. This is a comparison
|
||||
65% of uncompressed PCM size at the effective track 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
|
||||
both the final size and data usage. If any selected track lacks a duration,
|
||||
both the final size and data usage. Collection estimates sum each track's
|
||||
individual estimate. If any selected track lacks the required metadata,
|
||||
the picker does not show a partial sum as a complete total.
|
||||
|
||||
### Permissions
|
||||
|
||||
+1
-13
@@ -2137,19 +2137,7 @@
|
||||
}
|
||||
},
|
||||
"downloadSizeUnavailable": "Size estimate unavailable",
|
||||
"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.",
|
||||
"downloadSizeEstimateNote": "Lossless estimates use the track quality reported by the selected provider. Actual size depends on compression; artwork and tags are excluded. 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,7 +6200,6 @@
|
||||
"libraryFilterMetadataMissingIsrc": "Missing ISRC",
|
||||
"downloadEstimatedSize": "≈ {size}",
|
||||
"downloadSizeUnavailable": "Estimasi ukuran belum tersedia",
|
||||
"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.",
|
||||
"downloadSizeEstimateNote": "Estimasi lossless memakai kualitas lagu dari provider yang dipilih. Ukuran sebenarnya bergantung pada kompresi; tanpa sampul dan tag. Penggunaan data bisa lebih besar.",
|
||||
"downloadConvertedSizeEstimate": "Setelah konversi ke {format}: {size}"
|
||||
}
|
||||
|
||||
@@ -689,6 +689,7 @@ class PostProcessingHook {
|
||||
|
||||
class QualityOption {
|
||||
final String id;
|
||||
final String? kind;
|
||||
final String label;
|
||||
final String? description;
|
||||
final QualitySizeEstimate? sizeEstimate;
|
||||
@@ -696,6 +697,7 @@ class QualityOption {
|
||||
|
||||
const QualityOption({
|
||||
required this.id,
|
||||
this.kind,
|
||||
required this.label,
|
||||
this.description,
|
||||
this.sizeEstimate,
|
||||
@@ -705,6 +707,7 @@ class QualityOption {
|
||||
factory QualityOption.fromJson(Map<String, dynamic> json) {
|
||||
return QualityOption(
|
||||
id: json['id'] as String? ?? '',
|
||||
kind: json['kind'] as String?,
|
||||
label: json['label'] as String? ?? '',
|
||||
description: json['description'] as String?,
|
||||
sizeEstimate: json['sizeEstimate'] is Map<String, dynamic>
|
||||
|
||||
@@ -321,6 +321,9 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
|
||||
_albumTotalTracks,
|
||||
composer: data['composer']?.toString(),
|
||||
audioQuality: data['audio_quality']?.toString(),
|
||||
source:
|
||||
(data['source'] ?? data['provider_id'])?.toString() ??
|
||||
_directMetadataProviderId(),
|
||||
audioModes: data['audio_modes']?.toString(),
|
||||
previewUrl: data['preview_url']?.toString(),
|
||||
explicit: parseExplicitFlag(data['explicit']),
|
||||
|
||||
@@ -583,7 +583,7 @@ extension _ArtistScreenSections on _ArtistScreenState {
|
||||
if (settings.askQualityBeforeDownload || settings.allowQualityVariants) {
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
duration: Duration(seconds: track.duration),
|
||||
tracks: [track],
|
||||
recommendedService: _recommendedDownloadService(),
|
||||
onSelect: (quality, service) {
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -27,7 +27,6 @@ import 'package:spotiflac_android/services/cover_download_service.dart';
|
||||
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/adaptive_layout.dart';
|
||||
import 'package:spotiflac_android/utils/download_size_estimate.dart';
|
||||
import 'package:spotiflac_android/utils/extension_auth_launcher.dart';
|
||||
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
@@ -653,7 +652,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
coverUrl: track.coverUrl,
|
||||
duration: Duration(seconds: track.duration),
|
||||
tracks: [track],
|
||||
recommendedService:
|
||||
trackState.searchExtensionId ?? trackState.searchSource,
|
||||
onSelect: (quality, service) {
|
||||
|
||||
@@ -479,7 +479,7 @@ extension _HomeTabExploreUI on _HomeTabState {
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
coverUrl: track.coverUrl,
|
||||
duration: Duration(seconds: track.duration),
|
||||
tracks: [track],
|
||||
onSelect: (quality, service) {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
|
||||
@@ -197,7 +197,7 @@ extension _HomeTabCsvImport on _HomeTabState {
|
||||
this.context,
|
||||
trackName: l10n.csvImportTracks(tracksToQueue.length),
|
||||
artistName: l10n.dialogImportPlaylistTitle,
|
||||
duration: totalDownloadDuration(tracksToQueue),
|
||||
tracks: tracksToQueue,
|
||||
onSelect: (quality, service) {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
|
||||
@@ -16,7 +16,6 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/adaptive_layout.dart';
|
||||
import 'package:spotiflac_android/utils/audio_quality_badge_policy.dart';
|
||||
import 'package:spotiflac_android/utils/download_size_estimate.dart';
|
||||
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
|
||||
@@ -253,11 +253,9 @@ extension _QueueTabSelectionActions on _QueueTabState {
|
||||
context,
|
||||
trackName: context.l10n.tracksCount(totalTracks),
|
||||
artistName: context.l10n.playlistsCount(selectedPlaylists.length),
|
||||
duration: totalDownloadDuration(
|
||||
selectedPlaylists.expand(
|
||||
(playlist) => playlist.tracks.map((item) => item.track),
|
||||
),
|
||||
),
|
||||
tracks: selectedPlaylists
|
||||
.expand((playlist) => playlist.tracks.map((item) => item.track))
|
||||
.toList(),
|
||||
onSelect: (quality, service) {
|
||||
enqueueAll(qualityOverride: quality, service: service);
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
|
||||
class DownloadSizeEstimate {
|
||||
final int bytes;
|
||||
final int? assumedBitDepth;
|
||||
final int? assumedSampleRate;
|
||||
|
||||
const DownloadSizeEstimate({
|
||||
required this.bytes,
|
||||
this.assumedBitDepth,
|
||||
this.assumedSampleRate,
|
||||
});
|
||||
const DownloadSizeEstimate({required this.bytes});
|
||||
}
|
||||
|
||||
/// Unknown durations must not make a batch estimate look like a complete total.
|
||||
@@ -26,6 +22,8 @@ Duration? totalDownloadDuration(Iterable<Track> tracks) {
|
||||
DownloadSizeEstimate? estimateDownloadSize({
|
||||
required Duration? duration,
|
||||
required QualityOption quality,
|
||||
List<Track>? tracks,
|
||||
String? providerId,
|
||||
}) {
|
||||
if (duration == null || duration <= Duration.zero) return null;
|
||||
final parameters = quality.sizeEstimate ?? _legacyParameters(quality);
|
||||
@@ -38,6 +36,44 @@ DownloadSizeEstimate? estimateDownloadSize({
|
||||
return DownloadSizeEstimate(bytes: bytes);
|
||||
}
|
||||
|
||||
if (tracks != null) {
|
||||
if (tracks.isEmpty || providerId == null || providerId.isEmpty) return null;
|
||||
var bytes = 0;
|
||||
for (final track in tracks) {
|
||||
// Quality from a metadata provider is not evidence for another catalog.
|
||||
if (track.source != providerId ||
|
||||
track.isCollection ||
|
||||
track.duration <= 0) {
|
||||
return null;
|
||||
}
|
||||
final match = _trackQualityPattern.firstMatch(track.audioQuality ?? '');
|
||||
if (match == null) return null;
|
||||
final depth = int.parse(match.group(1)!);
|
||||
final rate =
|
||||
(double.parse(match.group(2)!) *
|
||||
(match.group(3)!.toLowerCase() == 'khz' ? 1000 : 1))
|
||||
.round();
|
||||
if (depth <= 0 || rate <= 0) return null;
|
||||
// Metadata reports the highest available quality of this track. A lower
|
||||
// selection caps it; a higher selection must never upscale the estimate.
|
||||
final estimate = estimateDownloadSize(
|
||||
duration: Duration(seconds: track.duration),
|
||||
quality: QualityOption(
|
||||
id: quality.id,
|
||||
label: quality.label,
|
||||
sizeEstimate: QualitySizeEstimate(
|
||||
bitDepth: math.min(parameters.bitDepth ?? depth, depth),
|
||||
sampleRate: math.min(parameters.sampleRate ?? rate, rate),
|
||||
channels: parameters.channels,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (estimate == null) return null;
|
||||
bytes += estimate.bytes;
|
||||
}
|
||||
return DownloadSizeEstimate(bytes: bytes);
|
||||
}
|
||||
|
||||
final depth = parameters.bitDepth;
|
||||
final rate = parameters.sampleRate;
|
||||
if (depth == null ||
|
||||
@@ -47,22 +83,30 @@ DownloadSizeEstimate? estimateDownloadSize({
|
||||
parameters.channels <= 0) {
|
||||
return null;
|
||||
}
|
||||
// Use one comparison estimate at the selected tier, assuming 65% of PCM.
|
||||
// Estimate compressed audio from the effective quality, 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(
|
||||
bytes: (seconds * depth * rate * parameters.channels / 8 * 0.65).round(),
|
||||
assumedBitDepth: parameters.isMaximum ? depth : null,
|
||||
assumedSampleRate: parameters.isMaximum ? rate : null,
|
||||
);
|
||||
}
|
||||
|
||||
final _trackQualityPattern = RegExp(
|
||||
r'^\s*(\d+)\s*-?\s*bit\s*/\s*(\d+(?:\.\d+)?)\s*(kHz|Hz)\s*$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
QualitySizeEstimate? _legacyParameters(QualityOption quality) {
|
||||
// These are the same legacy tiers already labelled by the picker. Custom
|
||||
// IDs (including best/high/low and spatial audio) need declared parameters.
|
||||
switch (quality.id.toUpperCase()) {
|
||||
case 'BEST':
|
||||
case 'DEFAULT':
|
||||
case 'FLAC':
|
||||
if (quality.kind == 'lossless' || quality.label.toUpperCase() == 'FLAC') {
|
||||
return const QualitySizeEstimate();
|
||||
}
|
||||
return null;
|
||||
case 'LOSSLESS':
|
||||
return const QualitySizeEstimate(bitDepth: 16, sampleRate: 44100);
|
||||
case 'HI_RES':
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/utils/audio_format_utils.dart';
|
||||
import 'package:spotiflac_android/utils/download_size_estimate.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
@@ -15,8 +16,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
|
||||
final String? artistName;
|
||||
final String? coverUrl;
|
||||
|
||||
/// Combined duration for the tracks being queued; null if any is unknown.
|
||||
final Duration? duration;
|
||||
final List<Track> tracks;
|
||||
final void Function(String quality, String service) onSelect;
|
||||
final String? recommendedService;
|
||||
|
||||
@@ -25,7 +25,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
|
||||
this.trackName,
|
||||
this.artistName,
|
||||
this.coverUrl,
|
||||
this.duration,
|
||||
this.tracks = const [],
|
||||
required this.onSelect,
|
||||
this.recommendedService,
|
||||
});
|
||||
@@ -39,7 +39,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
|
||||
String? trackName,
|
||||
String? artistName,
|
||||
String? coverUrl,
|
||||
Duration? duration,
|
||||
List<Track> tracks = const [],
|
||||
String? recommendedService,
|
||||
required void Function(String quality, String service) onSelect,
|
||||
}) {
|
||||
@@ -57,7 +57,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
|
||||
trackName: trackName,
|
||||
artistName: artistName,
|
||||
coverUrl: coverUrl,
|
||||
duration: duration,
|
||||
tracks: tracks,
|
||||
onSelect: onSelect,
|
||||
recommendedService: recommendedService,
|
||||
),
|
||||
@@ -130,9 +130,10 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
final hasProviders = downloadExtensions.isNotEmpty;
|
||||
final qualityOptions = _getQualityOptions(downloadExtensions);
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final duration = totalDownloadDuration(widget.tracks);
|
||||
final convertedSize = settings.autoConvertDownloads
|
||||
? estimateDownloadSize(
|
||||
duration: widget.duration,
|
||||
duration: duration,
|
||||
quality: QualityOption(
|
||||
id: 'converted',
|
||||
label: '',
|
||||
@@ -218,8 +219,10 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
estimatedSize: _sizeLabel(
|
||||
context,
|
||||
estimateDownloadSize(
|
||||
duration: widget.duration,
|
||||
duration: duration,
|
||||
quality: quality,
|
||||
tracks: widget.tracks,
|
||||
providerId: _selectedService,
|
||||
),
|
||||
),
|
||||
icon: _getQualityIcon(quality.id),
|
||||
@@ -261,14 +264,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
String _sizeLabel(BuildContext context, DownloadSizeEstimate? estimate) {
|
||||
if (estimate == null) return context.l10n.downloadSizeUnavailable;
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/utils/download_size_estimate.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
import 'package:spotiflac_android/widgets/view_queue_snackbar_action.dart';
|
||||
@@ -36,7 +35,7 @@ void downloadSingleTrack(
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
coverUrl: track.coverUrl,
|
||||
duration: Duration(seconds: track.duration),
|
||||
tracks: [track],
|
||||
recommendedService: recommendedService,
|
||||
onSelect: (quality, service) {
|
||||
ref
|
||||
@@ -220,7 +219,7 @@ Future<void> queueTracksSkippingDownloaded(
|
||||
context,
|
||||
trackName: '${tracksToQueue.length} tracks',
|
||||
artistName: artistNameForPicker,
|
||||
duration: totalDownloadDuration(tracksToQueue),
|
||||
tracks: tracksToQueue,
|
||||
recommendedService: recommendedService,
|
||||
onSelect: (quality, service) {
|
||||
ref
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
@@ -56,6 +57,8 @@ class _PickerSettings extends SettingsNotifier {
|
||||
Future<void> _openPicker(
|
||||
WidgetTester tester, {
|
||||
Duration? duration,
|
||||
String? audioQuality = '16bit/44.1kHz',
|
||||
String? source = 'provider-a',
|
||||
AppSettings settings = const AppSettings(),
|
||||
Locale locale = const Locale('en'),
|
||||
double textScale = 1,
|
||||
@@ -83,7 +86,17 @@ Future<void> _openPicker(
|
||||
onPressed: () => DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: 'Selected tracks',
|
||||
duration: duration,
|
||||
tracks: [
|
||||
Track(
|
||||
id: 'sample',
|
||||
name: 'Sample',
|
||||
artistName: 'Artist',
|
||||
albumName: 'Album',
|
||||
duration: duration?.inSeconds ?? 0,
|
||||
source: source,
|
||||
audioQuality: audioQuality,
|
||||
),
|
||||
],
|
||||
onSelect: onSelect ?? (_, _) {},
|
||||
),
|
||||
child: const Text('Open'),
|
||||
@@ -107,9 +120,7 @@ void main() {
|
||||
duration: const Duration(minutes: 4),
|
||||
onSelect: (quality, service) => selected = (quality, service),
|
||||
);
|
||||
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.text('≈ 26.2 MB'), findsNWidgets(3));
|
||||
expect(find.textContaining('MB–'), findsNothing);
|
||||
await tester.tap(find.text('Audio B'));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -135,16 +146,37 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('capped estimates show their quality assumption in Indonesian', (
|
||||
testWidgets('metadata caps both hi-res tiers to the available sample rate', (
|
||||
tester,
|
||||
) async {
|
||||
await _openPicker(
|
||||
tester,
|
||||
duration: const Duration(minutes: 4),
|
||||
audioQuality: '24bit/96kHz',
|
||||
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);
|
||||
expect(find.text('≈ 85.7 MB'), findsNWidgets(2));
|
||||
expect(find.text('≈ 26.2 MB'), findsOneWidget);
|
||||
expect(find.textContaining('171.4'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('foreign or incomplete track quality stays unavailable', (
|
||||
tester,
|
||||
) async {
|
||||
await _openPicker(
|
||||
tester,
|
||||
duration: const Duration(minutes: 4),
|
||||
source: 'provider-b',
|
||||
);
|
||||
expect(find.text('Size estimate unavailable'), findsNWidgets(3));
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pumpAndSettle();
|
||||
await _openPicker(
|
||||
tester,
|
||||
duration: const Duration(minutes: 4),
|
||||
audioQuality: '24bit',
|
||||
);
|
||||
expect(find.text('Size estimate unavailable'), findsNWidgets(3));
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
@@ -164,7 +196,7 @@ void main() {
|
||||
autoConvertBitrate: '256k',
|
||||
),
|
||||
);
|
||||
expect(find.text('≈ 26.2 MB'), findsOneWidget);
|
||||
expect(find.text('≈ 26.2 MB'), findsNWidgets(3));
|
||||
final conversionNote = find.textContaining(
|
||||
'After conversion to OPUS: ≈ 7.3 MB',
|
||||
);
|
||||
|
||||
@@ -3,18 +3,76 @@ import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/utils/download_size_estimate.dart';
|
||||
|
||||
Track _track(int duration, {String? itemType}) => Track(
|
||||
Track _track(
|
||||
int duration, {
|
||||
String? itemType,
|
||||
String? audioQuality,
|
||||
String? source = 'provider-a',
|
||||
}) => Track(
|
||||
id: 'track',
|
||||
name: 'Track',
|
||||
artistName: 'Artist',
|
||||
albumName: 'Album',
|
||||
duration: duration,
|
||||
itemType: itemType,
|
||||
audioQuality: audioQuality,
|
||||
source: source,
|
||||
);
|
||||
|
||||
void main() {
|
||||
const duration = Duration(minutes: 4);
|
||||
|
||||
test('track metadata caps quality and sums each recording separately', () {
|
||||
final cd = _track(240, audioQuality: '16bit/44.1kHz');
|
||||
final hiRes = _track(120, audioQuality: '24-bit/96000Hz');
|
||||
for (final id in ['LOSSLESS', 'HI_RES', 'HI_RES_LOSSLESS']) {
|
||||
expect(
|
||||
estimateDownloadSize(
|
||||
duration: duration,
|
||||
quality: QualityOption(id: id, label: ''),
|
||||
tracks: [cd],
|
||||
providerId: 'provider-a',
|
||||
)!.bytes,
|
||||
27518400,
|
||||
);
|
||||
}
|
||||
final tracks = [cd, hiRes];
|
||||
expect(
|
||||
estimateDownloadSize(
|
||||
duration: totalDownloadDuration(tracks),
|
||||
quality: QualityOption.fromJson({
|
||||
'id': 'best',
|
||||
'label': 'Best',
|
||||
'kind': 'lossless',
|
||||
}),
|
||||
tracks: tracks,
|
||||
providerId: 'provider-a',
|
||||
)!.bytes,
|
||||
27518400 + 44928000,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'lossless metadata must be complete and belong to selected provider',
|
||||
() {
|
||||
for (final track in [
|
||||
_track(240),
|
||||
_track(240, audioQuality: '24bit'),
|
||||
_track(240, audioQuality: '16bit/44.1kHz', source: 'provider-b'),
|
||||
]) {
|
||||
expect(
|
||||
estimateDownloadSize(
|
||||
duration: duration,
|
||||
quality: const QualityOption(id: 'LOSSLESS', label: ''),
|
||||
tracks: [track],
|
||||
providerId: 'provider-a',
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('four minutes at 256 kbps is 7,680,000 bytes before overhead', () {
|
||||
for (final quality in [
|
||||
const QualityOption(id: 'opus_256', label: 'Opus'),
|
||||
@@ -30,8 +88,6 @@ void main() {
|
||||
quality: quality,
|
||||
)!;
|
||||
expect(estimate.bytes, 7680000);
|
||||
expect(estimate.assumedBitDepth, isNull);
|
||||
expect(estimate.assumedSampleRate, isNull);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -39,16 +95,15 @@ void main() {
|
||||
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, rate, bytes) in [
|
||||
('HI_RES', 96000, 89856000),
|
||||
('HI_RES_LOSSLESS', 192000, 179712000),
|
||||
for (final (id, bytes) in [
|
||||
('HI_RES', 89856000),
|
||||
('HI_RES_LOSSLESS', 179712000),
|
||||
]) {
|
||||
final size = estimateDownloadSize(
|
||||
duration: duration,
|
||||
@@ -56,8 +111,6 @@ void main() {
|
||||
)!;
|
||||
expect(size.bytes, bytes);
|
||||
expect(size.bytes, greaterThan(cdSize.bytes));
|
||||
expect(size.assumedBitDepth, 24);
|
||||
expect(size.assumedSampleRate, rate);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -74,8 +127,6 @@ void main() {
|
||||
quality: quality,
|
||||
)!;
|
||||
expect(estimate.bytes, 22464000);
|
||||
expect(estimate.assumedBitDepth, isNull);
|
||||
expect(estimate.assumedSampleRate, isNull);
|
||||
final capped = estimateDownloadSize(
|
||||
duration: duration,
|
||||
quality: QualityOption.fromJson({
|
||||
@@ -89,8 +140,6 @@ void main() {
|
||||
}),
|
||||
)!;
|
||||
expect(capped.bytes, 179712000);
|
||||
expect(capped.assumedBitDepth, 24);
|
||||
expect(capped.assumedSampleRate, 192000);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user