feat(download): estimate file sizes in quality picker

This commit is contained in:
zarzet
2026-09-06 21:40:25 +07:00
parent bfffb8da11
commit c6195bcd10
17 changed files with 652 additions and 10 deletions
+161
View File
@@ -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<void> _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 MB32.3 MB'), findsOneWidget);
expect(find.text('≈ 20.2 MB210.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 MB32.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 MB32.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);
},
);
}
+161
View File
@@ -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),
);
});
}