mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-19 08:12:14 +02:00
feat(download): estimate file sizes in quality picker
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (50–80% 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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user