diff --git a/docs/EXTENSION_DEVELOPMENT.md b/docs/EXTENSION_DEVELOPMENT.md index f5b659b8..e5eaf32b 100644 --- a/docs/EXTENSION_DEVELOPMENT.md +++ b/docs/EXTENSION_DEVELOPMENT.md @@ -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 diff --git a/go_backend/extension_manifest.go b/go_backend/extension_manifest.go index f9adc043..6f2d7d1e 100644 --- a/go_backend/extension_manifest.go +++ b/go_backend/extension_manifest.go @@ -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 { diff --git a/go_backend/extension_quality_size_test.go b/go_backend/extension_quality_size_test.go deleted file mode 100644 index f8c0e07f..00000000 --- a/go_backend/extension_quality_size_test.go +++ /dev/null @@ -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) - } -} diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 155c9764..6f87c789 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -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" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 09936484..7e44eb95 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -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" } diff --git a/lib/providers/extension_models.dart b/lib/providers/extension_models.dart index c2f43a30..b993b1d8 100644 --- a/lib/providers/extension_models.dart +++ b/lib/providers/extension_models.dart @@ -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 settings; const QualityOption({ required this.id, - this.kind, required this.label, this.description, - this.sizeEstimate, this.settings = const [], }); factory QualityOption.fromJson(Map 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 - ? QualitySizeEstimate.fromJson( - json['sizeEstimate'] as Map, - ) - : null, settings: (json['settings'] as List?) ?.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 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/album_screen.dart b/lib/screens/album_screen.dart index 9e33a12f..cfc82f0a 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -321,9 +321,6 @@ class _AlbumScreenState extends ConsumerState _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']), diff --git a/lib/screens/artist_screen_widgets.dart b/lib/screens/artist_screen_widgets.dart index 98f05e98..ab5d4914 100644 --- a/lib/screens/artist_screen_widgets.dart +++ b/lib/screens/artist_screen_widgets.dart @@ -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; diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 6addea81..8b38b91d 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -652,7 +652,6 @@ class _HomeTabState extends ConsumerState trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl, - tracks: [track], 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 5352d9f1..538a1902 100644 --- a/lib/screens/home_tab_explore.dart +++ b/lib/screens/home_tab_explore.dart @@ -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) diff --git a/lib/screens/home_tab_import.dart b/lib/screens/home_tab_import.dart index 3e17c439..b25850e1 100644 --- a/lib/screens/home_tab_import.dart +++ b/lib/screens/home_tab_import.dart @@ -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) diff --git a/lib/screens/queue_tab_selection.dart b/lib/screens/queue_tab_selection.dart index a3afa666..321a70fa 100644 --- a/lib/screens/queue_tab_selection.dart +++ b/lib/screens/queue_tab_selection.dart @@ -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; diff --git a/lib/utils/download_size_estimate.dart b/lib/utils/download_size_estimate.dart deleted file mode 100644 index a64cae50..00000000 --- a/lib/utils/download_size_estimate.dart +++ /dev/null @@ -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 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? 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; -} diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index 020e91af..bbc96963 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -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 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 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 { 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 { _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 { ); } - 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 { 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, ); } diff --git a/lib/widgets/track_detail_actions.dart b/lib/widgets/track_detail_actions.dart index b4090865..206ff69d 100644 --- a/lib/widgets/track_detail_actions.dart +++ b/lib/widgets/track_detail_actions.dart @@ -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 queueTracksSkippingDownloaded( context, trackName: '${tracksToQueue.length} tracks', artistName: artistNameForPicker, - tracks: tracksToQueue, recommendedService: recommendedService, onSelect: (quality, service) { ref diff --git a/test/download_service_picker_test.dart b/test/download_service_picker_test.dart deleted file mode 100644 index a679fcfa..00000000 --- a/test/download_service_picker_test.dart +++ /dev/null @@ -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 _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); - }, - ); -} diff --git a/test/download_size_estimate_test.dart b/test/download_size_estimate_test.dart deleted file mode 100644 index aa869b6f..00000000 --- a/test/download_size_estimate_test.dart +++ /dev/null @@ -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), - ); - }); -}