mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 21:02:09 +02:00
fix(player): preserve offline motion artwork and smooth cover changes
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user