mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-14 05:49:02 +02:00
revert: remove download size estimates
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user