mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 05:12:00 +02:00
feat(mornye): cache motion covers and refine player and search
Save album motion videos in app storage during downloads and prefer offline playback with an aspect-aware layout. Add cache management for saved videos. Center active lyrics on opening, expand lyrics during manual scrolling, and adjust landscape header spacing. Group search rows and skeletons with dividers and remove redundant result icons. Validation: Flutter analysis clean; 723 tests passed, 4 skipped; iOS simulator build installed.
This commit is contained in:
@@ -7,7 +7,7 @@ import 'package:spotiflac_android/widgets/mornye_chrome.dart';
|
||||
void main() {
|
||||
for (final brightness in Brightness.values) {
|
||||
testWidgets(
|
||||
'search glass cards scroll and filters remain usable in $brightness',
|
||||
'search glass sections scroll and filters remain usable in $brightness',
|
||||
(tester) async {
|
||||
var selected = false;
|
||||
await tester.pumpWidget(
|
||||
@@ -31,16 +31,26 @@ void main() {
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: 12,
|
||||
itemBuilder: (context, index) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: MornyeGlassPanel.overlay(
|
||||
radius: 24,
|
||||
child: SizedBox(
|
||||
height: 88,
|
||||
child: Text('Result $index'),
|
||||
itemBuilder: (context, index) =>
|
||||
MornyeGlassPanel.overlay(
|
||||
radius: 24,
|
||||
firstInGroup: index == 0,
|
||||
lastInGroup: index == 11,
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 88,
|
||||
child: Text('Result $index'),
|
||||
),
|
||||
if (index < 11)
|
||||
const Divider(
|
||||
height: 1,
|
||||
indent: 80,
|
||||
endIndent: 12,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
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/providers/player_motion_artwork_provider.dart';
|
||||
import 'package:spotiflac_android/services/motion_artwork_store.dart';
|
||||
|
||||
void main() {
|
||||
const album = (album: 'An Album', artist: 'An Artist');
|
||||
late Directory root;
|
||||
setUp(
|
||||
() async => root = await Directory.systemTemp.createTemp('motion-test-'),
|
||||
);
|
||||
tearDown(() async => root.delete(recursive: true));
|
||||
|
||||
test(
|
||||
'saved video survives restart and app directory relocation offline',
|
||||
() async {
|
||||
var downloads = 0;
|
||||
var location = Directory('${root.path}/old-container');
|
||||
final store = MotionArtworkStore(
|
||||
directory: () async => location,
|
||||
download: (_, output) async {
|
||||
downloads++;
|
||||
await File(output).writeAsBytes([1, 2, 3, 4]);
|
||||
return 0.75;
|
||||
},
|
||||
);
|
||||
final saved = await store.save(
|
||||
album,
|
||||
resolveSource: () async => 'https://example.com/cover.m3u8',
|
||||
);
|
||||
expect(saved?.aspectRatio, 0.75);
|
||||
expect(await File.fromUri(Uri.parse(saved!.source)).readAsBytes(), [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
]);
|
||||
location = await location.rename('${root.path}/new-container');
|
||||
final restarted = MotionArtworkStore(
|
||||
directory: () async => location,
|
||||
download: (_, _) async => throw StateError('offline'),
|
||||
);
|
||||
final cached = await restarted.save((
|
||||
album: ' AN ALBUM ',
|
||||
artist: 'AN ARTIST',
|
||||
), resolveSource: () async => throw StateError('offline'));
|
||||
expect(cached?.source, contains('new-container'));
|
||||
expect(cached?.aspectRatio, 0.75);
|
||||
expect(downloads, 1);
|
||||
final container = ProviderContainer(
|
||||
overrides: [motionArtworkStoreProvider.overrideWithValue(restarted)],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
final playback = await container.read(
|
||||
playerMotionArtworkProvider(album).future,
|
||||
);
|
||||
expect(playback?.source, cached?.source);
|
||||
expect(container.exists(extensionProvider), isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('concurrent tracks in one album download one complete video', () async {
|
||||
final gate = Completer<void>();
|
||||
var downloads = 0;
|
||||
var lookups = 0;
|
||||
final store = MotionArtworkStore(
|
||||
directory: () async => root,
|
||||
download: (_, output) async {
|
||||
downloads++;
|
||||
await gate.future;
|
||||
await File(output).writeAsBytes([1]);
|
||||
return 0.75;
|
||||
},
|
||||
);
|
||||
Future<String?> resolve() async {
|
||||
lookups++;
|
||||
return 'https://example.com/video.mp4';
|
||||
}
|
||||
|
||||
final first = store.save(album, resolveSource: resolve);
|
||||
final second = store.save(album, resolveSource: resolve);
|
||||
gate.complete();
|
||||
final saved = await Future.wait([first, second]);
|
||||
expect(saved.first?.source, saved.last?.source);
|
||||
expect(downloads, 1);
|
||||
expect(lookups, 1);
|
||||
expect(
|
||||
root.listSync().whereType<File>().where((f) => f.path.endsWith('.mp4')),
|
||||
hasLength(1),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'failed optional transfers leave no partial file and can retry',
|
||||
() async {
|
||||
var fail = true;
|
||||
final store = MotionArtworkStore(
|
||||
directory: () async => root,
|
||||
download: (_, output) async {
|
||||
await File(output).writeAsBytes([1]);
|
||||
if (fail) throw const SocketException('offline');
|
||||
return 1.0;
|
||||
},
|
||||
);
|
||||
Future<String?> resolve() async => 'https://example.com/video.mp4';
|
||||
expect(await store.save(album, resolveSource: resolve), isNull);
|
||||
expect(root.listSync(), isEmpty);
|
||||
expect(await store.find(album), isNull);
|
||||
fail = false;
|
||||
expect(await store.save(album, resolveSource: resolve), isNotNull);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'only remote artwork is downloaded and unknown albums keep static art',
|
||||
() async {
|
||||
final store = MotionArtworkStore(
|
||||
directory: () async => root,
|
||||
download: (_, _) async => throw StateError('must not download'),
|
||||
);
|
||||
expect(
|
||||
await store.save(
|
||||
album,
|
||||
resolveSource: () async => 'file:///private/video.mp4',
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
await store.save((
|
||||
album: '',
|
||||
artist: '',
|
||||
), resolveSource: () async => throw StateError('no album')),
|
||||
isNull,
|
||||
);
|
||||
expect(root.listSync(), isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'clearing saved motion waits for writes and keeps the music file',
|
||||
() async {
|
||||
final music = File('${root.path}/song.flac');
|
||||
await music.writeAsBytes([9, 8, 7]);
|
||||
final gate = Completer<void>();
|
||||
final started = Completer<void>();
|
||||
final store = MotionArtworkStore(
|
||||
directory: () async => Directory('${root.path}/motion'),
|
||||
download: (_, output) async {
|
||||
started.complete();
|
||||
await gate.future;
|
||||
await File(output).writeAsBytes([1]);
|
||||
return 0.75;
|
||||
},
|
||||
);
|
||||
final save = store.save(
|
||||
album,
|
||||
resolveSource: () async => 'https://example.com/cover.mp4',
|
||||
);
|
||||
await started.future;
|
||||
final clear = store.clear();
|
||||
expect(
|
||||
await store.save(
|
||||
album,
|
||||
resolveSource: () async => throw StateError('clearing'),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
gate.complete();
|
||||
await save;
|
||||
await clear;
|
||||
expect(await store.find(album), isNull);
|
||||
expect(await music.readAsBytes(), [9, 8, 7]);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'download queue track serialization retains extension motion artwork',
|
||||
() {
|
||||
final track = Track.fromBackendMap({
|
||||
'id': 'track',
|
||||
'name': 'Track',
|
||||
'artists': 'Artist',
|
||||
'album_name': 'Album',
|
||||
'header_video': 'https://example.com/cover.m3u8',
|
||||
}, source: 'example-provider');
|
||||
final restored = Track.fromJson(track.copyWith(name: 'Updated').toJson());
|
||||
expect(restored.headerVideoUrl, 'https://example.com/cover.m3u8');
|
||||
expect(restored.source, 'example-provider');
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -8,12 +8,16 @@ class _VideoPlatform extends VideoPlayerPlatform {
|
||||
int playCalls = 0;
|
||||
bool looping = false;
|
||||
double volume = 1;
|
||||
DataSource? source;
|
||||
|
||||
@override
|
||||
Future<void> init() async {}
|
||||
|
||||
@override
|
||||
Future<int?> createWithOptions(VideoCreationOptions options) async => 1;
|
||||
Future<int?> createWithOptions(VideoCreationOptions options) async {
|
||||
source = options.dataSource;
|
||||
return 1;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<VideoEvent> videoEventsFor(int playerId) => Stream.value(
|
||||
@@ -58,6 +62,37 @@ class _VideoPlatform extends VideoPlayerPlatform {
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'offline cover uses a silent looping file and reports its ratio',
|
||||
(tester) async {
|
||||
final previousPlatform = VideoPlayerPlatform.instance;
|
||||
final platform = _VideoPlatform();
|
||||
VideoPlayerPlatform.instance = platform;
|
||||
addTearDown(() => VideoPlayerPlatform.instance = previousPlatform);
|
||||
double? ratio;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: SizedBox(
|
||||
width: 320,
|
||||
height: 180,
|
||||
child: MotionHeaderBanner(
|
||||
videoUrl: 'file:///app/motion_artwork/cover.mp4',
|
||||
fallback: const ColoredBox(color: Colors.black),
|
||||
onAspectRatioChanged: (value) => ratio = value,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(platform.source?.sourceType, DataSourceType.file);
|
||||
expect(platform.looping, isTrue);
|
||||
expect(platform.volume, 0);
|
||||
expect(platform.playing, isTrue);
|
||||
expect(ratio, closeTo(320 / 180, 0.001));
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('collapsed headers pause video and visible headers resume', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -137,6 +137,105 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user