revert: remove download size estimates

This commit is contained in:
zarzet
2026-09-06 22:17:23 +07:00
parent 174852b056
commit ed51b5d6ea
17 changed files with 10 additions and 806 deletions
-47
View File
@@ -148,53 +148,6 @@ 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.
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`.
`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 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. 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
```json
+5 -16
View File
@@ -48,22 +48,11 @@ type ExtensionSetting struct {
}
type QualityOption struct {
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"`
ID string `json:"id"`
Kind string `json:"kind,omitempty"`
Label string `json:"label"`
Description string `json:"description"`
Settings []QualitySpecificSetting `json:"settings,omitempty"`
}
type QualitySpecificSetting struct {
-33
View File
@@ -1,33 +0,0 @@
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,29 +2127,6 @@
"@downloadSelectQuality": {
"description": "Dialog title - choose audio quality"
},
"downloadEstimatedSize": "≈ {size}",
"@downloadEstimatedSize": {
"description": "Approximate audio file size, or total for all selected tracks; size includes units",
"placeholders": {
"size": {
"type": "String"
}
}
},
"downloadSizeUnavailable": "Size estimate unavailable",
"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",
"placeholders": {
"format": {
"type": "String"
},
"size": {
"type": "String"
}
}
},
"downloadFrom": "Download From",
"@downloadFrom": {
"description": "Label - download source"
+1 -5
View File
@@ -6197,9 +6197,5 @@
"nowPlayingRepeatAll": "Repeat all",
"nowPlayingRepeatOne": "Repeat one",
"queueNetworkFailedOffline": "{count} downloads failed while offline",
"libraryFilterMetadataMissingIsrc": "Missing ISRC",
"downloadEstimatedSize": "≈ {size}",
"downloadSizeUnavailable": "Estimasi ukuran belum tersedia",
"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}"
"libraryFilterMetadataMissingIsrc": "Missing ISRC"
}
-37
View File
@@ -689,32 +689,22 @@ class PostProcessingHook {
class QualityOption {
final String id;
final String? kind;
final String label;
final String? description;
final QualitySizeEstimate? sizeEstimate;
final List<QualitySpecificSetting> settings;
const QualityOption({
required this.id,
this.kind,
required this.label,
this.description,
this.sizeEstimate,
this.settings = const [],
});
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>
? QualitySizeEstimate.fromJson(
json['sizeEstimate'] as Map<String, dynamic>,
)
: null,
settings:
(json['settings'] as List<dynamic>?)
?.map(
@@ -727,33 +717,6 @@ 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;
-3
View File
@@ -321,9 +321,6 @@ 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']),
-1
View File
@@ -583,7 +583,6 @@ extension _ArtistScreenSections on _ArtistScreenState {
if (settings.askQualityBeforeDownload || settings.allowQualityVariants) {
DownloadServicePicker.show(
context,
tracks: [track],
recommendedService: _recommendedDownloadService(),
onSelect: (quality, service) {
if (!mounted) return;
-1
View File
@@ -652,7 +652,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
trackName: track.name,
artistName: track.artistName,
coverUrl: track.coverUrl,
tracks: [track],
recommendedService:
trackState.searchExtensionId ?? trackState.searchSource,
onSelect: (quality, service) {
-1
View File
@@ -479,7 +479,6 @@ extension _HomeTabExploreUI on _HomeTabState {
trackName: track.name,
artistName: track.artistName,
coverUrl: track.coverUrl,
tracks: [track],
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
-1
View File
@@ -197,7 +197,6 @@ extension _HomeTabCsvImport on _HomeTabState {
this.context,
trackName: l10n.csvImportTracks(tracksToQueue.length),
artistName: l10n.dialogImportPlaylistTitle,
tracks: tracksToQueue,
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
-3
View File
@@ -253,9 +253,6 @@ extension _QueueTabSelectionActions on _QueueTabState {
context,
trackName: context.l10n.tracksCount(totalTracks),
artistName: context.l10n.playlistsCount(selectedPlaylists.length),
tracks: selectedPlaylists
.expand((playlist) => playlist.tracks.map((item) => item.track))
.toList(),
onSelect: (quality, service) {
enqueueAll(qualityOverride: quality, service: service);
if (!mounted) return;
-138
View File
@@ -1,138 +0,0 @@
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;
const DownloadSizeEstimate({required this.bytes});
}
/// 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,
List<Track>? tracks,
String? providerId,
}) {
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(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 ||
depth <= 0 ||
rate == null ||
rate <= 0 ||
parameters.channels <= 0) {
return null;
}
// Estimate compressed audio from the effective quality, assuming 65% of PCM.
// This is a heuristic, not a measurement of this recording's compression.
// Artwork, tags, container overhead and later conversion are excluded.
return DownloadSizeEstimate(
bytes: (seconds * depth * rate * parameters.channels / 8 * 0.65).round(),
);
}
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':
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;
}
+4 -75
View File
@@ -6,17 +6,11 @@ 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';
class DownloadServicePicker extends ConsumerStatefulWidget {
final String? trackName;
final String? artistName;
final String? coverUrl;
final List<Track> tracks;
final void Function(String quality, String service) onSelect;
final String? recommendedService;
@@ -25,7 +19,6 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
this.trackName,
this.artistName,
this.coverUrl,
this.tracks = const [],
required this.onSelect,
this.recommendedService,
});
@@ -39,7 +32,6 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
String? trackName,
String? artistName,
String? coverUrl,
List<Track> tracks = const [],
String? recommendedService,
required void Function(String quality, String service) onSelect,
}) {
@@ -57,7 +49,6 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
trackName: trackName,
artistName: artistName,
coverUrl: coverUrl,
tracks: tracks,
onSelect: onSelect,
recommendedService: recommendedService,
),
@@ -129,22 +120,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
final downloadExtensions = _downloadExtensions();
final hasProviders = downloadExtensions.isNotEmpty;
final qualityOptions = _getQualityOptions(downloadExtensions);
final settings = ref.watch(settingsProvider);
final duration = totalDownloadDuration(widget.tracks);
final convertedSize = settings.autoConvertDownloads
? estimateDownloadSize(
duration: duration,
quality: QualityOption(
id: 'converted',
label: '',
sizeEstimate: QualitySizeEstimate(
bitrateKbps: autoConvertBitrateKbps(
settings.autoConvertBitrate,
),
),
),
)
: null;
return SafeArea(
child: SingleChildScrollView(
@@ -216,42 +191,12 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
_QualityOption(
title: _localizedQualityLabel(context, quality),
subtitle: _localizedQualityDescription(context, quality),
estimatedSize: _sizeLabel(
context,
estimateDownloadSize(
duration: duration,
quality: quality,
tracks: widget.tracks,
providerId: _selectedService,
),
),
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),
@@ -261,12 +206,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
);
}
String _sizeLabel(BuildContext context, DownloadSizeEstimate? estimate) {
if (estimate == null) return context.l10n.downloadSizeUnavailable;
final size = formatBytes(estimate.bytes);
return context.l10n.downloadEstimatedSize(size);
}
IconData _getQualityIcon(String qualityId) {
final normalized = qualityId.toUpperCase();
if (normalized.startsWith('MP3_') || normalized == 'MP3') {
@@ -321,14 +260,12 @@ 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,
});
@@ -347,20 +284,12 @@ class _QualityOption extends StatelessWidget {
child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20),
),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (subtitle.isNotEmpty)
Text(
subtitle: subtitle.isNotEmpty
? Text(
subtitle,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
Text(
estimatedSize,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
],
),
)
: null,
onTap: onTap,
);
}
-2
View File
@@ -35,7 +35,6 @@ void downloadSingleTrack(
trackName: track.name,
artistName: track.artistName,
coverUrl: track.coverUrl,
tracks: [track],
recommendedService: recommendedService,
onSelect: (quality, service) {
ref
@@ -219,7 +218,6 @@ Future<void> queueTracksSkippingDownloaded(
context,
trackName: '${tracksToQueue.length} tracks',
artistName: artistNameForPicker,
tracks: tracksToQueue,
recommendedService: recommendedService,
onSelect: (quality, service) {
ref
-209
View File
@@ -1,209 +0,0 @@
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/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';
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', label: 'Hi-Res'),
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,
String? audioQuality = '16bit/44.1kHz',
String? source = 'provider-a',
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',
tracks: [
Track(
id: 'sample',
name: 'Sample',
artistName: 'Artist',
albumName: 'Album',
duration: duration?.inSeconds ?? 0,
source: source,
audioQuality: audioQuality,
),
],
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('≈ 26.2 MB'), findsNWidgets(3));
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('≈ 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'));
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(3));
expect(find.textContaining(''), findsNothing);
await tester.tap(find.text('FLAC Lossless'));
await tester.pumpAndSettle();
expect(find.byType(DownloadServicePicker), findsNothing);
},
);
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'), 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(
'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('≈ 26.2 MB'), findsNWidgets(3));
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);
},
);
}
-211
View File
@@ -1,211 +0,0 @@
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,
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'),
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.bytes, 7680000);
}
});
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);
final batchSize = estimateDownloadSize(
duration: totalDownloadDuration([_track(240), _track(240)]),
quality: cd,
)!;
expect(batchSize.bytes, cdSize.bytes * 2);
for (final (id, bytes) in [
('HI_RES', 89856000),
('HI_RES_LOSSLESS', 179712000),
]) {
final size = estimateDownloadSize(
duration: duration,
quality: QualityOption(id: id, label: ''),
)!;
expect(size.bytes, bytes);
expect(size.bytes, greaterThan(cdSize.bytes));
}
});
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.bytes, 22464000);
final capped = estimateDownloadSize(
duration: duration,
quality: QualityOption.fromJson({
'id': 'best',
'label': 'Best',
'sizeEstimate': {
'bitDepth': 24,
'sampleRate': 192000,
'isMaximum': true,
},
}),
)!;
expect(capped.bytes, 179712000);
},
);
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),
);
});
}