diff --git a/docs/EXTENSION_DEVELOPMENT.md b/docs/EXTENSION_DEVELOPMENT.md index e5eaf32b..7ff4f263 100644 --- a/docs/EXTENSION_DEVELOPMENT.md +++ b/docs/EXTENSION_DEVELOPMENT.md @@ -148,6 +148,46 @@ labels, with `downloadFallbackTier` helping classify `best` and `default`; explicit kinds avoid ambiguity for custom IDs. Descriptions are not used because they may describe other fallback formats. +### Estimated download sizes + +The quality picker estimates audio size locally from the known track durations. +An optional `sizeEstimate` object on a quality option supplies audio parameters +for custom IDs without resolving streams or triggering verification: + +```json +{ + "id": "studio", + "label": "Best FLAC", + "kind": "lossless", + "sizeEstimate": { + "bitDepth": 24, + "sampleRate": 192000, + "channels": 2, + "isMaximum": true + } +} +``` + +- For encoded audio, supply `bitrateKbps` (total target bitrate across channels). + For example, `"sizeEstimate": {"bitrateKbps": 256}`. +- For compressed lossless audio, supply `bitDepth` and `sampleRate` in Hz. + `channels` defaults to 2. Do not use this model for uncompressed PCM. +- Set `isMaximum: true` for a lossless tier that can return lower quality. Its + estimate spans CD quality through the declared maximum. A capped bitrate + alone cannot supply a useful range and is shown as unavailable. +- Omitted parameters remain unknown. Older extensions retain estimates for + the picker's legacy `LOSSLESS`, `HI_RES`, and `HI_RES_LOSSLESS` tiers and + explicit codec/bitrate IDs or labels such as `opus_256` or `Opus 256kbps`. + Generic `best`, `high`, `low`, and spatial tiers need explicit parameters. + +Lossy estimates use duration × bitrate / 8. Lossless estimates use a rough +50–80% of uncompressed PCM size; this is a heuristic, not a guaranteed range. +Estimates exclude artwork, tags, and container overhead. They describe the +selected quality before automatic conversion, with a separate converted-size +estimate when enabled. Fallback quality and intermediate transfers can change +both the final size and data usage. If any selected track lacks a duration, +the picker does not show a partial sum as a complete total. + ### Permissions ```json diff --git a/go_backend/extension_manifest.go b/go_backend/extension_manifest.go index 6f2d7d1e..f9adc043 100644 --- a/go_backend/extension_manifest.go +++ b/go_backend/extension_manifest.go @@ -48,11 +48,22 @@ type ExtensionSetting struct { } type QualityOption struct { - ID string `json:"id"` - Kind string `json:"kind,omitempty"` - Label string `json:"label"` - Description string `json:"description"` - Settings []QualitySpecificSetting `json:"settings,omitempty"` + ID string `json:"id"` + Kind string `json:"kind,omitempty"` + Label string `json:"label"` + Description string `json:"description"` + SizeEstimate *QualitySizeEstimate `json:"sizeEstimate,omitempty"` + Settings []QualitySpecificSetting `json:"settings,omitempty"` +} + +// QualitySizeEstimate describes audio parameters, not an exact file size. +// BitrateKbps is for encoded audio; BitDepth/SampleRate are for compressed lossless. +type QualitySizeEstimate struct { + BitrateKbps int `json:"bitrateKbps,omitempty"` + BitDepth int `json:"bitDepth,omitempty"` + SampleRate int `json:"sampleRate,omitempty"` + Channels int `json:"channels,omitempty"` + IsMaximum bool `json:"isMaximum,omitempty"` } type QualitySpecificSetting struct { diff --git a/go_backend/extension_quality_size_test.go b/go_backend/extension_quality_size_test.go new file mode 100644 index 00000000..f8c0e07f --- /dev/null +++ b/go_backend/extension_quality_size_test.go @@ -0,0 +1,33 @@ +package gobackend + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestQualitySizeParametersSurviveManifestRoundTrip(t *testing.T) { + const source = `{"name":"sample-audio","version":"1.0.0","description":"Sample","type":["download_provider"],"qualityOptions":[{"id":"studio","label":"Studio","kind":"lossless","sizeEstimate":{"bitDepth":24,"sampleRate":96000,"channels":2,"isMaximum":true}},{"id":"compact","label":"Compact","sizeEstimate":{"bitrateKbps":256}},{"id":"legacy","label":"Legacy"}]}` + manifest, err := ParseManifest([]byte(source)) + if err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(manifest.QualityOptions) + if err != nil { + t.Fatal(err) + } + var options []QualityOption + if err := json.Unmarshal(encoded, &options); err != nil { + t.Fatal(err) + } + want := QualitySizeEstimate{BitDepth: 24, SampleRate: 96000, Channels: 2, IsMaximum: true} + if options[0].SizeEstimate == nil || *options[0].SizeEstimate != want { + t.Fatalf("lossless parameters lost: %+v", options[0].SizeEstimate) + } + if options[1].SizeEstimate == nil || options[1].SizeEstimate.BitrateKbps != 256 { + t.Fatalf("bitrate lost: %+v", options[1].SizeEstimate) + } + if options[2].SizeEstimate != nil || strings.Contains(string(encoded), `"sizeEstimate":null`) { + t.Fatalf("legacy quality acquired size parameters: %s", encoded) + } +} diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 6f87c789..9e904360 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -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" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 7e44eb95..c70754b1 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -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}" } diff --git a/lib/providers/extension_models.dart b/lib/providers/extension_models.dart index b993b1d8..16d6d770 100644 --- a/lib/providers/extension_models.dart +++ b/lib/providers/extension_models.dart @@ -691,12 +691,14 @@ class QualityOption { final String id; final String label; final String? description; + final QualitySizeEstimate? sizeEstimate; final List 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 + ? QualitySizeEstimate.fromJson( + json['sizeEstimate'] as Map, + ) + : null, settings: (json['settings'] as List?) ?.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 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; diff --git a/lib/screens/artist_screen_widgets.dart b/lib/screens/artist_screen_widgets.dart index ab5d4914..3d2ef6f2 100644 --- a/lib/screens/artist_screen_widgets.dart +++ b/lib/screens/artist_screen_widgets.dart @@ -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; diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 8b38b91d..ada48e2a 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -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 trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl, + duration: Duration(seconds: track.duration), recommendedService: trackState.searchExtensionId ?? trackState.searchSource, onSelect: (quality, service) { diff --git a/lib/screens/home_tab_explore.dart b/lib/screens/home_tab_explore.dart index 538a1902..025e605a 100644 --- a/lib/screens/home_tab_explore.dart +++ b/lib/screens/home_tab_explore.dart @@ -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) diff --git a/lib/screens/home_tab_import.dart b/lib/screens/home_tab_import.dart index b25850e1..776f1e3f 100644 --- a/lib/screens/home_tab_import.dart +++ b/lib/screens/home_tab_import.dart @@ -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) diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index e04c813a..67d1a22c 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -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'; diff --git a/lib/screens/queue_tab_selection.dart b/lib/screens/queue_tab_selection.dart index 321a70fa..9635d86d 100644 --- a/lib/screens/queue_tab_selection.dart +++ b/lib/screens/queue_tab_selection.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; diff --git a/lib/utils/download_size_estimate.dart b/lib/utils/download_size_estimate.dart new file mode 100644 index 00000000..d8b961a8 --- /dev/null +++ b/lib/utils/download_size_estimate.dart @@ -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 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; +} diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index bbc96963..49fe70cb 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -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 { 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 { _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 { ); } + 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 { 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, ); } diff --git a/lib/widgets/track_detail_actions.dart b/lib/widgets/track_detail_actions.dart index 206ff69d..7e809d9d 100644 --- a/lib/widgets/track_detail_actions.dart +++ b/lib/widgets/track_detail_actions.dart @@ -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 queueTracksSkippingDownloaded( context, trackName: '${tracksToQueue.length} tracks', artistName: artistNameForPicker, + duration: totalDownloadDuration(tracksToQueue), recommendedService: recommendedService, onSelect: (quality, service) { ref diff --git a/test/download_service_picker_test.dart b/test/download_service_picker_test.dart new file mode 100644 index 00000000..abb8f4b1 --- /dev/null +++ b/test/download_service_picker_test.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/l10n/app_localizations.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/widgets/download_service_picker.dart'; + +class _PickerExtensions extends ExtensionNotifier { + @override + ExtensionState build() => const ExtensionState( + extensions: [ + Extension( + id: 'provider-a', + name: 'provider-a', + displayName: 'Audio A', + version: '1.0.0', + description: '', + enabled: true, + status: 'loaded', + hasDownloadProvider: true, + qualityOptions: [ + QualityOption(id: 'LOSSLESS', label: 'Lossless'), + QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res'), + ], + ), + Extension( + id: 'provider-b', + name: 'provider-b', + displayName: 'Audio B', + version: '1.0.0', + description: '', + enabled: true, + status: 'loaded', + hasDownloadProvider: true, + qualityOptions: [ + QualityOption(id: 'opus_256', label: 'Opus 256kbps'), + QualityOption(id: 'custom', label: 'Custom'), + ], + ), + ], + ); +} + +class _PickerSettings extends SettingsNotifier { + final AppSettings _settings; + + _PickerSettings(this._settings); + + @override + AppSettings build() => _settings; +} + +Future _openPicker( + WidgetTester tester, { + Duration? duration, + AppSettings settings = const AppSettings(), + Locale locale = const Locale('en'), + double textScale = 1, + void Function(String, String)? onSelect, +}) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + extensionProvider.overrideWith(_PickerExtensions.new), + settingsProvider.overrideWith(() => _PickerSettings(settings)), + ], + child: MaterialApp( + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => DownloadServicePicker.show( + context, + trackName: 'Selected tracks', + duration: duration, + onSelect: onSelect ?? (_, _) {}, + ), + child: const Text('Open'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('estimates change with the provider and selection stays intact', ( + tester, + ) async { + (String, String)? selected; + await _openPicker( + tester, + duration: const Duration(minutes: 4), + onSelect: (quality, service) => selected = (quality, service), + ); + expect(find.text('≈ 20.2 MB–32.3 MB'), findsOneWidget); + expect(find.text('≈ 20.2 MB–210.9 MB'), findsOneWidget); + await tester.tap(find.text('Audio B')); + await tester.pumpAndSettle(); + expect(find.text('≈ 7.3 MB'), findsOneWidget); + expect(find.text('Size estimate unavailable'), findsOneWidget); + expect(find.text('≈ 20.2 MB–32.3 MB'), findsNothing); + await tester.tap(find.text('Opus 256kbps')); + await tester.pumpAndSettle(); + expect(selected, ('opus_256', 'provider-b')); + expect(find.byType(DownloadServicePicker), findsNothing); + }); + + testWidgets( + 'missing duration stays unknown and downloads remain selectable', + (tester) async { + await _openPicker(tester, locale: const Locale('id')); + expect(find.text('Estimasi ukuran belum tersedia'), findsNWidgets(2)); + expect(find.textContaining('≈'), findsNothing); + await tester.tap(find.text('FLAC Lossless')); + await tester.pumpAndSettle(); + expect(find.byType(DownloadServicePicker), findsNothing); + }, + ); + + testWidgets( + 'conversion estimate is separate and fits narrow, enlarged text', + (tester) async { + tester.view.physicalSize = const Size(320, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await _openPicker( + tester, + duration: const Duration(minutes: 4), + textScale: 1.8, + settings: const AppSettings( + autoConvertDownloads: true, + autoConvertFormat: 'opus', + autoConvertBitrate: '256k', + ), + ); + expect(find.text('≈ 20.2 MB–32.3 MB'), findsOneWidget); + final conversionNote = find.textContaining( + 'After conversion to OPUS: ≈ 7.3 MB', + ); + expect(conversionNote, findsOneWidget); + await tester.ensureVisible(conversionNote); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }, + ); +} diff --git a/test/download_size_estimate_test.dart b/test/download_size_estimate_test.dart new file mode 100644 index 00000000..f9f7d1bf --- /dev/null +++ b/test/download_size_estimate_test.dart @@ -0,0 +1,161 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/utils/download_size_estimate.dart'; + +Track _track(int duration, {String? itemType}) => Track( + id: 'track', + name: 'Track', + artistName: 'Artist', + albumName: 'Album', + duration: duration, + itemType: itemType, +); + +void main() { + const duration = Duration(minutes: 4); + + test('four minutes at 256 kbps is 7,680,000 bytes before overhead', () { + for (final quality in [ + const QualityOption(id: 'opus_256', label: 'Opus'), + const QualityOption(id: 'custom', label: 'Opus 256kbps'), + QualityOption.fromJson({ + 'id': 'custom', + 'label': 'Audio', + 'sizeEstimate': {'bitrateKbps': 256}, + }), + ]) { + final estimate = estimateDownloadSize( + duration: duration, + quality: quality, + )!; + expect(estimate.minBytes, 7680000); + expect(estimate.maxBytes, 7680000); + } + }); + + test( + 'lossless range scales with duration and includes lower capped tiers', + () { + const cd = QualityOption(id: 'LOSSLESS', label: 'Lossless'); + final cdSize = estimateDownloadSize(duration: duration, quality: cd)!; + expect(cdSize.minBytes, 21168000); + expect(cdSize.maxBytes, 33868800); + final batchSize = estimateDownloadSize( + duration: totalDownloadDuration([_track(240), _track(240)]), + quality: cd, + )!; + expect(batchSize.minBytes, cdSize.minBytes * 2); + expect(batchSize.maxBytes, cdSize.maxBytes * 2); + + for (final (id, maximum) in [ + ('HI_RES', 110592000), + ('HI_RES_LOSSLESS', 221184000), + ]) { + final size = estimateDownloadSize( + duration: duration, + quality: QualityOption(id: id, label: ''), + )!; + expect(size.minBytes, cdSize.minBytes); + expect(size.maxBytes, maximum); + } + }, + ); + + test( + 'explicit parameters override legacy assumptions and preserve channels', + () { + final quality = QualityOption.fromJson({ + 'id': 'LOSSLESS', + 'label': 'Custom lossless', + 'sizeEstimate': {'bitDepth': 24, 'sampleRate': 48000, 'channels': 1}, + }); + final estimate = estimateDownloadSize( + duration: duration, + quality: quality, + )!; + expect(estimate.minBytes, 17280000); + expect(estimate.maxBytes, 27648000); + final capped = estimateDownloadSize( + duration: duration, + quality: QualityOption.fromJson({ + 'id': 'best', + 'label': 'Best', + 'sizeEstimate': { + 'bitDepth': 24, + 'sampleRate': 192000, + 'isMaximum': true, + }, + }), + )!; + expect(capped.minBytes, 21168000); + expect(capped.maxBytes, 221184000); + }, + ); + + test( + 'unknown tiers, incomplete or invalid parameters do not invent sizes', + () { + for (final quality in [ + const QualityOption(id: 'best', label: 'Best'), + const QualityOption(id: 'HIGH', label: 'High'), + const QualityOption(id: 'DOLBY_ATMOS', label: 'Spatial'), + const QualityOption(id: 'opus', label: 'Opus'), + const QualityOption( + id: 'custom', + label: 'Best', + description: 'May fall back to Opus 256kbps', + ), + const QualityOption( + id: 'LOSSLESS', + label: '', + sizeEstimate: QualitySizeEstimate(bitDepth: 24), + ), + const QualityOption( + id: 'LOSSLESS', + label: '', + sizeEstimate: QualitySizeEstimate(bitDepth: 0, sampleRate: 48000), + ), + const QualityOption( + id: 'opus_256', + label: '', + sizeEstimate: QualitySizeEstimate(bitrateKbps: -1), + ), + const QualityOption( + id: 'custom', + label: '', + sizeEstimate: QualitySizeEstimate(bitrateKbps: 320, isMaximum: true), + ), + ]) { + expect( + estimateDownloadSize(duration: duration, quality: quality), + isNull, + ); + } + for (final duration in [ + null, + Duration.zero, + const Duration(seconds: -1), + ]) { + expect( + estimateDownloadSize( + duration: duration, + quality: const QualityOption(id: 'mp3_320', label: 'MP3'), + ), + isNull, + ); + } + }, + ); + + test('batch duration is unavailable when tracks are missing duration', () { + expect(totalDownloadDuration([]), isNull); + expect(totalDownloadDuration([_track(240), _track(0)]), isNull); + expect(totalDownloadDuration([_track(-1)]), isNull); + expect(totalDownloadDuration([_track(240, itemType: 'album')]), isNull); + expect( + totalDownloadDuration([_track(120), _track(180)]), + const Duration(minutes: 5), + ); + }); +}