mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 04:42:02 +02:00
Reuse downloaded motion artwork for local and downloaded album headers, with static fallbacks and reduced-motion support. Opening an album does not trigger an artwork search or download.
405 lines
14 KiB
Dart
405 lines
14 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:audio_service/audio_service.dart';
|
|
import 'package:flutter/material.dart';
|
|
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/providers/download_history_provider.dart';
|
|
import 'package:spotiflac_android/providers/player_artwork_video_provider.dart';
|
|
import 'package:spotiflac_android/providers/player_motion_artwork_provider.dart';
|
|
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
|
|
import 'package:spotiflac_android/screens/local_album_screen.dart';
|
|
import 'package:spotiflac_android/services/library_database.dart';
|
|
import 'package:spotiflac_android/services/motion_artwork_store.dart';
|
|
import 'package:spotiflac_android/theme/mornye_theme.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';
|
|
|
|
class _VideoPlatform extends VideoPlayerPlatform {
|
|
bool playing = false;
|
|
int playCalls = 0;
|
|
bool looping = false;
|
|
double volume = 1;
|
|
DataSource? source;
|
|
int creations = 0;
|
|
int disposals = 0;
|
|
Map<int, StreamController<VideoEvent>>? events;
|
|
|
|
@override
|
|
Future<void> init() async {}
|
|
|
|
@override
|
|
Future<int?> createWithOptions(VideoCreationOptions options) async {
|
|
source = options.dataSource;
|
|
final id = ++creations;
|
|
events?[id] = StreamController<VideoEvent>();
|
|
return id;
|
|
}
|
|
|
|
@override
|
|
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 {
|
|
playing = true;
|
|
playCalls++;
|
|
}
|
|
|
|
@override
|
|
Future<void> pause(int playerId) async => playing = false;
|
|
|
|
@override
|
|
Future<void> setLooping(int playerId, bool looping) async =>
|
|
this.looping = looping;
|
|
|
|
@override
|
|
Future<void> setVolume(int playerId, double volume) async =>
|
|
this.volume = volume;
|
|
|
|
@override
|
|
Future<void> setPlaybackSpeed(int playerId, double speed) async {}
|
|
|
|
@override
|
|
Future<void> setMixWithOthers(bool mixWithOthers) async {}
|
|
|
|
@override
|
|
Future<Duration> getPosition(int playerId) async => Duration.zero;
|
|
|
|
@override
|
|
Widget buildViewWithOptions(VideoViewOptions options) => const SizedBox();
|
|
|
|
@override
|
|
Future<void> dispose(int playerId) async {
|
|
disposals++;
|
|
await events?[playerId]?.close();
|
|
}
|
|
}
|
|
|
|
void main() {
|
|
for (final downloaded in [false, true]) {
|
|
for (final mode in ['saved', 'missing', 'reduced motion']) {
|
|
testWidgets(
|
|
'local album uses saved motion with fallback (downloaded: $downloaded, $mode)',
|
|
(tester) async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
final previous = VideoPlayerPlatform.instance;
|
|
final platform = _VideoPlatform();
|
|
VideoPlayerPlatform.instance = platform;
|
|
addTearDown(() => VideoPlayerPlatform.instance = previous);
|
|
const channel = MethodChannel('com.zarz.spotiflac/backend');
|
|
final messenger =
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
|
messenger.setMockMethodCallHandler(channel, (_) async => null);
|
|
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
|
|
var lookups = 0;
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
playerMotionArtworkProvider.overrideWith((ref, album) async {
|
|
lookups++;
|
|
expect(album, (album: 'Album', artist: 'Track Artist'));
|
|
return mode == 'missing'
|
|
? null
|
|
: const MotionArtwork('file:///album-cover.mp4');
|
|
}),
|
|
downloadedAlbumTracksProvider(
|
|
const DownloadedAlbumTracksRequest(
|
|
albumName: 'Album',
|
|
artistName: 'Album Artist',
|
|
),
|
|
).overrideWith(
|
|
(ref) async => [
|
|
DownloadHistoryItem(
|
|
id: 'track',
|
|
trackName: 'Track',
|
|
artistName: 'Track Artist',
|
|
albumName: 'Album',
|
|
albumArtist: 'Album Artist',
|
|
filePath: 'content://library/track.flac',
|
|
service: 'provider-a',
|
|
downloadedAt: DateTime(2026),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
child: MaterialApp(
|
|
theme: MornyeTheme.build(Brightness.light),
|
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
|
supportedLocales: AppLocalizations.supportedLocales,
|
|
builder: (context, child) => MediaQuery(
|
|
data: MediaQuery.of(
|
|
context,
|
|
).copyWith(disableAnimations: mode == 'reduced motion'),
|
|
child: child!,
|
|
),
|
|
home: downloaded
|
|
? const DownloadedAlbumScreen(
|
|
albumName: 'Album',
|
|
artistName: 'Album Artist',
|
|
)
|
|
: LocalAlbumScreen(
|
|
albumName: 'Album',
|
|
artistName: 'Album Artist',
|
|
tracks: [
|
|
LocalLibraryItem(
|
|
id: 'track',
|
|
trackName: 'Track',
|
|
artistName: 'Track Artist',
|
|
albumName: 'Album',
|
|
albumArtist: 'Album Artist',
|
|
filePath: 'content://library/track.flac',
|
|
scannedAt: DateTime(2026),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
expect(lookups, mode == 'reduced motion' ? 0 : 1);
|
|
if (mode == 'saved') {
|
|
expect(find.byType(MotionHeaderBanner), findsOneWidget);
|
|
expect(platform.source?.sourceType, DataSourceType.file);
|
|
expect(platform.playing, isTrue);
|
|
expect(platform.looping, isTrue);
|
|
expect(platform.volume, 0);
|
|
} else {
|
|
expect(find.byType(MotionHeaderBanner), findsNothing);
|
|
expect(platform.creations, 0);
|
|
}
|
|
expect(find.text('Album Artist'), findsOneWidget);
|
|
expect(tester.takeException(), isNull);
|
|
await tester.pumpWidget(const SizedBox());
|
|
await tester.pumpAndSettle();
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
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();
|
|
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
|
|
expect(platform.disposals, 1, reason: 'Release the outgoing decoder');
|
|
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 {
|
|
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 {
|
|
final previousPlatform = VideoPlayerPlatform.instance;
|
|
final platform = _VideoPlatform();
|
|
VideoPlayerPlatform.instance = platform;
|
|
addTearDown(() => VideoPlayerPlatform.instance = previousPlatform);
|
|
final scroll = ScrollController();
|
|
addTearDown(scroll.dispose);
|
|
|
|
Widget app({bool active = true}) => MaterialApp(
|
|
home: Scaffold(
|
|
body: TickerMode(
|
|
enabled: active,
|
|
child: CustomScrollView(
|
|
controller: scroll,
|
|
slivers: const [
|
|
SliverAppBar(
|
|
pinned: true,
|
|
expandedHeight: 300,
|
|
flexibleSpace: FlexibleSpaceBar(
|
|
background: MotionHeaderBanner(
|
|
videoUrl: 'https://example.com/banner.m3u8',
|
|
fallback: ColoredBox(color: Colors.blue),
|
|
),
|
|
),
|
|
),
|
|
SliverToBoxAdapter(child: SizedBox(height: 2000)),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
await tester.pumpWidget(app());
|
|
await tester.pump();
|
|
expect(platform.playing, isTrue);
|
|
expect(platform.looping, isTrue);
|
|
expect(platform.volume, 0);
|
|
final initialPlayCalls = platform.playCalls;
|
|
|
|
scroll.jumpTo(50);
|
|
await tester.pump();
|
|
expect(platform.playCalls, initialPlayCalls);
|
|
scroll.jumpTo(400);
|
|
await tester.pump();
|
|
expect(platform.playing, isFalse);
|
|
|
|
scroll.jumpTo(0);
|
|
await tester.pump();
|
|
expect(platform.playing, isTrue);
|
|
await tester.pumpWidget(app(active: false));
|
|
await tester.pump();
|
|
expect(platform.playing, isFalse);
|
|
await tester.pumpWidget(app());
|
|
await tester.pump();
|
|
expect(platform.playing, isTrue);
|
|
|
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
|
|
await tester.pump();
|
|
expect(platform.playing, isFalse);
|
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
|
await tester.pump();
|
|
expect(platform.playing, isTrue);
|
|
await tester.pumpWidget(const SizedBox());
|
|
await tester.pump();
|
|
});
|
|
}
|