perf(mornye): streamline download source and quality picker

This commit is contained in:
zarzet committed 2026-10-07 00:17:40 +07:00
1 parent a7c17ad316
commit b33824e267
3 files changed
+466 -69

No files matched your search

@@ -0,0 +1,199 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:liquid_glass_easy/liquid_glass_easy.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/l10n/l10n.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/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/app_snack_bar.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
import 'package:spotiflac_android/widgets/track_detail_actions.dart';
import 'performance_probe.dart';
class _Settings extends SettingsNotifier {
@override
AppSettings build() => const AppSettings();
}
class _Extensions extends ExtensionNotifier {
@override
ExtensionState build() => ExtensionState(
extensions: [
for (var source = 0; source < 4; source++)
Extension(
id: 'source-$source',
name: 'source-$source',
displayName: 'Source $source',
version: '1.0.0',
description: '',
enabled: true,
status: 'loaded',
hasDownloadProvider: true,
qualityOptions: [
for (var quality = 0; quality < 16; quality++)
QualityOption(
id: 'quality-$quality',
label: 'Quality $quality',
description: 'Example audio format',
),
],
),
],
);
@override
void refreshEnabledExtensionHealth({bool force = false}) {}
}
void main() {
final binding = PerformanceTestBinding.ensureInitialized();
for (final brightness in Brightness.values) {
testWidgets(
'download glass, source changes and queue feedback in $brightness',
(tester) async {
final probe = PerformanceProbe(
binding,
tester,
suite: 'download_picker_${brightness.name}',
);
expect(ui.ImageFilter.isShaderFilterSupported, isTrue);
final capture = GlobalKey();
(String, String)? selection;
await tester.pumpWidget(
ProviderScope(
overrides: [
settingsProvider.overrideWith(_Settings.new),
extensionProvider.overrideWith(_Extensions.new),
],
child: MaterialApp(
theme: MornyeTheme.build(brightness),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
builder: (_, child) => RepaintBoundary(
key: capture,
child: AppScaffoldMessenger(child: child!),
),
home: Scaffold(
body: Builder(
builder: (context) => Stack(
fit: StackFit.expand,
children: [
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Color(0xff76534b),
Color(0xff385763),
Color(0xff92836b),
],
),
),
),
Center(
child: TextButton(
onPressed: () => DownloadServicePicker.show(
context,
trackName: 'Example track',
artistName: 'Example artist',
onSelect: (quality, service) {
selection = (quality, service);
showQueuedSnackbar(context, 3, 0);
},
),
child: const Text('Open download'),
),
),
],
),
),
),
),
),
);
await tester.runAsync(() => LiquidGlassShaders.ensureLoaded());
await probe.wait(const Duration(milliseconds: 600));
await tester.tap(find.text('Open download'));
await probe.wait(const Duration(milliseconds: 500));
expect(find.byType(MornyeGlassPanel), findsOneWidget);
Future<void> savePose(String pose) async {
await tester.runAsync(() async {
final boundary = tester.renderObject<RenderRepaintBoundary>(
find.byKey(capture),
);
final image = await boundary.toImage(
pixelRatio: tester.view.devicePixelRatio,
);
try {
final bytes = (await image.toByteData(
format: ui.ImageByteFormat.png,
))!;
final directory = await getTemporaryDirectory();
final file = File(
'${directory.path}/download-${brightness.name}-$pose.png',
);
await file.writeAsBytes(
bytes.buffer.asUint8List(
bytes.offsetInBytes,
bytes.lengthInBytes,
),
);
debugPrint('DOWNLOAD_GLASS_POSE: ${file.path}');
} finally {
image.dispose();
}
});
}
await savePose('panel');
await probe.measure('change_sources', () async {
for (final source in [1, 2, 3, 0]) {
await tester.tap(find.text('Source $source'));
await probe.wait(const Duration(milliseconds: 150));
}
});
final scroll = tester.state<ScrollableState>(
find.descendant(
of: find.byType(DownloadServicePicker),
matching: find.byType(Scrollable),
),
);
await probe.measure('scroll_qualities', () async {
await scroll.position.animateTo(
scroll.position.maxScrollExtent,
duration: const Duration(milliseconds: 500),
curve: Curves.linear,
);
await scroll.position.animateTo(
0,
duration: const Duration(milliseconds: 500),
curve: Curves.linear,
);
});
await tester.tap(find.text('Quality 1'));
await probe.wait(const Duration(milliseconds: 500));
expect(selection, ('quality-1', 'source-0'));
expect(find.byType(DownloadServicePicker), findsNothing);
expect(find.byType(MornyeGlassPanel), findsOneWidget);
await savePose('queue');
await probe.finish(
metadata: {
'scope':
'production download picker and queue feedback with fixture providers; no downloads',
'brightness': brightness.name,
},
);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
},
);
}
}
+123 -55
View File
@@ -40,19 +40,23 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
required void Function(String quality, String service) onSelect,
}) {
final colorScheme = Theme.of(context).colorScheme;
final mornye = context.isMornye;
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: context.isMornye
backgroundColor: mornye
? Colors.transparent
: colorScheme.surfaceContainerHigh,
elevation: context.isMornye ? 0 : null,
barrierColor: context.isMornye
? Colors.black.withValues(alpha: 0.26)
elevation: mornye ? 0 : null,
// Only the glass draws the sheet outline, including its bottom corners.
shape: mornye ? const RoundedRectangleBorder() : null,
clipBehavior: mornye ? Clip.none : null,
barrierColor: mornye
? CupertinoDynamicColor.resolve(kCupertinoModalBarrierColor, context)
: null,
isScrollControlled: true,
showDragHandle: !context.isMornye,
showDragHandle: !mornye,
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.88,
),
@@ -65,16 +69,16 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
recommendedService: recommendedService,
);
return context.isMornye
? MornyeGlassPanel.overlay(
tintOpacity: colorScheme.brightness == Brightness.dark
? 0.68
: 0.75,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const AppSheetHandle(),
Flexible(child: picker),
],
? Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: MornyeGlassPanel.overlay(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const AppSheetHandle(),
Flexible(child: picker),
],
),
),
)
: picker;
@@ -103,7 +107,12 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ref.read(extensionProvider.notifier).refreshEnabledExtensionHealth();
final notifier = ref.read(extensionProvider.notifier);
for (final extension in _downloadExtensions()) {
if (extension.hasServiceHealth) {
unawaited(notifier.checkExtensionHealth(extension.id));
}
}
});
final downloadExtensions = _downloadExtensions();
final recommended = widget.recommendedService;
@@ -132,6 +141,7 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
}
void _selectService(Extension extension) {
if (_selectedService == extension.id) return;
setState(() => _selectedService = extension.id);
if (extension.hasServiceHealth) {
unawaited(
@@ -143,8 +153,12 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final extensionState = ref.watch(extensionProvider);
final downloadExtensions = _downloadExtensions();
final extensions = ref.watch(
extensionProvider.select((state) => state.extensions),
);
final downloadExtensions = extensions
.where((ext) => ext.enabled && ext.hasDownloadProvider)
.toList(growable: false);
final hasProviders = downloadExtensions.isNotEmpty;
final qualityOptions = _getQualityOptions(downloadExtensions);
final qualityRows = [
@@ -199,10 +213,11 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
children: [
for (final ext in downloadExtensions)
_ServiceChip(
key: ValueKey(ext.id),
label: ext.displayName,
isRecommended: widget.recommendedService == ext.id,
healthStatus: ext.hasServiceHealth
? extensionState.healthStatuses[ext.id]?.status
healthServiceId: ext.hasServiceHealth
? ext.id
: null,
isSelected: _selectedService == ext.id,
onTap: () => _selectService(ext),
@@ -233,10 +248,7 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
if (context.isMornye)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Material(
color: MornyeTheme.controlFill(context),
borderRadius: BorderRadius.circular(24),
clipBehavior: Clip.antiAlias,
child: RepaintBoundary(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -407,15 +419,16 @@ class _ServiceChip extends StatelessWidget {
final bool isRecommended;
final VoidCallback? onTap;
final String? iconPath;
final String? healthStatus;
final String? healthServiceId;
const _ServiceChip({
super.key,
required this.label,
required this.isSelected,
required this.onTap,
this.isRecommended = false,
this.iconPath,
this.healthStatus,
this.healthServiceId,
});
@override
@@ -435,15 +448,25 @@ class _ServiceChip extends StatelessWidget {
borderRadius: BorderRadius.circular(24),
color: isSelected
? colorScheme.primary.withValues(alpha: 0.14)
: MornyeTheme.controlFill(context),
: colorScheme.onSurface.withValues(alpha: 0.05),
onPressed: onTap,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isSelected) ...[
Icon(CupertinoIcons.checkmark, color: foreground, size: 18),
const SizedBox(width: 8),
],
// Reserve selection space so switching sources never reflows
// the chips and changes the sheet height under the finger.
SizedBox(
width: 18,
height: 18,
child: isSelected
? Icon(
CupertinoIcons.checkmark,
color: foreground,
size: 18,
)
: null,
),
const SizedBox(width: 8),
if (iconPath != null) ...[
ClipRRect(
borderRadius: BorderRadius.circular(4),
@@ -451,6 +474,10 @@ class _ServiceChip extends StatelessWidget {
File(iconPath!),
width: 18,
height: 18,
cacheWidth: (18 * MediaQuery.devicePixelRatioOf(context))
.ceil(),
cacheHeight: (18 * MediaQuery.devicePixelRatioOf(context))
.ceil(),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => Icon(
CupertinoIcons.square_grid_2x2,
@@ -466,7 +493,7 @@ class _ServiceChip extends StatelessWidget {
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: foreground,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
fontWeight: FontWeight.w600,
),
),
),
@@ -482,9 +509,9 @@ class _ServiceChip extends StatelessWidget {
),
),
],
if (healthStatus != null) ...[
if (healthServiceId != null) ...[
const SizedBox(width: 8),
_ServiceHealthDot(status: healthStatus!),
_ServiceHealthDot(serviceId: healthServiceId!),
],
],
),
@@ -510,8 +537,8 @@ class _ServiceChip extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (healthStatus != null) ...[
_ServiceHealthDot(status: healthStatus!),
if (healthServiceId != null) ...[
_ServiceHealthDot(serviceId: healthServiceId!),
const SizedBox(width: 8),
],
if (iconPath != null) ...[
@@ -521,6 +548,10 @@ class _ServiceChip extends StatelessWidget {
File(iconPath!),
width: 18,
height: 18,
cacheWidth: (18 * MediaQuery.devicePixelRatioOf(context))
.ceil(),
cacheHeight: (18 * MediaQuery.devicePixelRatioOf(context))
.ceil(),
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Icon(
Icons.extension,
@@ -549,13 +580,20 @@ class _ServiceChip extends StatelessWidget {
}
}
class _ServiceHealthDot extends StatelessWidget {
final String status;
class _ServiceHealthDot extends ConsumerWidget {
final String serviceId;
const _ServiceHealthDot({required this.status});
const _ServiceHealthDot({required this.serviceId});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final status =
ref.watch(
extensionProvider.select(
(state) => state.healthStatuses[serviceId]?.status,
),
) ??
'unknown';
final color = _serviceHealthColor(status);
return Tooltip(
message: _serviceHealthTooltip(context, status),
@@ -670,6 +708,8 @@ class _TrackInfoHeader extends StatefulWidget {
class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
bool _expanded = false;
bool _isOverflowing = false;
(double, TextStyle?, TextScaler, TextDirection, Locale?)? _titleLayout;
bool _titleOverflows = false;
@override
Widget build(BuildContext context) {
@@ -697,6 +737,12 @@ class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
widget.coverUrl!,
width: 56,
height: 56,
cacheWidth:
(56 * MediaQuery.devicePixelRatioOf(context))
.ceil(),
cacheHeight:
(56 * MediaQuery.devicePixelRatioOf(context))
.ceil(),
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
Container(
@@ -727,22 +773,35 @@ class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w600);
final titleSpan = TextSpan(
text: widget.trackName,
style: titleStyle,
final titleLayout = (
constraints.maxWidth,
titleStyle,
MediaQuery.textScalerOf(context),
Directionality.of(context),
Localizations.maybeLocaleOf(context),
);
final titlePainter = TextPainter(
text: titleSpan,
maxLines: 1,
textDirection: TextDirection.ltr,
)..layout(maxWidth: constraints.maxWidth);
final titleOverflows = titlePainter.didExceedMaxLines;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _isOverflowing != titleOverflows) {
setState(() => _isOverflowing = titleOverflows);
}
});
if (_titleLayout != titleLayout) {
_titleLayout = titleLayout;
final painter = TextPainter(
text: TextSpan(
text: widget.trackName,
style: titleStyle,
),
maxLines: 1,
textScaler: titleLayout.$3,
textDirection: titleLayout.$4,
locale: titleLayout.$5,
)..layout(maxWidth: constraints.maxWidth);
_titleOverflows = painter.didExceedMaxLines;
painter.dispose();
}
if (_isOverflowing != _titleOverflows) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _isOverflowing != _titleOverflows) {
setState(() => _isOverflowing = _titleOverflows);
}
});
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -788,4 +847,13 @@ class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
),
);
}
@override
void didUpdateWidget(covariant _TrackInfoHeader oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.trackName != widget.trackName) {
_titleLayout = null;
_expanded = false;
}
}
}
+144 -14
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
@@ -9,32 +10,81 @@ import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
class _Extensions extends ExtensionNotifier {
_Extensions(this._qualityCount);
_Extensions(
this._qualityCount, {
this.serviceCount = 1,
this.hasHealth = false,
});
final int _qualityCount;
final int serviceCount;
final bool hasHealth;
final healthRequests = <String>[];
@override
ExtensionState build() => ExtensionState(
extensions: [
Extension(
id: 'example',
name: 'example',
displayName: 'Example',
version: '1.0.0',
description: '',
enabled: true,
status: 'loaded',
hasDownloadProvider: true,
qualityOptions: List.generate(
_qualityCount,
(index) => QualityOption(id: '$index', label: 'Quality $index'),
for (var service = 0; service < serviceCount; service++)
Extension(
id: service == 0 ? 'example' : 'example-$service',
name: 'example',
displayName: service == 0 ? 'Example' : 'Source $service',
version: '1.0.0',
description: '',
enabled: true,
status: 'loaded',
hasDownloadProvider: true,
serviceHealth: hasHealth
? const [
ExtensionServiceHealthCheck(
id: 'health',
url: 'https://example.com/health',
),
]
: const [],
qualityOptions: List.generate(
_qualityCount,
(index) => QualityOption(id: '$index', label: 'Quality $index'),
),
),
if (hasHealth)
const Extension(
id: 'metadata-only',
name: 'metadata-only',
displayName: 'Metadata source',
version: '1.0.0',
description: '',
enabled: true,
status: 'loaded',
hasMetadataProvider: true,
serviceHealth: [
ExtensionServiceHealthCheck(
id: 'health',
url: 'https://example.com/health',
),
],
),
),
],
);
@override
void refreshEnabledExtensionHealth({bool force = false}) {}
void publishHealth(String id, String status) => state = state.copyWith(
healthStatuses: {
...state.healthStatuses,
id: ExtensionHealthStatus(extensionId: id, status: status),
},
);
@override
Future<ExtensionHealthStatus?> checkExtensionHealth(
String extensionId, {
bool force = false,
}) async {
healthRequests.add(extensionId);
return state.healthStatuses[extensionId];
}
}
class _Settings extends SettingsNotifier {
@@ -126,4 +176,84 @@ void main() {
},
);
}
for (final brightness in Brightness.values) {
testWidgets(
'source status stays local and selection keeps chip geometry in $brightness',
(tester) async {
tester.view.physicalSize = const Size(393, 852);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final extensions = _Extensions(3, serviceCount: 2, hasHealth: true);
(String, String)? selection;
await tester.pumpWidget(
ProviderScope(
overrides: [
extensionProvider.overrideWith(() => extensions),
settingsProvider.overrideWith(_Settings.new),
],
child: MaterialApp(
theme: MornyeTheme.build(brightness),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Builder(
builder: (context) => TextButton(
onPressed: () => DownloadServicePicker.show(
context,
trackName: 'Example track',
onSelect: (quality, service) =>
selection = (quality, service),
),
child: const Text('Open'),
),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
final qualityBefore = tester.widget(find.text('Quality 0'));
expect(extensions.healthRequests, ['example', 'example-1']);
final titleBefore = tester.widget(find.text('Example track'));
final source1 = find.ancestor(
of: find.text('Example'),
matching: find.byType(CupertinoButton),
);
final source2 = find.ancestor(
of: find.text('Source 1'),
matching: find.byType(CupertinoButton),
);
final sourceRects = [tester.getRect(source1), tester.getRect(source2)];
extensions.publishHealth('example', 'offline');
await tester.pumpAndSettle();
expect(find.byTooltip('Service offline'), findsOneWidget);
expect(
identical(tester.widget(find.text('Quality 0')), qualityBefore),
isTrue,
);
expect(
identical(tester.widget(find.text('Example track')), titleBefore),
isTrue,
);
expect([tester.getRect(source1), tester.getRect(source2)], sourceRects);
await tester.tap(find.text('Example'));
await tester.pumpAndSettle();
expect(
identical(tester.widget(find.text('Quality 0')), qualityBefore),
isTrue,
);
expect(extensions.healthRequests, ['example', 'example-1']);
await tester.tap(find.text('Source 1'));
await tester.pumpAndSettle();
expect([tester.getRect(source1), tester.getRect(source2)], sourceRects);
await tester.tap(find.text('Quality 1'));
await tester.pumpAndSettle();
expect(selection, ('1', 'example-1'));
expect(find.byType(DownloadServicePicker), findsNothing);
expect(tester.takeException(), isNull);
},
);
}
}