Files
SpotiFLAC-Mobile/test/now_playing_lyrics_transition_test.dart
T
zarzet a7717ddfbf perf(player): scope position and contrast rebuilds to controls
Keep playback position updates inside the timeline subtree and notify only artwork foreground controls when contrast changes. Preserve layout and transport state; add regression coverage for stable artwork and transport widgets.
2026-09-20 17:20:52 +07:00

1302 lines
45 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:ui' show ImageFilter;
import 'dart:ui' as ui;
import 'package:audio_service/audio_service.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show RenderRepaintBoundary;
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/providers/player_motion_artwork_provider.dart';
import 'package:spotiflac_android/providers/player_artwork_video_provider.dart';
import 'package:spotiflac_android/services/motion_artwork_store.dart';
import 'package:video_player/video_player.dart';
import 'package:spotiflac_android/screens/now_playing_screen.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/mornye_volume_control.dart';
import 'package:spotiflac_android/widgets/mornye_player_queue.dart';
import 'package:spotiflac_android/widgets/mornye_playback_button.dart';
import 'package:spotiflac_android/widgets/mornye_playback_time.dart';
import 'package:spotiflac_android/widgets/mornye_player_actions_sheet.dart';
import 'package:spotiflac_android/widgets/mornye_player_favorite_button.dart';
import 'package:spotiflac_android/widgets/mornye_player_details_sheet.dart';
import 'package:spotiflac_android/widgets/mornye_metadata_row.dart';
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
import 'package:spotiflac_android/widgets/mornye_player_background.dart';
import 'package:spotiflac_android/widgets/mornye_player_artwork.dart';
import 'package:spotiflac_android/widgets/mornye_artwork_contrast.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const backendChannel = MethodChannel('com.zarz.spotiflac/backend');
late StreamController<MediaItem?> mediaItems;
late List<double> volumeWrites;
late Map<String, dynamic> metadataOverrides;
late List<String> metadataReads;
setUp(() {
SharedPreferences.setMockInitialValues({});
mediaItems = StreamController<MediaItem?>.broadcast();
volumeWrites = [];
metadataOverrides = {};
metadataReads = [];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(backendChannel, (call) async {
if (call.method != 'readFileMetadata') {
fail('Unexpected platform call: ${call.method}');
}
final arguments = (call.arguments as Map).cast<String, dynamic>();
final path = arguments['file_path']?.toString() ?? '';
metadataReads.add(path);
final lyrics = path.endsWith('/timed.flac')
? '''<tt xmlns="http://www.w3.org/ns/ttml"><body><div><p begin="00:00.000" end="00:02.000"><span begin="00:00.000">Short</span></p></div></body></tt>'''
: path.endsWith('/many.flac')
? '[00:00.00]First line\n[00:02.00]Second line\n[00:04.00]Third line\n[00:06.00]Fourth line'
: path.endsWith('/second.flac')
? '[00:01.00]Second lyric'
: '[00:01.00]First lyric';
return jsonEncode({
'title': path.endsWith('/second.flac') ? 'Second' : 'First',
'lyrics': lyrics,
...metadataOverrides,
});
});
});
tearDown(() async {
await mediaItems.close();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(backendChannel, null);
});
MediaItem item(String id) => MediaItem(
id: id,
title: id == 'first' ? 'First' : 'Second',
artist: 'Artist',
album: 'Album',
duration: const Duration(minutes: 3),
extras: {'source': 'content://library/$id.flac'},
);
Future<void> pumpNowPlaying(
WidgetTester tester, {
ThemeData? theme,
Size size = const Size(1080, 1920),
PlaybackState? playback,
Stream<PlaybackState>? playbackEvents,
Widget Function(Widget)? wrapPlayer,
MotionArtwork? motionArtwork,
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentMediaItemProvider.overrideWith((ref) => mediaItems.stream),
playerMotionArtworkProvider.overrideWith(
(ref, album) async => motionArtwork,
),
playerArtworkVideoProvider.overrideWith(
(ref, source) => Completer<VideoPlayerController>().future,
),
playerCollectionTrackProvider.overrideWith(
(ref, item) async => Track(
id: item.id,
name: item.title,
artistName: item.artist ?? '',
albumName: item.album ?? '',
duration: item.duration?.inSeconds ?? 0,
source: 'local',
),
),
libraryCollectionsProvider.overrideWith(_TestCollections.new),
playbackStateProvider.overrideWith(
(ref) =>
playbackEvents ??
(playback == null
? const Stream.empty()
: Stream.value(playback)),
),
playQueueProvider.overrideWith((ref) => const Stream.empty()),
systemVolumeProvider.overrideWith((ref) => Stream.value(0.5)),
systemVolumeWriterProvider.overrideWith(
(ref) =>
(value) async => volumeWrites.add(value),
),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
theme: theme,
home:
wrapPlayer?.call(const NowPlayingScreen()) ??
const NowPlayingScreen(),
),
),
);
}
testWidgets(
'portrait motion cover keeps the raised square-cover control positions',
(tester) async {
Future<(double, double)> positions(MotionArtwork? artwork) async {
await pumpNowPlaying(
tester,
theme: MornyeTheme.build(Brightness.dark),
size: const Size(393, 852),
motionArtwork: artwork,
);
mediaItems.add(item('first'));
await tester.pumpAndSettle();
final title = tester.getTopLeft(find.text('First').hitTestable()).dy;
final transport = tester
.getCenter(
find.byWidgetPredicate(
(widget) =>
widget is MornyePlaybackButton &&
widget.icon == CupertinoIcons.play_fill,
),
)
.dy;
expect(title, lessThan(852 * 0.64));
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
return (title, transport);
}
final square = await positions(null);
final portrait = await positions(
const MotionArtwork('file:///cover.mp4', aspectRatio: 0.75),
);
expect(portrait.$1, closeTo(square.$1, 1));
expect(portrait.$2, closeTo(square.$2, 1));
},
);
testWidgets('video contrast updates controls without rebuilding artwork', (
tester,
) async {
await pumpNowPlaying(
tester,
theme: MornyeTheme.build(Brightness.dark),
size: const Size(393, 852),
motionArtwork: const MotionArtwork(
'file:///cover.mp4',
aspectRatio: 0.75,
),
);
mediaItems.add(item('first'));
await tester.pumpAndSettle();
final background = tester.widget<MornyePlayerBackground>(
find.byType(MornyePlayerBackground),
);
final title = find.text('First').hitTestable();
final bounds = tester.getRect(title);
final contrast = tester.widget<MornyeArtworkContrast>(
find.byType(MornyeArtworkContrast),
);
for (final color in [Colors.black, Colors.white]) {
contrast.onChanged({'header': color, 'controls': color, 'volume': color});
await tester.pump();
expect(
tester.widget<MornyePlayerBackground>(
find.byType(MornyePlayerBackground),
),
same(background),
);
expect(tester.getRect(title), bounds);
expect(
tester
.widgetList<MornyePlaybackButton>(find.byType(MornyePlaybackButton))
.where(
(button) => [
CupertinoIcons.backward_fill,
CupertinoIcons.play_fill,
CupertinoIcons.forward_fill,
].contains(button.icon),
)
.map((button) => button.color),
everyElement(color),
);
expect(
tester
.widget<MornyeVolumeControl>(find.byType(MornyeVolumeControl))
.foreground,
color,
);
}
await tester.pumpWidget(const SizedBox());
});
testWidgets('opening Mornye lyrics centers the current wrapped line', (
tester,
) async {
final lyrics = List.generate(100, (index) {
final seconds = index * 2;
final time =
'${(seconds ~/ 60).toString().padLeft(2, '0')}:'
'${(seconds % 60).toString().padLeft(2, '0')}.00';
return '[$time]Line $index with enough words to wrap across several rows';
}).join('\n');
metadataOverrides = {'lyrics': lyrics};
await pumpNowPlaying(
tester,
theme: MornyeTheme.build(Brightness.dark),
size: const Size(393, 780),
playback: PlaybackState(updatePosition: const Duration(seconds: 140)),
);
mediaItems.add(item('many'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(CupertinoIcons.quote_bubble));
await tester.pumpAndSettle();
final current = find.text(
'Line 70 with enough words to wrap across several rows',
);
expect(current, findsOneWidget);
final list = find.ancestor(of: current, matching: find.byType(ListView));
expect(tester.getCenter(current).dy, closeTo(tester.getCenter(list).dy, 2));
await tester.drag(list, const Offset(0, 200));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(CupertinoIcons.quote_bubble));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(CupertinoIcons.quote_bubble));
await tester.pumpAndSettle();
expect(tester.getCenter(current).dy, closeTo(tester.getCenter(list).dy, 2));
expect(tester.takeException(), isNull);
});
testWidgets('Mornye landscape has no top handle or reserved toolbar height', (
tester,
) async {
await pumpNowPlaying(
tester,
theme: MornyeTheme.build(
Brightness.dark,
).copyWith(platform: TargetPlatform.android),
size: const Size(900, 420),
);
mediaItems.add(item('first'));
await tester.pumpAndSettle();
final bar = tester.widget<AppBar>(find.byType(AppBar));
expect(bar.toolbarHeight, 0);
expect(bar.title, isNull);
});
testWidgets('manual lyric scrolling hides controls down and restores them up', (
tester,
) async {
metadataOverrides = {
'lyrics': List.generate(
20,
(index) =>
'[00:${(index * 3).toString().padLeft(2, '0')}.00]Lyric $index with several words on this line',
).join('\n'),
};
await pumpNowPlaying(
tester,
theme: MornyeTheme.build(Brightness.dark),
size: const Size(393, 780),
playback: PlaybackState(updatePosition: const Duration(seconds: 30)),
);
mediaItems.add(item('many'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(CupertinoIcons.quote_bubble));
await tester.pumpAndSettle();
final list = find.byType(ListView);
final initialHeight = tester.getSize(list).height;
final headerTop = tester.getTopLeft(find.text('Second')).dy;
final transport = find.byWidgetPredicate(
(widget) =>
widget is MornyePlaybackButton &&
const [
CupertinoIcons.play_fill,
CupertinoIcons.backward_fill,
CupertinoIcons.forward_fill,
].contains(widget.icon),
);
expect(transport.hitTestable(), findsNWidgets(3));
await tester.drag(list, const Offset(0, -140));
await tester.pumpAndSettle();
expect(tester.getSize(list).height, greaterThan(initialHeight + 100));
expect(tester.getTopLeft(find.text('Second')).dy, closeTo(headerTop, 1));
expect(transport.hitTestable(), findsNothing);
await tester.drag(list, const Offset(0, 140));
await tester.pumpAndSettle();
expect(tester.getSize(list).height, closeTo(initialHeight, 1));
expect(transport.hitTestable(), findsNWidgets(3));
expect(tester.takeException(), isNull);
});
testWidgets('Mornye player background does not reveal the page below', (
tester,
) async {
final background = ValueNotifier<Color>(Colors.white);
addTearDown(background.dispose);
final capture = GlobalKey();
await pumpNowPlaying(
tester,
theme: MornyeTheme.build(Brightness.light),
size: const Size(393, 780),
wrapPlayer: (player) => RepaintBoundary(
key: capture,
child: ValueListenableBuilder<Color>(
valueListenable: background,
child: player,
builder: (context, color, child) => Stack(
fit: StackFit.expand,
children: [
ColoredBox(color: color),
child!,
],
),
),
),
);
mediaItems.add(item('first'));
await tester.pumpAndSettle();
Future<List<int>?> edgePixel() => tester.runAsync(() async {
final boundary = tester.renderObject<RenderRepaintBoundary>(
find.byKey(capture),
);
final image = await boundary.toImage();
final bytes = (await image.toByteData(
format: ui.ImageByteFormat.rawRgba,
))!;
final offset = (image.width + 1) * 4;
final pixel = List<int>.generate(4, (i) => bytes.getUint8(offset + i));
image.dispose();
return pixel;
});
final onWhite = await edgePixel();
background.value = Colors.red;
await tester.pumpAndSettle();
expect(await edgePixel(), onWhite);
expect(tester.takeException(), isNull);
});
testWidgets(
'Mornye player menu floats above its button and retains dark colors',
(tester) async {
await pumpNowPlaying(
tester,
theme: MornyeTheme.build(Brightness.light),
size: const Size(393, 780),
);
mediaItems.add(item('first'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(CupertinoIcons.ellipsis).hitTestable());
await tester.pump();
await tester.pump(const Duration(milliseconds: 40));
final panel = find.byType(MornyePlayerActionsSheet);
final enteringWidth = tester.getSize(panel).width;
await tester.pumpAndSettle();
expect(tester.getSize(panel).width, enteringWidth);
expect(tester.getRect(panel).bottom, lessThan(780 - 16));
expect(tester.getRect(panel).width, 320);
expect(find.byType(BottomSheet), findsNothing);
expect(Theme.of(tester.element(panel)).brightness, Brightness.dark);
expect(
tester.widget<Text>(find.text('Go to Album')).style?.color,
Colors.white,
);
expect(find.byIcon(CupertinoIcons.square_stack), findsOneWidget);
expect(find.byIcon(CupertinoIcons.moon_zzz), findsOneWidget);
expect(find.byIcon(CupertinoIcons.gear_alt), findsNothing);
await tester.tap(find.text('Sleep timer'));
await tester.pumpAndSettle();
expect(
Theme.of(tester.element(find.text('15 minutes'))).brightness,
Brightness.dark,
);
expect(tester.takeException(), isNull);
},
);
testWidgets(
'Mornye title opens artist and album destinations with subtitles',
(tester) async {
await pumpNowPlaying(
tester,
theme: MornyeTheme.build(Brightness.dark),
size: const Size(393, 780),
);
mediaItems.add(item('first'));
await tester.pumpAndSettle();
await tester.tap(find.text('First').hitTestable());
await tester.pumpAndSettle();
expect(find.text('Go to Artist'), findsOneWidget);
expect(find.text('Go to Album'), findsOneWidget);
expect(
find.descendant(
of: find.byType(MornyePlayerNavigationMenu),
matching: find.text('Artist'),
),
findsOneWidget,
);
expect(
find.descendant(
of: find.byType(MornyePlayerNavigationMenu),
matching: find.text('Album'),
),
findsOneWidget,
);
await tester.tapAt(const Offset(5, 770));
await tester.pumpAndSettle();
expect(find.byType(MornyePlayerNavigationMenu), findsNothing);
expect(tester.takeException(), isNull);
},
);
testWidgets('Android Details reads full tags from a restored content URI', (
tester,
) async {
metadataOverrides = {
'album_artist': 'Album Artist',
'genre': 'Pop/Rock',
'composer': 'Composer Name',
'date': '2025-04-16',
'track_number': 4,
};
await pumpNowPlaying(
tester,
theme: MornyeTheme.build(
Brightness.dark,
).copyWith(platform: TargetPlatform.android),
size: const Size(393, 852),
);
mediaItems.add(item('first'));
await tester.pumpAndSettle();
expect(metadataReads, isEmpty);
await tester.tap(find.byIcon(CupertinoIcons.ellipsis).hitTestable());
await tester.pumpAndSettle();
await tester.tap(find.text('Details'));
await tester.pumpAndSettle();
expect(metadataReads, ['content://library/first.flac']);
final details = tester.widget<MornyePlayerDetailsSheet>(
find.byType(MornyePlayerDetailsSheet),
);
expect(
details.rows,
containsAll([
('Title', 'First'),
('Album', 'Album'),
('Album Artist', 'Album Artist'),
('Genre', 'Pop/Rock'),
('Composer', 'Composer Name'),
('Date', '2025-04-16'),
('Track #', '4'),
]),
);
expect(tester.takeException(), isNull);
});
testWidgets(
'Mornye Details keeps long values readable and Done visible while scrolling',
(tester) async {
final rights = List.filled(10, 'A long copyright value').join(' ');
metadataOverrides = {
'title': 'First',
'artist': 'Artist',
'album': 'Album',
'genre': 'Rock',
'composer': 'Composer',
'isrc': 'USAAA2600001',
'copyright': rights,
'format': 'flac',
'sample_rate': 96000,
'bit_depth': 24,
};
await pumpNowPlaying(
tester,
theme: MornyeTheme.build(Brightness.light),
size: const Size(393, 780),
);
mediaItems.add(
item('first').copyWith(extras: {'source': '/library/first.flac'}),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(CupertinoIcons.ellipsis).hitTestable());
await tester.pumpAndSettle();
await tester.tap(find.text('Details'));
await tester.pumpAndSettle();
final sheet = find.byType(MornyePlayerDetailsSheet);
expect(sheet, findsOneWidget);
expect(Theme.of(tester.element(sheet)).brightness, Brightness.dark);
expect(
find.descendant(of: sheet, matching: find.byType(Card)),
findsNothing,
);
expect(
find.descendant(of: sheet, matching: find.byType(MornyeGlass)),
findsOneWidget,
);
final titleRow = find.widgetWithText(MornyeMetadataRow, 'Title');
expect(
tester
.getTopLeft(
find.descendant(of: titleRow, matching: find.text('First')),
)
.dy,
greaterThan(tester.getBottomLeft(find.text('Title')).dy),
);
await tester.scrollUntilVisible(
find.text(rights),
200,
scrollable: find.descendant(
of: sheet,
matching: find.byType(Scrollable),
),
);
expect(tester.getSize(find.text(rights)).height, greaterThan(40));
expect(find.text('Done').hitTestable(), findsOneWidget);
await tester.tap(find.text('Done'));
await tester.pumpAndSettle();
expect(sheet, findsNothing);
expect(tester.takeException(), isNull);
},
);
testWidgets(
'Mornye star shares Library favorites across tracks and compact headers',
(tester) async {
await pumpNowPlaying(
tester,
theme: MornyeTheme.build(Brightness.light),
size: const Size(393, 780),
);
mediaItems.add(item('first'));
await tester.pumpAndSettle();
final container = ProviderScope.containerOf(
tester.element(find.byType(NowPlayingScreen)),
);
final star = find.byIcon(CupertinoIcons.star).hitTestable();
expect(star, findsOneWidget);
await tester.tap(star);
await tester.pumpAndSettle();
expect(
find.byIcon(CupertinoIcons.star_fill).hitTestable(),
findsOneWidget,
);
expect(
container.read(libraryCollectionsProvider).loved.single.track.id,
'first',
);
await tester.tap(find.byIcon(CupertinoIcons.list_bullet));
await tester.pumpAndSettle();
expect(
find.byIcon(CupertinoIcons.star_fill).hitTestable(),
findsOneWidget,
);
mediaItems.add(item('second'));
await tester.pumpAndSettle();
expect(find.byIcon(CupertinoIcons.star).hitTestable(), findsOneWidget);
mediaItems.add(item('first'));
await tester.pumpAndSettle();
expect(
find.byIcon(CupertinoIcons.star_fill).hitTestable(),
findsOneWidget,
);
// A change from Library must also update the player, without reopening it.
final track = container
.read(libraryCollectionsProvider)
.loved
.single
.track;
await container
.read(libraryCollectionsProvider.notifier)
.toggleLoved(track);
await tester.pumpAndSettle();
expect(find.byIcon(CupertinoIcons.star).hitTestable(), findsOneWidget);
expect(
find.byType(MornyePlayerFavoriteButton).hitTestable(),
findsOneWidget,
);
expect(tester.takeException(), isNull);
},
);
testWidgets('Mornye lyrics stay blurred until playback starts', (
tester,
) async {
final playback = StreamController<PlaybackState>();
addTearDown(playback.close);
await pumpNowPlaying(
tester,
theme: MornyeTheme.build(Brightness.dark),
size: const Size(393, 780),
playbackEvents: playback.stream,
);
mediaItems.add(item('many'));
playback.add(
PlaybackState(
processingState: AudioProcessingState.loading,
playing: true,
updatePosition: const Duration(seconds: 2),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
await tester.tap(find.byIcon(CupertinoIcons.quote_bubble));
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
void expectLine(String text, {required bool active}) {
final line = find.text(text);
final filter = tester.widget<ImageFiltered>(
find.ancestor(of: line, matching: find.byType(ImageFiltered)).first,
);
final opacity = tester.widget<AnimatedOpacity>(
find.ancestor(of: line, matching: find.byType(AnimatedOpacity)).first,
);
expect(opacity.opacity, active ? 1 : 0.48);
expect(filter.enabled, !active, reason: '$text: ${filter.imageFilter}');
}
expectLine('First line', active: false);
expectLine('Second line', active: false);
expectLine('Third line', active: false);
playback.add(PlaybackState(processingState: AudioProcessingState.ready));
await tester.pumpAndSettle();
expectLine('First line', active: false);
playback.add(
PlaybackState(
processingState: AudioProcessingState.ready,
playing: true,
updatePosition: const Duration(seconds: 2),
),
);
await tester.pump();
for (var frame = 0; frame < 3; frame++) {
await tester.pump(const Duration(milliseconds: 160));
}
expectLine('First line', active: false);
expectLine('Second line', active: true);
expectLine('Third line', active: false);
playback.add(
PlaybackState(
processingState: AudioProcessingState.ready,
updatePosition: const Duration(seconds: 2),
),
);
await tester.pumpAndSettle();
expectLine('Second line', active: true);
playback.add(
PlaybackState(
processingState: AudioProcessingState.buffering,
playing: true,
updatePosition: const Duration(seconds: 2),
),
);
await tester.pump();
for (var frame = 0; frame < 3; frame++) {
await tester.pump(const Duration(milliseconds: 160));
}
expectLine('First line', active: false);
expectLine('Second line', active: false);
expectLine('Third line', active: false);
expect(tester.takeException(), isNull);
});
testWidgets(
'Mornye lyrics keep the active line sharp with bold leading-aligned text',
(tester) async {
await pumpNowPlaying(
tester,
theme: MornyeTheme.build(Brightness.light),
size: const Size(393, 780),
playback: PlaybackState(updatePosition: const Duration(seconds: 2)),
);
mediaItems.add(item('many'));
await tester.pumpAndSettle(const Duration(milliseconds: 16));
await tester.tap(find.byIcon(CupertinoIcons.quote_bubble));
await tester.pumpAndSettle(const Duration(milliseconds: 16));
final active = find.text('Second line');
final inactive = find.text('Third line');
final activeText = tester.widget<Text>(active);
expect(activeText.textAlign, TextAlign.start);
expect(activeText.style?.fontWeight, FontWeight.bold);
expect(activeText.style?.fontSize, 28);
expect(tester.getTopLeft(active).dx, tester.getTopLeft(inactive).dx);
expect(
tester.getTopLeft(active).dx,
tester.getTopLeft(find.byType(ListView)).dx + 24,
);
final activeFilters = tester.widgetList<ImageFiltered>(
find.ancestor(of: active, matching: find.byType(ImageFiltered)),
);
expect(activeFilters.any((filter) => !filter.enabled), isTrue);
final inactiveFilters = tester.widgetList<ImageFiltered>(
find.ancestor(of: inactive, matching: find.byType(ImageFiltered)),
);
expect(
inactiveFilters.any(
(filter) =>
filter.enabled &&
filter.imageFilter == ImageFilter.blur(sigmaX: 0.6, sigmaY: 0.6),
),
isTrue,
);
expect(tester.takeException(), isNull);
},
);
testWidgets(
'Mornye queue replaces artwork while volume and playback stay in place',
(tester) async {
await pumpNowPlaying(
tester,
theme: MornyeTheme.build(Brightness.light),
size: const Size(393, 780),
);
mediaItems.add(item('first'));
await tester.pumpAndSettle(const Duration(milliseconds: 16));
await tester.tap(find.byIcon(CupertinoIcons.quote_bubble));
await tester.pumpAndSettle(const Duration(milliseconds: 16));
expect(find.text('First lyric').hitTestable(), findsOneWidget);
final volume = tester.getRect(find.byType(MornyeVolumeControl));
await tester.tap(find.byIcon(CupertinoIcons.list_bullet));
await tester.pumpAndSettle(const Duration(milliseconds: 16));
expect(find.byType(MornyePlayerQueue), findsOneWidget);
expect(find.byType(BottomSheet), findsNothing);
expect(tester.getRect(find.byType(MornyeVolumeControl)), volume);
expect(tester.takeException(), isNull);
await tester.tap(find.byIcon(CupertinoIcons.list_bullet));
await tester.pump();
expect(find.text('First lyric'), findsNothing);
await tester.pump(const Duration(milliseconds: 120));
expect(find.text('First lyric'), findsNothing);
expect(find.byType(MornyePlayerQueue), findsOneWidget);
expect(find.byType(MornyePlayerQueue).hitTestable(), findsNothing);
await tester.pumpAndSettle(const Duration(milliseconds: 16));
expect(find.byType(MornyePlayerQueue), findsNothing);
expect(tester.getRect(find.byType(MornyeVolumeControl)), volume);
},
);
testWidgets('Mornye player renders Apple-style transport controls', (
tester,
) async {
await pumpNowPlaying(tester, theme: MornyeTheme.build(Brightness.light));
mediaItems.add(item('first'));
await tester.pumpAndSettle();
expect(find.byIcon(CupertinoIcons.play_fill), findsOneWidget);
expect(find.byIcon(CupertinoIcons.backward_fill), findsOneWidget);
expect(find.byIcon(CupertinoIcons.forward_fill), findsOneWidget);
expect(find.byType(MornyeVolumeControl), findsOneWidget);
expect(tester.takeException(), isNull);
final nextButton = find.widgetWithIcon(
MornyePlaybackButton,
CupertinoIcons.forward_fill,
);
final nextState = tester.state(nextButton);
mediaItems.add(item('second'));
await tester.pumpAndSettle();
expect(tester.state(nextButton), same(nextState));
});
testWidgets('Mornye elapsed and remaining times roll on the same second', (
tester,
) async {
final playbackEvents = StreamController<PlaybackState>();
addTearDown(playbackEvents.close);
await pumpNowPlaying(
tester,
theme: MornyeTheme.build(Brightness.dark),
size: const Size(393, 780),
playbackEvents: playbackEvents.stream,
);
mediaItems.add(
item(
'first',
).copyWith(duration: const Duration(seconds: 180, milliseconds: 600)),
);
Future<void> positionAt(int milliseconds) async {
playbackEvents.add(
PlaybackState(
processingState: AudioProcessingState.ready,
playing: false,
updatePosition: Duration(milliseconds: milliseconds),
),
);
await tester.pump();
await tester.pump();
}
final elapsed = find.byKey(const ValueKey('elapsed:first'));
final remaining = find.byKey(const ValueKey('remaining:first'));
void expectTimes(int elapsedSeconds, int remainingSeconds) {
expect(
tester.widget<MornyePlaybackTime>(elapsed).seconds,
elapsedSeconds,
);
expect(
tester.widget<MornyePlaybackTime>(remaining).seconds,
remainingSeconds,
);
}
await positionAt(34200);
await tester.pumpAndSettle();
expectTimes(34, 146);
final transport = tester
.widgetList<MornyePlaybackButton>(find.byType(MornyePlaybackButton))
.toList();
await positionAt(34800);
expectTimes(34, 146);
final updatedTransport = tester
.widgetList<MornyePlaybackButton>(find.byType(MornyePlaybackButton))
.toList();
for (var i = 0; i < transport.length; i++) {
expect(updatedTransport[i], same(transport[i]));
}
await positionAt(35000);
await tester.pump(const Duration(milliseconds: 80));
expectTimes(35, 145);
double incomingOffset(Finder label) => tester
.widget<SlideTransition>(
find
.ancestor(
of: find.descendant(of: label, matching: find.text('5')),
matching: find.byType(SlideTransition),
)
.first,
)
.position
.value
.dy;
expect(incomingOffset(elapsed), greaterThan(0));
expect(incomingOffset(remaining), -incomingOffset(elapsed));
// Another position report within this second must not restart either roll.
await positionAt(35200);
await tester.pump(const Duration(milliseconds: 200));
expect(incomingOffset(elapsed), 0);
expect(incomingOffset(remaining), 0);
await positionAt(35900);
expectTimes(35, 145);
await positionAt(36000);
expectTimes(36, 144);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
testWidgets('Mornye volume stays above the bottom actions on a phone', (
tester,
) async {
await pumpNowPlaying(
tester,
theme: MornyeTheme.build(Brightness.dark),
size: const Size(393, 700),
);
mediaItems.add(item('first'));
await tester.pumpAndSettle();
final volume = find.byType(MornyeVolumeControl);
final lyricsButton = find.byIcon(CupertinoIcons.quote_bubble);
final slider = find.descendant(of: volume, matching: find.byType(Slider));
expect(
tester.getRect(volume).bottom,
lessThan(tester.getRect(lyricsButton).top),
);
expect(tester.getSize(slider).height, greaterThanOrEqualTo(44));
final gesture = await tester.startGesture(tester.getCenter(slider));
await gesture.moveBy(const Offset(60, 0));
await tester.pump(const Duration(milliseconds: 16));
expect(volumeWrites, isNotEmpty);
expect(volumeWrites.last, greaterThan(0.5));
await gesture.up();
await tester.pumpAndSettle(const Duration(milliseconds: 16));
expect(volumeWrites, isNotEmpty);
expect(volumeWrites.last, greaterThan(0.5));
expect(tester.takeException(), isNull);
});
testWidgets(
'Mornye landscape shows lyrics automatically without bottom actions',
(tester) async {
await pumpNowPlaying(
tester,
theme: MornyeTheme.build(Brightness.dark),
size: const Size(393, 852),
);
mediaItems.add(item('first'));
await tester.pumpAndSettle();
tester.view.physicalSize = const Size(852, 393);
tester.view.padding = FakeViewPadding(left: 59, right: 59, bottom: 21);
addTearDown(tester.view.resetPadding);
await tester.pumpAndSettle();
final artwork = find.byType(Hero);
final volume = find.byType(MornyeVolumeControl);
final artRect = tester.getRect(artwork);
expect(artRect.width, closeTo(artRect.height, 0.1));
expect(artRect.width, greaterThan(140));
expect(artRect.top, greaterThanOrEqualTo(36));
final header = find.text('First').hitTestable();
final lyric = find.text('First lyric').hitTestable();
expect(header, findsOneWidget);
expect(lyric, findsOneWidget);
expect(tester.getRect(header).left, greaterThan(artRect.right));
expect(tester.getRect(lyric).left, greaterThan(artRect.right));
expect(
tester.getRect(lyric).top,
greaterThan(tester.getRect(header).bottom),
);
expect(volume, findsNothing);
expect(find.byIcon(CupertinoIcons.quote_bubble), findsNothing);
expect(find.byIcon(CupertinoIcons.list_bullet), findsNothing);
expect(tester.takeException(), isNull);
await tester.tap(artwork);
await tester.pumpAndSettle();
expect(volume.hitTestable(), findsOneWidget);
expect(
find.byIcon(CupertinoIcons.play_fill).hitTestable(),
findsOneWidget,
);
final volumeSlider = find.descendant(
of: volume,
matching: find.byType(Slider),
);
final drag = await tester.startGesture(tester.getCenter(volumeSlider));
await drag.moveBy(const Offset(30, 0));
await tester.pump(const Duration(seconds: 5));
expect(volume.hitTestable(), findsOneWidget);
expect(volumeWrites, isNotEmpty);
await drag.up();
await tester.pump(const Duration(seconds: 4));
await tester.pumpAndSettle();
expect(volume, findsNothing);
expect(find.text('First lyric').hitTestable(), findsOneWidget);
mediaItems.add(item('second'));
await tester.pumpAndSettle();
expect(find.text('Second lyric').hitTestable(), findsOneWidget);
expect(find.text('First lyric'), findsNothing);
tester.view.physicalSize = const Size(393, 852);
tester.view.resetPadding();
await tester.pumpAndSettle();
expect(
find.byIcon(CupertinoIcons.quote_bubble).hitTestable(),
findsOneWidget,
);
expect(
find.byIcon(CupertinoIcons.list_bullet).hitTestable(),
findsOneWidget,
);
expect(
tester.getRect(artwork).bottom,
lessThan(tester.getRect(volume).top),
);
expect(tester.takeException(), isNull);
},
);
testWidgets('Mornye cover stays attached throughout opening and closing', (
tester,
) async {
tester.view.padding = FakeViewPadding(top: 59, bottom: 34);
tester.view.viewPadding = FakeViewPadding(top: 59, bottom: 34);
addTearDown(tester.view.resetPadding);
addTearDown(tester.view.resetViewPadding);
await pumpNowPlaying(
tester,
theme: MornyeTheme.build(Brightness.dark),
size: const Size(393, 852),
wrapPlayer: (_) => Consumer(
builder: (context, ref, _) {
ref.watch(currentMediaItemProvider);
return Scaffold(
body: Align(
alignment: Alignment.bottomLeft,
child: TextButton(
onPressed: () => Navigator.of(context).push(NowPlayingRoute()),
child: const Hero(
tag: kNowPlayingArtworkHeroTag,
child: SizedBox.square(dimension: 38, child: Text('Open')),
),
),
),
);
},
),
);
mediaItems.add(item('first'));
await tester.pumpAndSettle();
await tester.tap(find.text('Open'));
await tester.pump();
void expectAttached() {
final panel = tester.getRect(find.byType(MornyePlayerBackground));
final artwork = tester.getRect(find.byType(MornyePlayerArtwork));
expect(artwork.left, closeTo(panel.left, 0.01));
expect(artwork.top, closeTo(panel.top, 0.01));
expect(artwork.width, closeTo(393, 0.01));
expect(artwork.height, closeTo(393, 0.01));
}
for (var frame = 0; frame < 8; frame++) {
await tester.pump(const Duration(milliseconds: 40));
expectAttached();
}
await tester.pumpAndSettle();
expectAttached();
Navigator.of(tester.element(find.byType(NowPlayingScreen))).pop();
await tester.pump();
for (var frame = 0; frame < 5; frame++) {
await tester.pump(const Duration(milliseconds: 40));
expectAttached();
expect(
tester.getTopLeft(find.byType(MornyePlayerBackground)).dy,
greaterThan(0),
);
}
await tester.pumpAndSettle();
expect(find.byType(NowPlayingScreen), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('Mornye lyrics replace artwork while controls stay in place', (
tester,
) async {
await pumpNowPlaying(
tester,
theme: MornyeTheme.build(Brightness.dark),
size: const Size(393, 700),
);
mediaItems.add(item('first'));
await tester.pumpAndSettle();
final artwork = find.byType(Hero);
final fullArtwork = tester.getRect(artwork);
expect(fullArtwork.height, fullArtwork.width);
final volume = find.byType(MornyeVolumeControl);
final volumeRect = tester.getRect(volume);
final play = find.byIcon(CupertinoIcons.play_fill);
final playRect = tester.getRect(play);
final lyricsButton = find.byIcon(CupertinoIcons.quote_bubble);
await tester.tap(lyricsButton);
await tester.pump();
await tester.pump(const Duration(milliseconds: 150));
final fullCover = find.byKey(const ValueKey('full-player-artwork'));
double fullCoverOpacity() => tester
.widget<FadeTransition>(
find
.ancestor(of: fullCover, matching: find.byType(FadeTransition))
.first,
)
.opacity
.value;
expect(fullCover, findsOneWidget);
expect(fullCoverOpacity(), inExclusiveRange(0.0, 1.0));
await tester.pumpAndSettle();
expect(find.byType(PageView), findsNothing);
expect(find.text('First lyric').hitTestable(), findsOneWidget);
expect(tester.getSize(artwork).width, lessThan(fullArtwork.width));
expect(fullArtwork.top, 0);
expect(fullArtwork.width, 393);
expect(tester.getRect(volume), volumeRect);
expect(tester.getRect(play), playRect);
mediaItems.add(item('second'));
await tester.pumpAndSettle();
expect(find.text('Second lyric').hitTestable(), findsOneWidget);
expect(find.text('First lyric'), findsNothing);
await tester.tap(lyricsButton);
await tester.pump();
await tester.pump(const Duration(milliseconds: 150));
expect(fullCoverOpacity(), inExclusiveRange(0.0, 1.0));
expect(
find.descendant(
of: find.byType(MornyePlayerBackground),
matching: find.byType(Hero),
),
findsOneWidget,
);
await tester.pumpAndSettle();
expect(tester.getRect(artwork), fullArtwork);
expect(find.text('Second lyric').hitTestable(), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets(
'automatic SAF track change refreshes lyrics while Lyrics page is active',
(tester) async {
await pumpNowPlaying(tester);
mediaItems.add(item('first'));
await tester.pumpAndSettle();
await tester.drag(find.byType(PageView), const Offset(-700, 0));
await tester.pumpAndSettle();
expect(find.text('First lyric'), findsOneWidget);
mediaItems.add(item('second'));
await tester.pumpAndSettle();
expect(find.text('Second lyric'), findsOneWidget);
expect(find.text('First lyric'), findsNothing);
},
);
testWidgets('timed lyric fills text fragments in reading order', (
tester,
) async {
const lyricText = 'AAAA BBBB CCCC DDDD EEEE FFFF GGGG HHHH';
metadataOverrides['lyrics'] =
'<tt xmlns="http://www.w3.org/ns/ttml"><body><div>'
'<p begin="00:00.000" end="00:08.000">'
'<span begin="00:00.000">$lyricText</span>'
'</p></div></body></tt>';
await pumpNowPlaying(
tester,
theme: MornyeTheme.build(Brightness.dark),
size: const Size(320, 844),
playback: PlaybackState(
processingState: AudioProcessingState.ready,
playing: false,
updatePosition: const Duration(seconds: 2),
),
);
mediaItems.add(item('first'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(CupertinoIcons.quote_bubble));
await tester.pumpAndSettle();
final paintFinder = find.descendant(
of: find.bySemanticsLabel(lyricText),
matching: find.byType(CustomPaint),
);
final painter = tester.widget<CustomPaint>(paintFinder).painter!;
final size = tester.getSize(paintFinder);
final highlightedPixels = await tester.runAsync(() async {
final recorder = ui.PictureRecorder();
painter.paint(Canvas(recorder), size);
final picture = recorder.endRecording();
final image = await picture.toImage(
size.width.ceil(),
size.height.ceil(),
);
final bytes = (await image.toByteData(
format: ui.ImageByteFormat.rawRgba,
))!;
var upper = 0;
var lower = 0;
for (var y = 0; y < image.height; y++) {
for (var x = 0; x < image.width; x++) {
if (bytes.getUint8((y * image.width + x) * 4 + 3) < 230) continue;
if (y < image.height ~/ 2) {
upper++;
} else {
lower++;
}
}
}
image.dispose();
picture.dispose();
return (upper, lower);
});
expect(highlightedPixels!.$1, greaterThan(0));
expect(highlightedPixels.$2, 0);
expect(tester.takeException(), isNull);
});
testWidgets('short timed lyric remains horizontally centered', (
tester,
) async {
await pumpNowPlaying(tester);
mediaItems.add(item('timed'));
await tester.pumpAndSettle();
await tester.drag(find.byType(PageView), const Offset(-700, 0));
await tester.pumpAndSettle();
final lyric = find.bySemanticsLabel('Short');
expect(lyric, findsOneWidget);
expect(tester.getCenter(lyric).dx, closeTo(540, 1));
});
testWidgets('Now Playing menu exposes Go to Album when album is known', (
tester,
) async {
await pumpNowPlaying(tester);
mediaItems.add(item('first'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.more_vert));
await tester.pumpAndSettle();
expect(find.text('Go to Album'), findsOneWidget);
expect(find.byIcon(Icons.album_outlined), findsOneWidget);
});
testWidgets('Now Playing menu exposes sleep timer duration choices', (
tester,
) async {
await pumpNowPlaying(tester);
mediaItems.add(item('first'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.more_vert));
await tester.pumpAndSettle();
await tester.tap(find.text('Sleep timer'));
await tester.pumpAndSettle();
expect(find.text('15 minutes'), findsOneWidget);
expect(find.text('30 minutes'), findsOneWidget);
expect(find.text('45 minutes'), findsOneWidget);
expect(find.text('60 minutes'), findsOneWidget);
expect(find.text('Turn off sleep timer'), findsNothing);
});
}
class _TestCollections extends LibraryCollectionsNotifier {
@override
LibraryCollectionsState build() => LibraryCollectionsState(isLoaded: true);
@override
Future<bool> toggleLoved(Track track) async {
final key = trackCollectionKey(track);
final added = !state.isLoved(track);
state = state.copyWith(
loved: added
? [
...state.loved,
CollectionTrackEntry(
key: key,
track: track,
addedAt: DateTime.now(),
),
]
: state.loved.where((entry) => entry.key != key).toList(),
);
return added;
}
}