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
-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