fix(player): preserve offline motion artwork and smooth cover changes

This commit is contained in:
zarzet
2026-09-20 12:20:24 +07:00
parent dc25370350
commit 0c930c691f
25 changed files with 1464 additions and 339 deletions
@@ -0,0 +1,143 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/download_motion_artwork_source.dart';
import 'package:spotiflac_android/services/motion_artwork_store.dart';
void main() {
const track = Track(
id: 'catalog:track-1',
source: 'catalog',
albumId: 'catalog:album-1',
name: 'Song',
albumName: 'Album',
artistName: 'Artist',
duration: 180,
);
const url = 'https://example.test/artwork.m3u8';
late List<(String, String, String)> calls;
late Map<String, dynamic> albumResponse;
late Map<String, dynamic> trackResponse;
Future<Map<String, dynamic>> metadata(
String provider,
String type,
String id,
) async {
calls.add((provider, type, id));
return type == 'album' ? albumResponse : trackResponse;
}
Future<String?> resolve({
Track original = track,
Track downloaded = track,
Map<String, dynamic> result = const {},
}) => resolveDownloadMotionArtworkSource(
original: original,
downloaded: downloaded,
result: result,
getMetadata: metadata,
);
setUp(() {
calls = [];
albumResponse = {
'album_info': {'header_video': url},
};
trackResponse = {
'track': {'album_id': 'album-1'},
};
});
test(
'search download gets artwork from its own metadata extension',
() async {
expect(
await resolve(downloaded: track.copyWith(source: 'audio-provider')),
url,
);
expect(calls, [('catalog', 'album', 'album-1')]);
},
);
test('direct artwork does not fetch metadata', () async {
expect(await resolve(original: track.copyWith(headerVideoUrl: url)), url);
expect(await resolve(result: {'header_video': url}), url);
expect(calls, isEmpty);
});
test('invalid direct artwork does not hide valid download artwork', () async {
expect(
await resolve(
original: track.copyWith(headerVideoUrl: 'file:///private/cover.mp4'),
result: {'header_video': url},
),
url,
);
expect(calls, isEmpty);
});
test('missing album ID is hydrated from the same source track', () async {
final original = Track.fromJson({...track.toJson(), 'albumId': null});
expect(await resolve(original: original), url);
expect(calls, [
('catalog', 'track', 'track-1'),
('catalog', 'album', 'album-1'),
]);
});
test(
'missing source never selects the downloader or another extension',
() async {
final original = Track.fromJson({
...track.toJson(),
'id': 'track-1',
'source': null,
});
expect(await resolve(original: original), isNull);
expect(calls, isEmpty);
},
);
test('missing artwork never falls back to a different source', () async {
albumResponse = {'album_info': <String, dynamic>{}};
expect(await resolve(), isNull);
expect(calls, [('catalog', 'album', 'album-1')]);
});
test('URL-handler album payload is accepted', () async {
albumResponse = {
'album': {'header_video': url},
};
expect(await resolve(), url);
});
test('source-only download is saved and reused offline', () async {
final root = await Directory.systemTemp.createTemp('download-motion-');
addTearDown(() => root.delete(recursive: true));
var downloads = 0;
final store = MotionArtworkStore(
directory: () async => root,
validate: (_) async => true,
download: (source, output) async {
expect(source, url);
downloads++;
await File(output).writeAsBytes([1, 2, 3]);
return 0.75;
},
);
const album = (album: 'Album', artist: 'Artist');
final saved = await store.save(album, resolveSource: resolve);
expect(saved, isNotNull);
expect(await File.fromUri(Uri.parse(saved!.source)).exists(), isTrue);
calls.clear();
expect((await store.find(album))?.source, saved.source);
expect(
(await store.save(album, resolveSource: resolve))?.source,
saved.source,
);
expect(calls, isEmpty);
expect(downloads, 1);
});
}
+81
View File
@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/widgets/mornye_artwork_contrast.dart';
void main() {
testWidgets('foreground follows changing frames separately for each region', (
tester,
) async {
final header = GlobalKey();
final controls = GlobalKey();
Map<String, Color> colors = {};
var changes = 0;
Widget app(Color top, Color bottom) => MaterialApp(
home: Stack(
fit: StackFit.expand,
children: [
MornyeArtworkContrast(
enabled: true,
targets: {'header': header, 'controls': controls},
onChanged: (value) {
colors = value;
changes++;
},
child: Column(
children: [
Expanded(
child: ColoredBox(color: top, child: const SizedBox.expand()),
),
Expanded(
child: ColoredBox(
color: bottom,
child: const SizedBox.expand(),
),
),
],
),
),
Positioned(
top: 20,
left: 20,
width: 100,
height: 30,
child: SizedBox(key: header),
),
Positioned(
bottom: 20,
left: 20,
width: 100,
height: 30,
child: SizedBox(key: controls),
),
],
),
);
Future<void> sample() async {
await tester.pump(const Duration(milliseconds: 334));
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 30)),
);
}
await tester.pumpWidget(app(Colors.white, Colors.black));
await sample();
expect(colors, {'header': Colors.black, 'controls': Colors.white});
await tester.pumpWidget(app(Colors.black, Colors.white));
await sample();
expect(colors, {'header': Colors.white, 'controls': Colors.black});
final previousChanges = changes;
await sample();
expect(changes, previousChanges);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
await tester.pumpWidget(app(Colors.white, Colors.black));
await sample();
expect(changes, previousChanges);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pump();
await sample();
expect(colors, {'header': Colors.black, 'controls': Colors.white});
await tester.pumpWidget(const SizedBox());
});
}
+155
View File
@@ -0,0 +1,155 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/services/motion_artwork_download.dart';
void main() {
late HttpServer server;
late Directory root;
late Uri base;
late Map<String, String> playlists;
late List<String> requests;
late List<int> video;
late int status;
late int? declaredLength;
late String media;
const validMedia = '''
#EXTM3U
#EXT-X-MAP:URI="cover.mp4",BYTERANGE="4@0"
#EXTINF:1,
#EXT-X-BYTERANGE:4@4
cover.mp4
#EXTINF:1,
#EXT-X-BYTERANGE:4
cover.mp4
#EXT-X-ENDLIST
''';
setUp(() async {
root = await Directory.systemTemp.createTemp('motion-download-test-');
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
base = Uri.parse('http://127.0.0.1:${server.port}/');
requests = [];
video = List.generate(12, (index) => index);
status = HttpStatus.ok;
declaredLength = null;
media = validMedia;
playlists = {
'/master.m3u8': '''
#EXTM3U
#EXT-X-I-FRAME-STREAM-INF:CODECS="avc1.64001f",URI="preview.m3u8"
#EXT-X-STREAM-INF:CODECS="hvc1.2.4.L120",RESOLUTION=664x886
hevc.m3u8
#EXT-X-STREAM-INF:CODECS="avc1.64001f",RESOLUTION=664x886
video/media.m3u8
''',
};
server.listen((request) async {
requests.add(request.uri.path);
expect(request.headers.value(HttpHeaders.rangeHeader), isNull);
if (request.uri.path == '/redirect.m3u8') {
await request.response.redirect(base.resolve('video/media.m3u8'));
return;
}
final text = request.uri.path == '/video/media.m3u8'
? media
: playlists[request.uri.path];
if (text != null) {
request.response.write(text);
} else if (request.uri.path == '/video/cover.mp4') {
request.response.statusCode = status;
if (declaredLength != null) {
request.response.contentLength = declaredLength!;
await request.response.flush();
final socket = await request.response.detachSocket();
socket.destroy();
return;
}
request.response.add(video);
} else {
request.response.statusCode = HttpStatus.notFound;
}
await request.response.close();
});
});
tearDown(() async {
await server.close(force: true);
await root.delete(recursive: true);
});
Future<File?> download([String path = 'master.m3u8']) =>
downloadMotionArtworkSource(
base.resolve(path).toString(),
'${root.path}/source.mp4',
);
test(
'fetches the complete H.264 resource once without range requests',
() async {
final file = await download();
expect(await file!.readAsBytes(), video);
expect(requests, [
'/master.m3u8',
'/video/media.m3u8',
'/video/cover.mp4',
]);
},
);
test('resolves media URLs relative to the redirected playlist', () async {
final file = await download('redirect.m3u8');
expect(await file!.readAsBytes(), video);
expect(requests.last, '/video/cover.mp4');
});
test('leaves other sources to the regular remuxer', () async {
expect(await download('cover.mp4'), isNull);
expect(requests, isEmpty);
expect(root.listSync(), isEmpty);
});
for (final unsupported in {
'separate segments': validMedia.replaceFirst(
'#EXT-X-BYTERANGE:4\ncover.mp4',
'#EXT-X-BYTERANGE:4\nother.mp4',
),
'missing bytes': validMedia.replaceFirst('4@4', '4@5'),
'nonzero initial offset': validMedia.replaceFirst('4@0', '4@1'),
'encrypted segments': validMedia.replaceFirst(
'#EXTM3U',
'#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI="key"',
),
'live playlist': validMedia.replaceFirst('#EXT-X-ENDLIST', ''),
'discontinuity': validMedia.replaceFirst(
'#EXTINF:1,',
'#EXT-X-DISCONTINUITY\n#EXTINF:1,',
),
'non-HTTP video': validMedia.replaceAll('cover.mp4', 'file:///cover.mp4'),
}.entries) {
test('does not flatten ${unsupported.key}', () async {
media = unsupported.value;
expect(await download('video/media.m3u8'), isNull);
expect(requests, ['/video/media.m3u8']);
expect(root.listSync(), isEmpty);
});
}
test('rejects a partial HTTP response', () async {
status = HttpStatus.partialContent;
await expectLater(download(), throwsA(isA<HttpException>()));
expect(root.listSync(), isEmpty);
});
test('rejects missing segment bytes and removes the partial file', () async {
video = [1, 2, 3];
await expectLater(download(), throwsFormatException);
expect(root.listSync(), isEmpty);
});
test('bounds video size before accepting a large response', () async {
declaredLength = 25 << 20;
await expectLater(download(), throwsFormatException);
expect(root.listSync(), isEmpty);
});
}
+153
View File
@@ -1,14 +1,17 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart';
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/providers/settings_provider.dart';
import 'package:spotiflac_android/services/motion_artwork_store.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const album = (album: 'An Album', artist: 'An Artist');
late Directory root;
setUp(
@@ -16,6 +19,151 @@ void main() {
);
tearDown(() async => root.delete(recursive: true));
Future<File> seedLegacyVideo() async {
final store = MotionArtworkStore(
directory: () async => root,
validate: (_) async => true,
download: (_, output) async {
await File(output).writeAsBytes([1]);
return 0.75;
},
);
final saved = await store.save(
album,
resolveSource: () async => 'https://example.com/cover.m3u8',
);
final file = File.fromUri(Uri.parse(saved!.source));
final info = root.listSync().whereType<File>().singleWhere(
(file) => file.path.endsWith('.json'),
);
await info.writeAsString('{"aspectRatio":0.75}');
return file;
}
test('successful transfers with broken video data are not cached', () async {
final store = MotionArtworkStore(
directory: () async => root,
download: (_, output) async {
await File(output).writeAsBytes([1]);
return 0.75;
},
validate: (_) async => false,
);
expect(
await store.save(
album,
resolveSource: () async => 'https://example.com/cover.m3u8',
),
isNull,
);
expect(root.listSync(), isEmpty);
});
test('corrupt legacy video is replaced only after a valid repair', () async {
final legacy = await seedLegacyVideo();
var failDownload = true;
var validations = 0;
final store = MotionArtworkStore(
directory: () async => root,
validate: (path) async {
validations++;
return (await File(path).readAsBytes()).single == 2;
},
download: (_, output) async {
if (failDownload) throw const SocketException('offline');
await File(output).writeAsBytes([2]);
return 0.75;
},
);
expect(await store.find(album), isNull);
expect(await store.find(album), isNull);
expect(validations, 1);
Future<String?> resolve() async => 'https://example.com/cover.m3u8';
expect(await store.save(album, resolveSource: resolve), isNull);
expect(await legacy.readAsBytes(), [1]);
failDownload = false;
final repaired = await store.save(album, resolveSource: resolve);
expect(repaired?.source, legacy.uri.toString());
expect(await legacy.readAsBytes(), [2]);
expect((await store.find(album))?.source, repaired?.source);
expect(validations, 2);
expect(
root.listSync().where((file) => file.path.contains('partial')),
isEmpty,
);
});
test(
'healthy legacy video stays available offline after validation',
() async {
final legacy = await seedLegacyVideo();
var validations = 0;
final store = MotionArtworkStore(
directory: () async => root,
validate: (_) async {
validations++;
return true;
},
download: (_, _) async => throw StateError('offline'),
);
final results = await Future.wait([store.find(album), store.find(album)]);
expect(
results.map((art) => art?.source),
everyElement(legacy.uri.toString()),
);
expect(validations, 1);
},
);
test('a failed legacy check does not prevent clearing artwork', () async {
await seedLegacyVideo();
final store = MotionArtworkStore(
directory: () async => root,
validate: (_) async => throw StateError('decoder unavailable'),
);
expect(await store.find(album), isNull);
await store.clear();
expect(await root.exists(), isFalse);
await root.create();
});
for (final hasLegacyCopy in [true, false]) {
test(
'player never searches or downloads missing artwork (legacy=$hasLegacyCopy)',
() async {
final legacy = hasLegacyCopy ? await seedLegacyVideo() : null;
final store = MotionArtworkStore(
directory: () async => root,
validate: (path) async =>
(await File(path).readAsBytes()).single == 2,
download: (_, _) async => throw StateError('must not download'),
);
const channel = MethodChannel('com.zarz.spotiflac/backend');
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(channel, (call) async {
fail('Playback must not call an extension: ${call.method}');
});
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
final container = ProviderContainer(
overrides: [motionArtworkStoreProvider.overrideWithValue(store)],
);
addTearDown(container.dispose);
final initial = await container.read(
playerMotionArtworkProvider(album).future,
);
expect(initial, isNull);
expect(container.exists(extensionProvider), isFalse);
expect(container.exists(settingsProvider), isFalse);
if (legacy != null) {
expect(await legacy.readAsBytes(), [1]);
} else {
expect(await store.find(album), isNull);
}
},
);
}
test(
'saved video survives restart and app directory relocation offline',
() async {
@@ -23,6 +171,7 @@ void main() {
var location = Directory('${root.path}/old-container');
final store = MotionArtworkStore(
directory: () async => location,
validate: (_) async => true,
download: (_, output) async {
downloads++;
await File(output).writeAsBytes([1, 2, 3, 4]);
@@ -43,6 +192,7 @@ void main() {
location = await location.rename('${root.path}/new-container');
final restarted = MotionArtworkStore(
directory: () async => location,
validate: (_) async => throw StateError('already validated'),
download: (_, _) async => throw StateError('offline'),
);
final cached = await restarted.save((
@@ -70,6 +220,7 @@ void main() {
var lookups = 0;
final store = MotionArtworkStore(
directory: () async => root,
validate: (_) async => true,
download: (_, output) async {
downloads++;
await gate.future;
@@ -101,6 +252,7 @@ void main() {
var fail = true;
final store = MotionArtworkStore(
directory: () async => root,
validate: (_) async => true,
download: (_, output) async {
await File(output).writeAsBytes([1]);
if (fail) throw const SocketException('offline');
@@ -150,6 +302,7 @@ void main() {
final started = Completer<void>();
final store = MotionArtworkStore(
directory: () async => Directory('${root.path}/motion'),
validate: (_) async => true,
download: (_, output) async {
started.complete();
await gate.future;
+138 -9
View File
@@ -1,5 +1,11 @@
import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/providers/player_artwork_video_provider.dart';
import 'package:spotiflac_android/widgets/mornye_player_artwork.dart';
import 'package:spotiflac_android/widgets/motion_header_banner.dart';
import 'package:video_player_platform_interface/video_player_platform_interface.dart';
@@ -9,6 +15,9 @@ class _VideoPlatform extends VideoPlayerPlatform {
bool looping = false;
double volume = 1;
DataSource? source;
int creations = 0;
int disposals = 0;
Map<int, StreamController<VideoEvent>>? events;
@override
Future<void> init() async {}
@@ -16,17 +25,21 @@ class _VideoPlatform extends VideoPlayerPlatform {
@override
Future<int?> createWithOptions(VideoCreationOptions options) async {
source = options.dataSource;
return 1;
final id = ++creations;
events?[id] = StreamController<VideoEvent>();
return id;
}
@override
Stream<VideoEvent> videoEventsFor(int playerId) => Stream.value(
VideoEvent(
eventType: VideoEventType.initialized,
duration: const Duration(seconds: 30),
size: const Size(320, 180),
),
);
Stream<VideoEvent> videoEventsFor(int playerId) =>
events?[playerId]?.stream ??
Stream.value(
VideoEvent(
eventType: VideoEventType.initialized,
duration: const Duration(seconds: 30),
size: const Size(320, 180),
),
);
@override
Future<void> play(int playerId) async {
@@ -58,10 +71,126 @@ class _VideoPlatform extends VideoPlayerPlatform {
Widget buildViewWithOptions(VideoViewOptions options) => const SizedBox();
@override
Future<void> dispose(int playerId) async {}
Future<void> dispose(int playerId) async {
disposals++;
await events?[playerId]?.close();
}
}
void main() {
testWidgets(
'prepared player video opens without a new decoder or cover fade',
(tester) async {
final previous = VideoPlayerPlatform.instance;
final platform = _VideoPlatform();
VideoPlayerPlatform.instance = platform;
addTearDown(() => VideoPlayerPlatform.instance = previous);
final show = ValueNotifier(false);
addTearDown(show.dispose);
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
home: Consumer(
builder: (context, ref, _) {
ref.watch(playerArtworkVideoProvider('file:///cover.mp4'));
return ValueListenableBuilder(
valueListenable: show,
builder: (_, visible, _) => visible
? const MornyePlayerArtwork(
mediaItem: MediaItem(id: 'song', title: 'Song'),
videoUrl: 'file:///cover.mp4',
)
: const SizedBox(),
);
},
),
),
),
);
await tester.pump();
expect(platform.creations, 1);
expect(platform.playing, isFalse);
show.value = true;
await tester.pump();
expect(platform.creations, 1);
expect(platform.playing, isTrue);
final banner = tester.widget<MotionHeaderBanner>(
find.byType(MotionHeaderBanner),
);
expect(banner.controller!.value.isInitialized, isTrue);
expect(banner.fadeDuration, Duration.zero);
show.value = false;
await tester.pump();
expect(platform.playing, isFalse);
expect(platform.disposals, 0);
await tester.pumpWidget(const SizedBox());
await tester.pump();
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
expect(platform.disposals, 1);
},
);
testWidgets('changing tracks retains video until the new frame is ready', (
tester,
) async {
final previous = VideoPlayerPlatform.instance;
final platform = _VideoPlatform()..events = {};
VideoPlayerPlatform.instance = platform;
addTearDown(() => VideoPlayerPlatform.instance = previous);
final source = ValueNotifier('file:///first.mp4');
addTearDown(source.dispose);
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
home: ValueListenableBuilder(
valueListenable: source,
builder: (_, url, _) => MornyePlayerArtwork(
mediaItem: MediaItem(id: url, title: 'Song'),
videoUrl: url,
),
),
),
),
);
await tester.pump();
void ready(int id) => platform.events![id]!.add(
VideoEvent(
eventType: VideoEventType.initialized,
duration: const Duration(seconds: 10),
size: const Size(300, 400),
),
);
ready(1);
await tester.pump();
await tester.pump();
expect(
tester
.widget<MotionHeaderBanner>(find.byType(MotionHeaderBanner))
.videoUrl,
source.value,
);
source.value = 'file:///second.mp4';
await tester.pump();
expect(
tester
.widget<MotionHeaderBanner>(find.byType(MotionHeaderBanner))
.videoUrl,
'file:///first.mp4',
);
expect(platform.disposals, 0);
ready(2);
await tester.pump();
await tester.pump();
expect(
tester
.widget<MotionHeaderBanner>(find.byType(MotionHeaderBanner))
.videoUrl,
source.value,
);
await tester.pumpWidget(const SizedBox());
await tester.pump();
});
testWidgets(
'offline cover uses a silent looping file and reports its ratio',
(tester) async {
+47 -1
View File
@@ -16,6 +16,9 @@ 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';
@@ -89,6 +92,7 @@ void main() {
PlaybackState? playback,
Stream<PlaybackState>? playbackEvents,
Widget Function(Widget)? wrapPlayer,
MotionArtwork? motionArtwork,
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
@@ -99,7 +103,12 @@ void main() {
ProviderScope(
overrides: [
currentMediaItemProvider.overrideWith((ref) => mediaItems.stream),
playerMotionArtworkProvider.overrideWith((ref, album) async => null),
playerMotionArtworkProvider.overrideWith(
(ref, album) async => motionArtwork,
),
playerArtworkVideoProvider.overrideWith(
(ref, source) => Completer<VideoPlayerController>().future,
),
playerCollectionTrackProvider.overrideWith(
(ref, item) async => Track(
id: item.id,
@@ -137,6 +146,43 @@ void main() {
);
}
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('opening Mornye lyrics centers the current wrapped line', (
tester,
) async {
+66
View File
@@ -0,0 +1,66 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/widgets/player_artwork.dart';
void main() {
testWidgets(
'track changes retain the decoded cover while the next image loads',
(tester) async {
final cache = PaintingBinding.instance.imageCache;
addTearDown(() {
cache.clear();
cache.clearLiveImages();
});
Future<ui.Image> pixel(Color color) async {
final recorder = ui.PictureRecorder();
Canvas(recorder).drawColor(color, BlendMode.src);
final picture = recorder.endRecording();
final image = await picture.toImage(1, 1);
picture.dispose();
return image;
}
final first = await tester.runAsync(() => pixel(Colors.red));
final second = await tester.runAsync(() => pixel(Colors.blue));
final pending = Completer<ImageInfo>();
cache.putIfAbsent(
FileImage(File('/cover-first.png')),
() => OneFrameImageStreamCompleter(
Future.value(ImageInfo(image: first!)),
),
);
cache.putIfAbsent(
FileImage(File('/cover-second.png')),
() => OneFrameImageStreamCompleter(pending.future),
);
Widget app(String path) => MaterialApp(
home: PlayerArtwork(
artUri: path,
colorScheme: const ColorScheme.dark(),
),
);
await tester.pumpWidget(app('/cover-first.png'));
await tester.pump();
final displayed = tester.widget<RawImage>(find.byType(RawImage)).image;
expect(displayed, isNotNull);
await tester.pumpWidget(app('/cover-second.png'));
expect(
tester.widget<RawImage>(find.byType(RawImage)).image,
same(displayed),
);
expect(find.byIcon(Icons.music_note), findsNothing);
pending.complete(ImageInfo(image: second!));
await tester.pumpAndSettle();
expect(
tester.widget<RawImage>(find.byType(RawImage)).image,
isNot(same(displayed)),
);
expect(find.byIcon(Icons.music_note), findsNothing);
await tester.pumpWidget(const SizedBox());
},
);
}
-74
View File
@@ -1,74 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/providers/player_motion_artwork_provider.dart';
void main() {
const album = (album: 'An Album', artist: 'An Artist');
test(
'motion lookup checks album and artist before loading metadata',
() async {
final loaded = <String>[];
final result = await findPlayerMotionArtwork(
album: album,
providerIds: ['provider-a'],
search: (_, _) async => [
{
'id': 'wrong-artist',
'name': 'An Album',
'artists': 'Not An Artist',
},
{
'id': 'wrong-album',
'name': 'Another Album',
'artists': 'An Artist',
},
{'id': 'match', 'name': 'AN ALBUM', 'artists': 'An Artist'},
],
loadAlbum: (_, id) async {
loaded.add(id);
return {
'album_info': {'header_video': 'https://example.com/cover.m3u8'},
};
},
);
expect(loaded, ['match']);
expect(result, 'https://example.com/cover.m3u8');
},
);
test('motion lookup falls back to another provider after an error', () async {
final result = await findPlayerMotionArtwork(
album: album,
providerIds: ['provider-a', 'provider-b'],
search: (provider, _) async {
if (provider == 'provider-a') throw StateError('offline');
return [
{
'name': 'An Album',
'artists': 'An Artist',
'header_video': 'https://example.com/motion.m3u8',
},
];
},
loadAlbum: (_, _) async =>
throw StateError('Direct artwork needs no lookup'),
);
expect(result, 'https://example.com/motion.m3u8');
});
test('unavailable or unsupported motion keeps the static cover', () async {
final result = await findPlayerMotionArtwork(
album: album,
providerIds: ['provider-a'],
search: (_, _) async => [
{
'name': 'An Album',
'artists': 'An Artist',
'header_video': 'file:///invalid.m3u8',
},
],
loadAlbum: (_, _) async => {},
);
expect(result, isNull);
});
}