feat(download): estimate file sizes in quality picker

This commit is contained in:
zarzet
2026-09-06 21:40:25 +07:00
parent bfffb8da11
commit c6195bcd10
17 changed files with 652 additions and 10 deletions
+40
View File
@@ -148,6 +148,46 @@ labels, with `downloadFallbackTier` helping classify `best` and `default`;
explicit kinds avoid ambiguity for custom IDs. Descriptions are not used
because they may describe other fallback formats.
### Estimated download sizes
The quality picker estimates audio size locally from the known track durations.
An optional `sizeEstimate` object on a quality option supplies audio parameters
for custom IDs without resolving streams or triggering verification:
```json
{
"id": "studio",
"label": "Best FLAC",
"kind": "lossless",
"sizeEstimate": {
"bitDepth": 24,
"sampleRate": 192000,
"channels": 2,
"isMaximum": true
}
}
```
- For encoded audio, supply `bitrateKbps` (total target bitrate across channels).
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 spans CD quality through the declared maximum. A capped bitrate
alone cannot supply a useful range and is shown as 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
5080% of uncompressed PCM size; this is a heuristic, not a guaranteed range.
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,
the picker does not show a partial sum as a complete total.
### Permissions
```json
+16 -5
View File
@@ -48,11 +48,22 @@ type ExtensionSetting struct {
}
type QualityOption struct {
ID string `json:"id"`
Kind string `json:"kind,omitempty"`
Label string `json:"label"`
Description string `json:"description"`
Settings []QualitySpecificSetting `json:"settings,omitempty"`
ID string `json:"id"`
Kind string `json:"kind,omitempty"`
Label string `json:"label"`
Description string `json:"description"`
SizeEstimate *QualitySizeEstimate `json:"sizeEstimate,omitempty"`
Settings []QualitySpecificSetting `json:"settings,omitempty"`
}
// QualitySizeEstimate describes audio parameters, not an exact file size.
// BitrateKbps is for encoded audio; BitDepth/SampleRate are for compressed lossless.
type QualitySizeEstimate struct {
BitrateKbps int `json:"bitrateKbps,omitempty"`
BitDepth int `json:"bitDepth,omitempty"`
SampleRate int `json:"sampleRate,omitempty"`
Channels int `json:"channels,omitempty"`
IsMaximum bool `json:"isMaximum,omitempty"`
}
type QualitySpecificSetting struct {
+33
View File
@@ -0,0 +1,33 @@
package gobackend
import (
"encoding/json"
"strings"
"testing"
)
func TestQualitySizeParametersSurviveManifestRoundTrip(t *testing.T) {
const source = `{"name":"sample-audio","version":"1.0.0","description":"Sample","type":["download_provider"],"qualityOptions":[{"id":"studio","label":"Studio","kind":"lossless","sizeEstimate":{"bitDepth":24,"sampleRate":96000,"channels":2,"isMaximum":true}},{"id":"compact","label":"Compact","sizeEstimate":{"bitrateKbps":256}},{"id":"legacy","label":"Legacy"}]}`
manifest, err := ParseManifest([]byte(source))
if err != nil {
t.Fatal(err)
}
encoded, err := json.Marshal(manifest.QualityOptions)
if err != nil {
t.Fatal(err)
}
var options []QualityOption
if err := json.Unmarshal(encoded, &options); err != nil {
t.Fatal(err)
}
want := QualitySizeEstimate{BitDepth: 24, SampleRate: 96000, Channels: 2, IsMaximum: true}
if options[0].SizeEstimate == nil || *options[0].SizeEstimate != want {
t.Fatalf("lossless parameters lost: %+v", options[0].SizeEstimate)
}
if options[1].SizeEstimate == nil || options[1].SizeEstimate.BitrateKbps != 256 {
t.Fatalf("bitrate lost: %+v", options[1].SizeEstimate)
}
if options[2].SizeEstimate != nil || strings.Contains(string(encoded), `"sizeEstimate":null`) {
t.Fatalf("legacy quality acquired size parameters: %s", encoded)
}
}
+23
View File
@@ -2127,6 +2127,29 @@
"@downloadSelectQuality": {
"description": "Dialog title - choose audio quality"
},
"downloadEstimatedSize": "≈ {size}",
"@downloadEstimatedSize": {
"description": "Approximate audio file size, or total for all selected tracks; size includes units and may be a range",
"placeholders": {
"size": {
"type": "String"
}
}
},
"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.",
"downloadConvertedSizeEstimate": "After conversion to {format}: {size}",
"@downloadConvertedSizeEstimate": {
"description": "Estimated size after the user's automatic conversion, excluding tags and artwork",
"placeholders": {
"format": {
"type": "String"
},
"size": {
"type": "String"
}
}
},
"downloadFrom": "Download From",
"@downloadFrom": {
"description": "Label - download source"
+5 -1
View File
@@ -6197,5 +6197,9 @@
"nowPlayingRepeatAll": "Repeat all",
"nowPlayingRepeatOne": "Repeat one",
"queueNetworkFailedOffline": "{count} downloads failed while offline",
"libraryFilterMetadataMissingIsrc": "Missing ISRC"
"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.",
"downloadConvertedSizeEstimate": "Setelah konversi ke {format}: {size}"
}
+34
View File
@@ -691,12 +691,14 @@ class QualityOption {
final String id;
final String label;
final String? description;
final QualitySizeEstimate? sizeEstimate;
final List<QualitySpecificSetting> settings;
const QualityOption({
required this.id,
required this.label,
this.description,
this.sizeEstimate,
this.settings = const [],
});
@@ -705,6 +707,11 @@ class QualityOption {
id: json['id'] as String? ?? '',
label: json['label'] as String? ?? '',
description: json['description'] as String?,
sizeEstimate: json['sizeEstimate'] is Map<String, dynamic>
? QualitySizeEstimate.fromJson(
json['sizeEstimate'] as Map<String, dynamic>,
)
: null,
settings:
(json['settings'] as List<dynamic>?)
?.map(
@@ -717,6 +724,33 @@ class QualityOption {
}
}
/// Optional audio parameters for local estimates, not a resolved file size.
class QualitySizeEstimate {
final int? bitrateKbps;
final int? bitDepth;
final int? sampleRate;
final int channels;
final bool isMaximum;
const QualitySizeEstimate({
this.bitrateKbps,
this.bitDepth,
this.sampleRate,
this.channels = 2,
this.isMaximum = false,
});
factory QualitySizeEstimate.fromJson(Map<String, dynamic> json) {
return QualitySizeEstimate(
bitrateKbps: (json['bitrateKbps'] as num?)?.toInt(),
bitDepth: (json['bitDepth'] as num?)?.toInt(),
sampleRate: (json['sampleRate'] as num?)?.toInt(),
channels: (json['channels'] as num?)?.toInt() ?? 2,
isMaximum: json['isMaximum'] as bool? ?? false,
);
}
}
class QualitySpecificSetting {
final String key;
final String label;
+1
View File
@@ -583,6 +583,7 @@ extension _ArtistScreenSections on _ArtistScreenState {
if (settings.askQualityBeforeDownload || settings.allowQualityVariants) {
DownloadServicePicker.show(
context,
duration: Duration(seconds: track.duration),
recommendedService: _recommendedDownloadService(),
onSelect: (quality, service) {
if (!mounted) return;
+2
View File
@@ -27,6 +27,7 @@ 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';
@@ -652,6 +653,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
trackName: track.name,
artistName: track.artistName,
coverUrl: track.coverUrl,
duration: Duration(seconds: track.duration),
recommendedService:
trackState.searchExtensionId ?? trackState.searchSource,
onSelect: (quality, service) {
+1
View File
@@ -479,6 +479,7 @@ extension _HomeTabExploreUI on _HomeTabState {
trackName: track.name,
artistName: track.artistName,
coverUrl: track.coverUrl,
duration: Duration(seconds: track.duration),
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
+1
View File
@@ -197,6 +197,7 @@ extension _HomeTabCsvImport on _HomeTabState {
this.context,
trackName: l10n.csvImportTracks(tracksToQueue.length),
artistName: l10n.dialogImportPlaylistTitle,
duration: totalDownloadDuration(tracksToQueue),
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
+1
View File
@@ -16,6 +16,7 @@ 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';
+5
View File
@@ -253,6 +253,11 @@ 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),
),
),
onSelect: (quality, service) {
enqueueAll(qualityOverride: quality, service: service);
if (!mounted) return;
+91
View File
@@ -0,0 +1,91 @@
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;
const DownloadSizeEstimate({required this.minBytes, required this.maxBytes});
}
/// Unknown durations must not make a batch estimate look like a complete total.
Duration? totalDownloadDuration(Iterable<Track> tracks) {
var seconds = 0;
for (final track in tracks) {
if (track.isCollection || track.duration <= 0) return null;
seconds += track.duration;
}
return seconds > 0 ? Duration(seconds: seconds) : null;
}
DownloadSizeEstimate? estimateDownloadSize({
required Duration? duration,
required QualityOption quality,
}) {
if (duration == null || duration <= Duration.zero) return null;
final parameters = quality.sizeEstimate ?? _legacyParameters(quality);
if (parameters == null) return null;
final seconds = duration.inMilliseconds / 1000;
final bitrate = parameters.bitrateKbps;
if (bitrate != null) {
if (bitrate <= 0 || parameters.isMaximum) return null;
final bytes = (seconds * bitrate * 1000 / 8).round();
return DownloadSizeEstimate(minBytes: bytes, maxBytes: bytes);
}
final depth = parameters.bitDepth;
final rate = parameters.sampleRate;
if (depth == null ||
depth <= 0 ||
rate == null ||
rate <= 0 ||
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 (5080% of PCM), not a bound or a
// guarantee. Capped tiers can deliver CD quality instead of their maximum.
// 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(),
);
}
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 'LOSSLESS':
return const QualitySizeEstimate(bitDepth: 16, sampleRate: 44100);
case 'HI_RES':
return const QualitySizeEstimate(
bitDepth: 24,
sampleRate: 96000,
isMaximum: true,
);
case 'HI_RES_LOSSLESS':
return const QualitySizeEstimate(
bitDepth: 24,
sampleRate: 192000,
isMaximum: true,
);
}
// Only explicit codec/bitrate labels or IDs; never infer a bitrate from a
// vague tier name or a description mentioning other fallback formats.
final bitratePattern = RegExp(
r'^(?:mp3|opus|aac)[ _-]+(\d+)(?:\s*(?:kbps|kb/s|kbit/s|k))?$',
caseSensitive: false,
);
for (final value in [quality.id, quality.label]) {
final match = bitratePattern.firstMatch(value.trim());
if (match != null) {
return QualitySizeEstimate(bitrateKbps: int.tryParse(match.group(1)!));
}
}
return null;
}
+74 -4
View File
@@ -6,11 +6,17 @@ 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/utils/audio_format_utils.dart';
import 'package:spotiflac_android/utils/download_size_estimate.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
class DownloadServicePicker extends ConsumerStatefulWidget {
final String? trackName;
final String? artistName;
final String? coverUrl;
/// Combined duration for the tracks being queued; null if any is unknown.
final Duration? duration;
final void Function(String quality, String service) onSelect;
final String? recommendedService;
@@ -19,6 +25,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
this.trackName,
this.artistName,
this.coverUrl,
this.duration,
required this.onSelect,
this.recommendedService,
});
@@ -32,6 +39,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
String? trackName,
String? artistName,
String? coverUrl,
Duration? duration,
String? recommendedService,
required void Function(String quality, String service) onSelect,
}) {
@@ -49,6 +57,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
trackName: trackName,
artistName: artistName,
coverUrl: coverUrl,
duration: duration,
onSelect: onSelect,
recommendedService: recommendedService,
),
@@ -120,6 +129,21 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
final downloadExtensions = _downloadExtensions();
final hasProviders = downloadExtensions.isNotEmpty;
final qualityOptions = _getQualityOptions(downloadExtensions);
final settings = ref.watch(settingsProvider);
final convertedSize = settings.autoConvertDownloads
? estimateDownloadSize(
duration: widget.duration,
quality: QualityOption(
id: 'converted',
label: '',
sizeEstimate: QualitySizeEstimate(
bitrateKbps: autoConvertBitrateKbps(
settings.autoConvertBitrate,
),
),
),
)
: null;
return SafeArea(
child: SingleChildScrollView(
@@ -191,12 +215,40 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
_QualityOption(
title: _localizedQualityLabel(context, quality),
subtitle: _localizedQualityDescription(context, quality),
estimatedSize: _sizeLabel(
context,
estimateDownloadSize(
duration: widget.duration,
quality: quality,
),
),
icon: _getQualityIcon(quality.id),
onTap: () {
Navigator.pop(context);
widget.onSelect(quality.id, _selectedService);
},
),
if (qualityOptions.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 0),
child: Text(
[
context.l10n.downloadSizeEstimateNote,
if (convertedSize != null)
context.l10n.downloadConvertedSizeEstimate(
displayFormatForLossyFormat(
normalizeAutoConvertFormat(
settings.autoConvertFormat,
),
),
_sizeLabel(context, convertedSize),
),
].join('\n\n'),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
const SizedBox(height: 16),
@@ -206,6 +258,14 @@ 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)}';
return context.l10n.downloadEstimatedSize(size);
}
IconData _getQualityIcon(String qualityId) {
final normalized = qualityId.toUpperCase();
if (normalized.startsWith('MP3_') || normalized == 'MP3') {
@@ -260,12 +320,14 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
class _QualityOption extends StatelessWidget {
final String title;
final String subtitle;
final String estimatedSize;
final IconData icon;
final VoidCallback onTap;
const _QualityOption({
required this.title,
required this.subtitle,
required this.estimatedSize,
required this.icon,
required this.onTap,
});
@@ -284,12 +346,20 @@ class _QualityOption extends StatelessWidget {
child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20),
),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: subtitle.isNotEmpty
? Text(
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (subtitle.isNotEmpty)
Text(
subtitle,
style: TextStyle(color: colorScheme.onSurfaceVariant),
)
: null,
),
Text(
estimatedSize,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
],
),
onTap: onTap,
);
}
+3
View File
@@ -7,6 +7,7 @@ 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';
@@ -35,6 +36,7 @@ void downloadSingleTrack(
trackName: track.name,
artistName: track.artistName,
coverUrl: track.coverUrl,
duration: Duration(seconds: track.duration),
recommendedService: recommendedService,
onSelect: (quality, service) {
ref
@@ -218,6 +220,7 @@ Future<void> queueTracksSkippingDownloaded(
context,
trackName: '${tracksToQueue.length} tracks',
artistName: artistNameForPicker,
duration: totalDownloadDuration(tracksToQueue),
recommendedService: recommendedService,
onSelect: (quality, service) {
ref
+161
View File
@@ -0,0 +1,161 @@
import 'package:flutter/material.dart';
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/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
class _PickerExtensions extends ExtensionNotifier {
@override
ExtensionState build() => const ExtensionState(
extensions: [
Extension(
id: 'provider-a',
name: 'provider-a',
displayName: 'Audio A',
version: '1.0.0',
description: '',
enabled: true,
status: 'loaded',
hasDownloadProvider: true,
qualityOptions: [
QualityOption(id: 'LOSSLESS', label: 'Lossless'),
QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res'),
],
),
Extension(
id: 'provider-b',
name: 'provider-b',
displayName: 'Audio B',
version: '1.0.0',
description: '',
enabled: true,
status: 'loaded',
hasDownloadProvider: true,
qualityOptions: [
QualityOption(id: 'opus_256', label: 'Opus 256kbps'),
QualityOption(id: 'custom', label: 'Custom'),
],
),
],
);
}
class _PickerSettings extends SettingsNotifier {
final AppSettings _settings;
_PickerSettings(this._settings);
@override
AppSettings build() => _settings;
}
Future<void> _openPicker(
WidgetTester tester, {
Duration? duration,
AppSettings settings = const AppSettings(),
Locale locale = const Locale('en'),
double textScale = 1,
void Function(String, String)? onSelect,
}) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
extensionProvider.overrideWith(_PickerExtensions.new),
settingsProvider.overrideWith(() => _PickerSettings(settings)),
],
child: MaterialApp(
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
builder: (context, child) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(textScaler: TextScaler.linear(textScale)),
child: child!,
),
home: Scaffold(
body: Builder(
builder: (context) => TextButton(
onPressed: () => DownloadServicePicker.show(
context,
trackName: 'Selected tracks',
duration: duration,
onSelect: onSelect ?? (_, _) {},
),
child: const Text('Open'),
),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
}
void main() {
testWidgets('estimates change with the provider and selection stays intact', (
tester,
) async {
(String, String)? selected;
await _openPicker(
tester,
duration: const Duration(minutes: 4),
onSelect: (quality, service) => selected = (quality, service),
);
expect(find.text('≈ 20.2 MB32.3 MB'), findsOneWidget);
expect(find.text('≈ 20.2 MB210.9 MB'), findsOneWidget);
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 MB32.3 MB'), findsNothing);
await tester.tap(find.text('Opus 256kbps'));
await tester.pumpAndSettle();
expect(selected, ('opus_256', 'provider-b'));
expect(find.byType(DownloadServicePicker), findsNothing);
});
testWidgets(
'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.textContaining(''), findsNothing);
await tester.tap(find.text('FLAC Lossless'));
await tester.pumpAndSettle();
expect(find.byType(DownloadServicePicker), findsNothing);
},
);
testWidgets(
'conversion estimate is separate and fits narrow, enlarged text',
(tester) async {
tester.view.physicalSize = const Size(320, 720);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await _openPicker(
tester,
duration: const Duration(minutes: 4),
textScale: 1.8,
settings: const AppSettings(
autoConvertDownloads: true,
autoConvertFormat: 'opus',
autoConvertBitrate: '256k',
),
);
expect(find.text('≈ 20.2 MB32.3 MB'), findsOneWidget);
final conversionNote = find.textContaining(
'After conversion to OPUS: ≈ 7.3 MB',
);
expect(conversionNote, findsOneWidget);
await tester.ensureVisible(conversionNote);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
},
);
}
+161
View File
@@ -0,0 +1,161 @@
import 'package:flutter_test/flutter_test.dart';
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(
id: 'track',
name: 'Track',
artistName: 'Artist',
albumName: 'Album',
duration: duration,
itemType: itemType,
);
void main() {
const duration = Duration(minutes: 4);
test('four minutes at 256 kbps is 7,680,000 bytes before overhead', () {
for (final quality in [
const QualityOption(id: 'opus_256', label: 'Opus'),
const QualityOption(id: 'custom', label: 'Opus 256kbps'),
QualityOption.fromJson({
'id': 'custom',
'label': 'Audio',
'sizeEstimate': {'bitrateKbps': 256},
}),
]) {
final estimate = estimateDownloadSize(
duration: duration,
quality: quality,
)!;
expect(estimate.minBytes, 7680000);
expect(estimate.maxBytes, 7680000);
}
});
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);
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);
}
},
);
test(
'explicit parameters override legacy assumptions and preserve channels',
() {
final quality = QualityOption.fromJson({
'id': 'LOSSLESS',
'label': 'Custom lossless',
'sizeEstimate': {'bitDepth': 24, 'sampleRate': 48000, 'channels': 1},
});
final estimate = estimateDownloadSize(
duration: duration,
quality: quality,
)!;
expect(estimate.minBytes, 17280000);
expect(estimate.maxBytes, 27648000);
final capped = estimateDownloadSize(
duration: duration,
quality: QualityOption.fromJson({
'id': 'best',
'label': 'Best',
'sizeEstimate': {
'bitDepth': 24,
'sampleRate': 192000,
'isMaximum': true,
},
}),
)!;
expect(capped.minBytes, 21168000);
expect(capped.maxBytes, 221184000);
},
);
test(
'unknown tiers, incomplete or invalid parameters do not invent sizes',
() {
for (final quality in [
const QualityOption(id: 'best', label: 'Best'),
const QualityOption(id: 'HIGH', label: 'High'),
const QualityOption(id: 'DOLBY_ATMOS', label: 'Spatial'),
const QualityOption(id: 'opus', label: 'Opus'),
const QualityOption(
id: 'custom',
label: 'Best',
description: 'May fall back to Opus 256kbps',
),
const QualityOption(
id: 'LOSSLESS',
label: '',
sizeEstimate: QualitySizeEstimate(bitDepth: 24),
),
const QualityOption(
id: 'LOSSLESS',
label: '',
sizeEstimate: QualitySizeEstimate(bitDepth: 0, sampleRate: 48000),
),
const QualityOption(
id: 'opus_256',
label: '',
sizeEstimate: QualitySizeEstimate(bitrateKbps: -1),
),
const QualityOption(
id: 'custom',
label: '',
sizeEstimate: QualitySizeEstimate(bitrateKbps: 320, isMaximum: true),
),
]) {
expect(
estimateDownloadSize(duration: duration, quality: quality),
isNull,
);
}
for (final duration in [
null,
Duration.zero,
const Duration(seconds: -1),
]) {
expect(
estimateDownloadSize(
duration: duration,
quality: const QualityOption(id: 'mp3_320', label: 'MP3'),
),
isNull,
);
}
},
);
test('batch duration is unavailable when tracks are missing duration', () {
expect(totalDownloadDuration([]), isNull);
expect(totalDownloadDuration([_track(240), _track(0)]), isNull);
expect(totalDownloadDuration([_track(-1)]), isNull);
expect(totalDownloadDuration([_track(240, itemType: 'album')]), isNull);
expect(
totalDownloadDuration([_track(120), _track(180)]),
const Duration(minutes: 5),
);
});
}