From 0c930c691fd32a0fcca7ddf82ccbf2774b8ce7e0 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 20 Sep 2026 12:20:24 +0700 Subject: [PATCH] fix(player): preserve offline motion artwork and smooth cover changes --- lib/providers/download_queue_provider.dart | 2 +- .../download_queue_provider_finalization.dart | 65 +------- .../player_artwork_video_provider.dart | 26 +++ .../player_motion_artwork_provider.dart | 108 +----------- lib/screens/now_playing_screen.dart | 80 ++++++--- .../download_motion_artwork_source.dart | 59 +++++++ lib/services/motion_artwork_download.dart | 152 +++++++++++++++++ lib/services/motion_artwork_store.dart | 157 +++++++++++++----- lib/widgets/mini_player.dart | 15 ++ lib/widgets/mornye_artwork_contrast.dart | 135 +++++++++++++++ lib/widgets/mornye_player_artwork.dart | 76 +++++++-- lib/widgets/mornye_player_background.dart | 4 +- .../mornye_player_favorite_button.dart | 4 +- lib/widgets/mornye_volume_control.dart | 16 +- lib/widgets/motion_header_banner.dart | 29 +++- lib/widgets/playback_seek_slider.dart | 4 + lib/widgets/player_artwork.dart | 4 +- test/download_motion_artwork_source_test.dart | 143 ++++++++++++++++ test/mornye_artwork_contrast_test.dart | 81 +++++++++ test/motion_artwork_download_test.dart | 155 +++++++++++++++++ test/motion_artwork_store_test.dart | 153 +++++++++++++++++ test/motion_header_banner_test.dart | 147 +++++++++++++++- test/now_playing_lyrics_transition_test.dart | 48 +++++- test/player_artwork_test.dart | 66 ++++++++ test/player_motion_artwork_test.dart | 74 --------- 25 files changed, 1464 insertions(+), 339 deletions(-) create mode 100644 lib/providers/player_artwork_video_provider.dart create mode 100644 lib/services/download_motion_artwork_source.dart create mode 100644 lib/services/motion_artwork_download.dart create mode 100644 lib/widgets/mornye_artwork_contrast.dart create mode 100644 test/download_motion_artwork_source_test.dart create mode 100644 test/mornye_artwork_contrast_test.dart create mode 100644 test/motion_artwork_download_test.dart create mode 100644 test/player_artwork_test.dart delete mode 100644 test/player_motion_artwork_test.dart diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 176f8e5e..8453560f 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -23,6 +23,7 @@ import 'package:spotiflac_android/providers/download_queue_state.dart'; import 'package:spotiflac_android/services/app_state_database.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/download_request_payload.dart'; +import 'package:spotiflac_android/services/download_motion_artwork_source.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/replaygain_service.dart'; import 'package:spotiflac_android/services/notification_service.dart'; @@ -38,7 +39,6 @@ import 'package:spotiflac_android/utils/extension_auth_launcher.dart'; import 'package:spotiflac_android/utils/download_error_type.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/utils/progress_stream_poller.dart'; -import 'package:spotiflac_android/utils/provider_resource_ids.dart'; import 'package:spotiflac_android/providers/download_history_provider.dart'; diff --git a/lib/providers/download_queue_provider_finalization.dart b/lib/providers/download_queue_provider_finalization.dart index e221ba9f..3c6e0b50 100644 --- a/lib/providers/download_queue_provider_finalization.dart +++ b/lib/providers/download_queue_provider_finalization.dart @@ -106,68 +106,15 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier { ) ?? item.track.artistName, ); - final extensions = ref.read(extensionProvider).extensions; - final preferred = ref.read(settingsProvider).searchProvider; - final providers = - { - if (item.track.source?.isNotEmpty == true) item.track.source!, - ?preferred, - } - .where( - (id) => extensions.any( - (extension) => - extension.id == id && - extension.enabled && - extension.hasCustomSearch, - ), - ) - .toList(); final store = ref.read(motionArtworkStoreProvider); final artwork = await store.save( album, - resolveSource: () async { - final direct = normalizeRemoteHttpUrl( - item.track.headerVideoUrl ?? - track.headerVideoUrl ?? - result['header_video']?.toString(), - ); - if (direct != null) return direct; - final source = item.track.source; - final albumId = item.track.albumId; - if (source != null && - providers.contains(source) && - albumId?.isNotEmpty == true) { - try { - final metadata = await PlatformBridge.getProviderMetadata( - source, - 'album', - stripPrefixedResourceId(albumId!), - ).timeout(const Duration(seconds: 6)); - final info = metadata['album_info']; - final url = normalizeRemoteHttpUrl( - ((info is Map ? info['header_video'] : null) ?? - metadata['header_video']) - ?.toString(), - ); - if (url != null) return url; - } catch (_) {} - } - return findPlayerMotionArtwork( - album: album, - providerIds: providers, - search: (provider, query) => - PlatformBridge.customSearchWithExtension( - provider, - query, - options: {'filter': 'album', 'limit': 8}, - ).timeout(const Duration(seconds: 6)), - loadAlbum: (provider, id) => PlatformBridge.getProviderMetadata( - provider, - 'album', - id, - ).timeout(const Duration(seconds: 6)), - ); - }, + resolveSource: () => resolveDownloadMotionArtworkSource( + original: item.track, + downloaded: track, + result: result, + getMetadata: PlatformBridge.getProviderMetadata, + ), ); if (artwork != null && ref.mounted) { ref.invalidate(playerMotionArtworkProvider(album)); diff --git a/lib/providers/player_artwork_video_provider.dart b/lib/providers/player_artwork_video_provider.dart new file mode 100644 index 00000000..84e48fca --- /dev/null +++ b/lib/providers/player_artwork_video_provider.dart @@ -0,0 +1,26 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:video_player/video_player.dart'; + +/// The mini player prepares the current offline cover, paused and silent. +/// The full player borrows it so opening the route does not restart decoding. +final playerArtworkVideoProvider = FutureProvider.autoDispose + .family((ref, source) async { + final uri = Uri.parse(source); + if (uri.scheme != 'file') { + throw ArgumentError('Player artwork must be saved locally'); + } + final controller = VideoPlayerController.file( + File.fromUri(uri), + videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true), + ); + ref.onDispose(() => unawaited(controller.dispose())); + await controller.initialize(); + if (!ref.mounted) return controller; + await controller.setVolume(0); + if (!ref.mounted) return controller; + await controller.setLooping(true); + return controller; + }); diff --git a/lib/providers/player_motion_artwork_provider.dart b/lib/providers/player_motion_artwork_provider.dart index 0fc99ea9..dc3119db 100644 --- a/lib/providers/player_motion_artwork_provider.dart +++ b/lib/providers/player_motion_artwork_provider.dart @@ -1,118 +1,16 @@ -import 'dart:convert'; - import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotiflac_android/providers/extension_provider.dart'; -import 'package:spotiflac_android/providers/settings_provider.dart'; -import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/motion_artwork_store.dart'; -import 'package:spotiflac_android/utils/string_utils.dart'; -import 'package:spotiflac_android/utils/ttl_cache.dart'; typedef PlayerArtworkAlbum = ({String album, String artist}); final motionArtworkStoreProvider = Provider((ref) => MotionArtworkStore()); -final _motionCache = TtlCache>( - const Duration(minutes: 10), - maxEntries: 32, -); - -/// Resolve the same album motion artwork used by collection headers. Cache by -/// album so advancing through its tracks does not repeat metadata requests. +/// Playback only reads artwork already saved with a download. Opening a player +/// must never search other extensions or trigger an artwork download. final playerMotionArtworkProvider = FutureProvider.autoDispose .family((ref, album) async { if (album.album.trim().isEmpty || album.artist.trim().isEmpty) { return null; } - final local = await ref.read(motionArtworkStoreProvider).find(album); - if (local != null) return local; - if (!ref.mounted) return null; - final extensions = ref.watch(extensionProvider); - final preferred = ref.watch( - settingsProvider.select((s) => s.searchProvider), - ); - final enabled = extensions.extensions - .where((extension) => extension.enabled && extension.hasCustomSearch) - .map((extension) => extension.id) - .toSet(); - final providers = { - if (preferred != null && enabled.contains(preferred)) preferred, - ...extensions.metadataProviderPriority.where(enabled.contains), - ...enabled, - }.toList(); - if (providers.isEmpty) return null; - final key = jsonEncode([album.album, album.artist, providers]); - final cached = _motionCache.get(key); - if (cached != null) { - final source = await cached; - return source == null ? null : MotionArtwork(source); - } - final request = findPlayerMotionArtwork( - album: album, - providerIds: providers, - search: (provider, query) => PlatformBridge.customSearchWithExtension( - provider, - query, - options: {'filter': 'album', 'limit': 8}, - ).timeout(const Duration(seconds: 6)), - loadAlbum: (provider, id) => PlatformBridge.getProviderMetadata( - provider, - 'album', - id, - ).timeout(const Duration(seconds: 6)), - ); - _motionCache.set(key, request); - final source = await request; - return source == null ? null : MotionArtwork(source); + return ref.watch(motionArtworkStoreProvider).find(album); }); - -Future findPlayerMotionArtwork({ - required PlayerArtworkAlbum album, - required List providerIds, - required Future>> Function( - String provider, - String query, - ) - search, - required Future> Function(String provider, String id) - loadAlbum, -}) async { - String normalized(String value) => - value.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' '); - for (final provider in providerIds) { - try { - final results = await search(provider, '${album.album} ${album.artist}'); - for (final result in results) { - final name = (result['name'] ?? result['album_name'] ?? '').toString(); - final artist = - (result['artists'] ?? - result['artist'] ?? - result['artist_name'] ?? - '') - .toString(); - // Never attach a similarly named album's video to the playing track. - if (normalized(name) != normalized(album.album) || - normalized(artist) != normalized(album.artist)) { - continue; - } - final direct = normalizeRemoteHttpUrl( - result['header_video']?.toString(), - ); - if (direct != null) return direct; - final id = (result['id'] ?? result['album_id'] ?? '').toString(); - if (id.isEmpty) continue; - final metadata = await loadAlbum(provider, id); - final info = metadata['album_info']; - final video = normalizeRemoteHttpUrl( - ((info is Map ? info['header_video'] : null) ?? - metadata['header_video']) - ?.toString(), - ); - if (video != null) return video; - } - } catch (_) { - // Offline, unsupported providers and expired links keep the static cover. - } - } - return null; -} diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 39f60159..72624a35 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -32,6 +32,7 @@ import 'package:spotiflac_android/widgets/settings_group.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_player_background.dart'; +import 'package:spotiflac_android/widgets/mornye_artwork_contrast.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'; @@ -223,6 +224,10 @@ class _NowPlayingScreenState extends ConsumerState { String? _failedMotionSource; bool _lyricsControlsHidden = false; double _lyricsScrollDistance = 0; + final _artworkHeaderKey = GlobalKey(); + final _artworkControlsKey = GlobalKey(); + final _artworkVolumeKey = GlobalKey(); + Map _artworkForeground = {}; @override void initState() { @@ -412,16 +417,15 @@ class _NowPlayingScreenState extends ConsumerState { } final source = mediaItem.extras?['source']?.toString() ?? ''; - final resolvedMotion = mornye && !MediaQuery.disableAnimationsOf(context) - ? ref - .watch( - playerMotionArtworkProvider(( - album: mediaItem.album ?? '', - artist: mediaItem.artist ?? '', - )), - ) - .value + final motionState = mornye && !MediaQuery.disableAnimationsOf(context) + ? ref.watch( + playerMotionArtworkProvider(( + album: mediaItem.album ?? '', + artist: mediaItem.artist ?? '', + )), + ) : null; + final resolvedMotion = motionState?.value; final motionArtwork = resolvedMotion?.source == _failedMotionSource ? null : resolvedMotion; @@ -432,9 +436,11 @@ class _NowPlayingScreenState extends ConsumerState { : null); final squareArtwork = motionArtwork == null || (motionRatio != null && motionRatio >= 0.95); + if (motionArtwork == null) _artworkForeground = {}; Widget artwork() => MornyePlayerArtwork( mediaItem: mediaItem, videoUrl: motionArtwork?.source, + resolvingVideo: motionState?.isLoading == true, onAspectRatioChanged: (ratio) { if (!mounted || (_measuredMotionSource == motionArtwork?.source && @@ -536,7 +542,6 @@ class _NowPlayingScreenState extends ConsumerState { mediaItem, controller, colorScheme, - squareArtwork: squareArtwork, motionArtwork: artwork(), artworkAspectRatio: motionRatio, ) @@ -632,7 +637,19 @@ class _NowPlayingScreenState extends ConsumerState { child: Stack( fit: StackFit.expand, children: [ - RepaintBoundary( + MornyeArtworkContrast( + enabled: + motionArtwork != null && + !_landscapeLyrics && + _currentPage == 0, + targets: { + 'header': _artworkHeaderKey, + 'controls': _artworkControlsKey, + 'volume': _artworkVolumeKey, + }, + onChanged: (colors) { + if (mounted) setState(() => _artworkForeground = colors); + }, child: MornyePlayerBackground( artUri: mediaItem.artUri, squareArtwork: squareArtwork, @@ -824,13 +841,22 @@ class _NowPlayingScreenState extends ConsumerState { MediaItem mediaItem, MusicPlayerController controller, ColorScheme colorScheme, { - required bool squareArtwork, required Widget motionArtwork, double? artworkAspectRatio, }) { final showLyrics = _landscapeLyrics || _currentPage == 1; final showQueue = !_landscapeLyrics && _currentPage == 2; final compactStage = showLyrics || showQueue; + ColorScheme foreground(String region) { + final color = compactStage ? null : _artworkForeground[region]; + return color == null + ? colorScheme + : colorScheme.copyWith( + onSurface: color, + onSurfaceVariant: color.withValues(alpha: 0.72), + ); + } + final motion = MediaQuery.disableAnimationsOf(context) ? Duration.zero : const Duration(milliseconds: 380); @@ -1008,21 +1034,20 @@ class _NowPlayingScreenState extends ConsumerState { }, ); - final volumeGap = - 8 + - (squareArtwork - ? (constraints.maxHeight - 480).clamp(0.0, 48.0) - : 0); + // Portrait video must not push the metadata/transport below the + // square-cover position. Leave room above the anchored volume row. + final volumeGap = 8 + (constraints.maxHeight - 440).clamp(0.0, 64.0); // Share the spare space above and below the transport row so it sits // between the timeline and volume without moving either slider. final transportShift = landscape ? 0.0 : ((volumeGap - 16) / 2).clamp(0.0, 20.0); Widget controls() => _PlaybackControls( + key: _artworkControlsKey, mediaId: mediaItem.id, duration: mediaItem.duration ?? Duration.zero, controller: controller, - colorScheme: colorScheme, + colorScheme: foreground('controls'), qualityLabel: _qualityLabel(), compact: landscape, transportTopPadding: 16 + transportShift, @@ -1051,8 +1076,9 @@ class _NowPlayingScreenState extends ConsumerState { child: compactStage ? const SizedBox(width: double.infinity) : Padding( + key: _artworkHeaderKey, padding: const EdgeInsets.fromLTRB(28, 12, 28, 8), - child: _trackHeader(mediaItem, colorScheme), + child: _trackHeader(mediaItem, foreground('header')), ), ), AnimatedSize( @@ -1074,7 +1100,10 @@ class _NowPlayingScreenState extends ConsumerState { children: [ controls(), SizedBox(height: volumeGap - transportShift), - const MornyeVolumeControl(), + MornyeVolumeControl( + key: _artworkVolumeKey, + foreground: foreground('volume').onSurface, + ), const SizedBox(height: 8), ], ), @@ -1142,7 +1171,7 @@ class _NowPlayingScreenState extends ConsumerState { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: compact ? 13 : 20, - color: Colors.white70, + color: colorScheme.onSurface.withValues(alpha: 0.72), ), ), ], @@ -1154,6 +1183,7 @@ class _NowPlayingScreenState extends ConsumerState { key: ValueKey(mediaItem.id), mediaItem: mediaItem, compact: compact, + color: colorScheme.onSurface, ), Builder( builder: (buttonContext) => IconButton( @@ -1878,11 +1908,15 @@ class _PlaybackControls extends ConsumerWidget { SliderTheme( data: SliderThemeData( trackHeight: 4, - activeTrackColor: mornye ? Colors.white : colorScheme.primary, + activeTrackColor: mornye + ? colorScheme.onSurface + : colorScheme.primary, inactiveTrackColor: colorScheme.onSurface.withValues( alpha: 0.18, ), - thumbColor: mornye ? Colors.white : colorScheme.primary, + thumbColor: mornye + ? colorScheme.onSurface + : colorScheme.primary, // A 7dp thumb was hard to grab; 10dp with a 24dp overlay // gives the drag gesture a full-size target. thumbShape: const RoundSliderThumbShape( diff --git a/lib/services/download_motion_artwork_source.dart b/lib/services/download_motion_artwork_source.dart new file mode 100644 index 00000000..4973f99d --- /dev/null +++ b/lib/services/download_motion_artwork_source.dart @@ -0,0 +1,59 @@ +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/utils/provider_resource_ids.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; + +/// Resolve optional artwork while downloading, using only the original +/// metadata extension. Search results may omit artwork held by the album. +Future resolveDownloadMotionArtworkSource({ + required Track original, + required Track downloaded, + required Map result, + required Future> Function( + String provider, + String type, + String id, + ) + getMetadata, +}) async { + String? video(Map data) => + normalizeRemoteHttpUrl(data['header_video']?.toString()); + + for (final candidate in [ + original.headerVideoUrl, + downloaded.headerVideoUrl, + result['header_video']?.toString(), + ]) { + final url = normalizeRemoteHttpUrl(candidate); + if (url != null) return url; + } + + final provider = resolvePreferredMetadataProviderId( + original.source, + original.id, + ); + if (provider == null) return null; + + String resourceId(String id) => + id.startsWith('$provider:') ? id.substring(provider.length + 1) : id; + Future> metadata(String type, String id) => getMetadata( + provider, + type, + resourceId(id), + ).timeout(const Duration(seconds: 15)); + + var albumId = normalizeOptionalString(original.albumId); + if (albumId == null) { + final trackId = normalizeOptionalString(original.id); + if (trackId == null) return null; + final response = await metadata('track', trackId); + final track = response['track']; + if (track is! Map) return null; + final url = video(track); + if (url != null) return url; + albumId = normalizeOptionalString(track['album_id']?.toString()); + } + if (albumId == null) return null; + final response = await metadata('album', albumId); + final album = response['album_info'] ?? response['album']; + return (album is Map ? video(album) : null) ?? video(response); +} diff --git a/lib/services/motion_artwork_download.dart b/lib/services/motion_artwork_download.dart new file mode 100644 index 00000000..748c5200 --- /dev/null +++ b/lib/services/motion_artwork_download.dart @@ -0,0 +1,152 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:spotiflac_android/utils/string_utils.dart'; + +/// Fetch a single-file HLS cover as one complete HTTP response. Passing its +/// byte-range playlist straight to FFmpeg can splice truncated segments into +/// an MP4 that still reports a successful remux. +Future downloadMotionArtworkSource(String source, String output) async { + final uri = Uri.parse(source); + if (!uri.path.toLowerCase().endsWith('.m3u8')) return null; + const maxBytes = 24 << 20; + final client = HttpClient()..connectionTimeout = const Duration(seconds: 8); + final deadline = Timer( + const Duration(seconds: 30), + () => client.close(force: true), + ); + final file = File(output); + var complete = false; + try { + Future<({Uri uri, String text})> playlist(Uri url) async { + final response = await _get(client, url); + final bytes = []; + await for (final chunk in response.timeout(const Duration(seconds: 8))) { + if (bytes.length + chunk.length > 512 << 10) { + throw const FormatException('Artwork playlist exceeds size limit'); + } + bytes.addAll(chunk); + } + for (final redirect in response.redirects) { + url = url.resolveUri(redirect.location); + } + return (uri: url, text: utf8.decode(bytes)); + } + + var media = await playlist(uri); + final variant = _videoVariant(media.text, media.uri); + if (variant != null) media = await playlist(variant); + final video = _singleFileVideo(media.text, media.uri); + if (video == null || video.length > maxBytes) return null; + final response = await _get(client, video.uri); + if (response.contentLength > maxBytes) { + throw const FormatException('Artwork video exceeds size limit'); + } + final sink = await file.open(mode: FileMode.write); + var length = 0; + try { + await for (final chunk in response.timeout(const Duration(seconds: 8))) { + length += chunk.length; + if (length > maxBytes) { + throw const FormatException('Artwork video exceeds size limit'); + } + await sink.writeFrom(chunk); + } + } finally { + await sink.close(); + } + if (length < video.length || + (response.contentLength >= 0 && length != response.contentLength)) { + throw const FormatException('Incomplete artwork video'); + } + complete = true; + return file; + } finally { + deadline.cancel(); + client.close(force: true); + if (!complete && await file.exists()) await file.delete(); + } +} + +Future _get(HttpClient client, Uri uri) async { + final request = await client.getUrl(uri).timeout(const Duration(seconds: 8)); + final response = await request.close().timeout(const Duration(seconds: 8)); + if (response.statusCode != HttpStatus.ok) { + throw HttpException('Artwork HTTP ${response.statusCode}'); + } + return response; +} + +Uri? _remoteUri(Uri base, String? value) { + if (value == null) return null; + final normalized = normalizeRemoteHttpUrl(base.resolve(value).toString()); + return normalized == null ? null : Uri.parse(normalized); +} + +Uri? _videoVariant(String playlist, Uri base) { + var video = false; + for (final raw in const LineSplitter().convert(playlist)) { + final line = raw.trim(); + if (line.startsWith('#EXT-X-STREAM-INF:')) { + // Prefer broadly supported H.264, never an I-frame preview playlist. + video = line.contains('avc1.') || line.contains('avc3.'); + } else if (line.isNotEmpty && !line.startsWith('#')) { + if (video) return _remoteUri(base, line); + video = false; + } + } + return null; +} + +({Uri uri, int length})? _singleFileVideo(String playlist, Uri base) { + final lines = const LineSplitter() + .convert(playlist) + .map((line) => line.trim()); + if (!lines.contains('#EXTM3U') || !lines.contains('#EXT-X-ENDLIST')) { + return null; + } + Uri? video; + var offset = 0; + var segments = 0; + String? range; + for (final line in lines) { + if (line.startsWith('#EXT-X-KEY:') && + !RegExp(r'(?:[:,])METHOD=NONE(?:,|$)').hasMatch(line)) { + return null; + } + if (line.startsWith('#EXT-X-DISCONTINUITY') || + line.startsWith('#EXT-X-STREAM-INF:')) { + return null; + } + if (line.startsWith('#EXT-X-MAP:')) { + if (video != null || segments != 0) return null; + video = _remoteUri( + base, + RegExp(r'(?:[:,])URI="([^"]+)"').firstMatch(line)?.group(1), + ); + final initial = RegExp(r'(?:[:,])BYTERANGE="(\d+)@0"').firstMatch(line); + offset = int.tryParse(initial?.group(1) ?? '') ?? 0; + if (video == null || offset <= 0) return null; + } else if (line.startsWith('#EXT-X-BYTERANGE:')) { + if (range != null) return null; + range = line.substring('#EXT-X-BYTERANGE:'.length); + } else if (line.isNotEmpty && !line.startsWith('#')) { + if (video == null || _remoteUri(base, line) != video || range == null) { + return null; + } + final match = RegExp(r'^(\d+)(?:@(\d+))?$').firstMatch(range); + final length = int.tryParse(match?.group(1) ?? '') ?? 0; + final start = match?.group(2); + if (length <= 0 || (start != null && int.tryParse(start) != offset)) { + return null; + } + offset += length; + range = null; + segments++; + } + } + return video == null || segments == 0 || range != null + ? null + : (uri: video, length: offset); +} diff --git a/lib/services/motion_artwork_store.dart b/lib/services/motion_artwork_store.dart index 869ea031..374494f1 100644 --- a/lib/services/motion_artwork_store.dart +++ b/lib/services/motion_artwork_store.dart @@ -7,6 +7,7 @@ import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit.dart'; import 'package:ffmpeg_kit_flutter_new_full/return_code.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; +import 'package:spotiflac_android/services/motion_artwork_download.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; @@ -25,14 +26,20 @@ class MotionArtworkStore { MotionArtworkStore({ Future Function()? directory, Future Function(String source, String output)? download, + Future Function(String path)? validate, }) : _directory = directory ?? defaultDirectory, - _download = download ?? _downloadVideo; + _download = download ?? _downloadVideo, + _validate = validate ?? _validateVideo; final Future Function() _directory; final Future Function(String source, String output) _download; + final Future Function(String path) _validate; final _pending = >{}; + final _legacyChecks = >{}; + final _invalid = {}; bool _clearing = false; static final _log = AppLogger('MotionArtwork'); + static const _cacheVersion = 2; static Future defaultDirectory() async => Directory( '${(await getApplicationSupportDirectory()).path}/motion_artwork', @@ -51,20 +58,40 @@ class MotionArtworkStore { } Future find(MotionArtworkAlbum album) async { + if (_clearing) return null; try { final root = await _directory(); final key = _key(album); + if (_invalid.contains(key)) return null; final file = File('${root.path}/$key.mp4'); if (!await file.exists() || await file.length() == 0) return null; double? ratio; + var version = 0; final info = File('${root.path}/$key.json'); if (await info.exists()) { final data = jsonDecode(await info.readAsString()); - if (data is Map && data['aspectRatio'] is num) { - final value = (data['aspectRatio'] as num).toDouble(); - if (value.isFinite && value > 0) ratio = value; + if (data is Map) { + if (data['version'] is int) version = data['version'] as int; + if (data['aspectRatio'] is num) { + final value = (data['aspectRatio'] as num).toDouble(); + if (value.isFinite && value > 0) ratio = value; + } } } + // Older downloads could report success with corrupt HLS byte ranges. + // Check those once per session; retain healthy covers for offline use. + if (version != _cacheVersion && + !await _legacyChecks.putIfAbsent(key, () async { + try { + return await _validate(file.path); + } catch (_) { + return false; + } + })) { + _invalid.add(key); + _log.w('Saved motion artwork failed validation; repair required'); + return null; + } return MotionArtwork(file.uri.toString(), aspectRatio: ratio); } catch (_) { return null; @@ -94,8 +121,11 @@ class MotionArtworkStore { _clearing = true; try { await Future.wait(_pending.values.toList()); + await Future.wait(_legacyChecks.values.toList()); final root = await _directory(); if (await root.exists()) await root.delete(recursive: true); + _legacyChecks.clear(); + _invalid.clear(); } finally { _clearing = false; } @@ -121,16 +151,19 @@ class MotionArtworkStore { !ratio.isFinite || ratio <= 0 || !await temporary.exists() || - await temporary.length() == 0) { + await temporary.length() == 0 || + !await _validate(temporary.path)) { return null; } infoTemporary = File('${root.path}/$key.partial.json'); await infoTemporary.writeAsString( - jsonEncode({'aspectRatio': ratio}), + jsonEncode({'version': _cacheVersion, 'aspectRatio': ratio}), flush: true, ); final file = await temporary.rename('${root.path}/$key.mp4'); await infoTemporary.rename('${root.path}/$key.json'); + _invalid.remove(key); + _legacyChecks.remove(key); return MotionArtwork(file.uri.toString(), aspectRatio: ratio); } catch (error) { _log.w('Could not save optional motion artwork: ${error.runtimeType}'); @@ -145,40 +178,87 @@ class MotionArtworkStore { } static Future _downloadVideo(String source, String output) async { - final completed = Completer(); + final original = await downloadMotionArtworkSource( + source, + '$output.source.mp4', + ); + try { + return await _remuxVideo(original?.path ?? source, output); + } finally { + if (original != null && await original.exists()) await original.delete(); + } + } + + static Future _remuxVideo(String source, String output) async { // Remux public video/HLS into a self-contained, silent MP4. Bound both // transfer time and output size; never let optional artwork block audio. - final session = await FFmpegKit.executeWithArgumentsAsync( - [ - '-y', - '-protocol_whitelist', - 'http,https,tcp,tls,crypto', - '-rw_timeout', - '10000000', - '-i', - source, - '-map', - '0:v:0', - '-an', - '-sn', - '-dn', - '-c:v', - 'copy', - '-t', - '30', - '-fs', - '25165824', - '-movflags', - '+faststart', - output, + final success = await _run([ + '-y', + '-protocol_whitelist', + 'file,http,https,tcp,tls,crypto', + '-rw_timeout', + '10000000', + // HLS byte ranges can share one MP4 URL. Reusing/prefetching HTTP + // connections can splice the next range into an unfinished segment. + if (Uri.parse(source).path.toLowerCase().endsWith('.m3u8')) ...[ + '-http_multiple', + '0', + '-http_persistent', + '0', ], - (session) async { - final success = ReturnCode.isSuccess(await session.getReturnCode()); - if (!completed.isCompleted) completed.complete(success); - }, - ); - final success = await completed.future.timeout( - const Duration(seconds: 60), + '-i', + source, + '-map', + '0:v:0', + '-an', + '-sn', + '-dn', + '-c:v', + 'copy', + '-t', + '30', + '-fs', + '25165824', + '-movflags', + '+faststart', + output, + ], timeout: const Duration(seconds: 60)); + if (!success) return null; + final dimensions = await FFmpegService.probeImageDimensions(output); + return dimensions == null ? null : dimensions.width / dimensions.height; + } + + static Future _validateVideo(String path) => _run([ + '-v', + 'error', + '-xerror', + '-err_detect', + 'explode', + '-i', + path, + '-map', + '0:v:0', + '-an', + '-sn', + '-dn', + '-f', + 'null', + '-', + ], timeout: const Duration(seconds: 20)); + + static Future _run( + List arguments, { + required Duration timeout, + }) async { + final completed = Completer(); + final session = await FFmpegKit.executeWithArgumentsAsync(arguments, ( + session, + ) async { + final success = ReturnCode.isSuccess(await session.getReturnCode()); + if (!completed.isCompleted) completed.complete(success); + }); + return completed.future.timeout( + timeout, onTimeout: () async { await FFmpegKit.cancel(session.getSessionId()); // Wait for this session to release its file before removing partials. @@ -189,8 +269,5 @@ class MotionArtworkStore { return false; }, ); - if (!success) return null; - final dimensions = await FFmpegService.probeImageDimensions(output); - return dimensions == null ? null : dimensions.width / dimensions.height; } } diff --git a/lib/widgets/mini_player.dart b/lib/widgets/mini_player.dart index 046511dd..ecc46925 100644 --- a/lib/widgets/mini_player.dart +++ b/lib/widgets/mini_player.dart @@ -3,6 +3,8 @@ import 'package:flutter/cupertino.dart' show CupertinoIcons; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.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/providers/runtime_profile_provider.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/screens/now_playing_screen.dart'; @@ -43,6 +45,19 @@ class _MiniPlayerState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; final mornye = context.isMornye; final compact = mornye && widget.compact; + if (mornye && !MediaQuery.disableAnimationsOf(context)) { + final artwork = ref + .watch( + playerMotionArtworkProvider(( + album: mediaItem.album ?? '', + artist: mediaItem.artist ?? '', + )), + ) + .value; + if (artwork != null) { + ref.watch(playerArtworkVideoProvider(artwork.source)); + } + } final player = Dismissible( key: ValueKey('mini-player-${mediaItem.id}'), diff --git a/lib/widgets/mornye_artwork_contrast.dart b/lib/widgets/mornye_artwork_contrast.dart new file mode 100644 index 00000000..53743edf --- /dev/null +++ b/lib/widgets/mornye_artwork_contrast.dart @@ -0,0 +1,135 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +/// Samples rendered video and its fade, rather than a static album palette. +/// Read back only 48 pixels across, at most three times a second when visible. +class MornyeArtworkContrast extends StatefulWidget { + const MornyeArtworkContrast({ + super.key, + required this.enabled, + required this.targets, + required this.onChanged, + required this.child, + }); + + final bool enabled; + final Map targets; + final ValueChanged> onChanged; + final Widget child; + + @override + State createState() => _MornyeArtworkContrastState(); +} + +class _MornyeArtworkContrastState extends State { + final _background = GlobalKey(); + Timer? _timer; + bool _sampling = false; + Map _colors = {}; + + @override + void initState() { + super.initState(); + _schedule(); + } + + @override + void didUpdateWidget(MornyeArtworkContrast oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.enabled != widget.enabled) _schedule(); + } + + void _schedule() { + _timer?.cancel(); + if (!widget.enabled) { + _colors = {}; + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) => _sample()); + _timer = Timer.periodic( + const Duration(milliseconds: 333), + (_) => _sample(), + ); + } + + Future _sample() async { + if (!mounted || !widget.enabled || _sampling) return; + final lifecycle = WidgetsBinding.instance.lifecycleState; + if (lifecycle != null && lifecycle != AppLifecycleState.resumed) return; + final route = ModalRoute.of(context); + if (route != null && + (!route.isCurrent || route.animation?.isAnimating == true)) { + return; + } + final boundary = _background.currentContext?.findRenderObject(); + if (boundary is! RenderRepaintBoundary || + !boundary.hasSize || + boundary.debugNeedsPaint || + boundary.size.isEmpty) { + return; + } + _sampling = true; + ui.Image? image; + try { + final scale = (48 / boundary.size.width).clamp(0.01, 1.0); + image = await boundary.toImage(pixelRatio: scale); + final pixels = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + if (!mounted || !widget.enabled || pixels == null) return; + final origin = boundary.localToGlobal(Offset.zero); + final next = {}; + for (final entry in widget.targets.entries) { + final target = entry.value.currentContext?.findRenderObject(); + if (target is! RenderBox || !target.hasSize || !target.attached) { + continue; + } + final rect = target.localToGlobal(Offset.zero) - origin & target.size; + final left = (rect.left * scale).floor().clamp(0, image.width); + final right = (rect.right * scale).ceil().clamp(0, image.width); + final top = (rect.top * scale).floor().clamp(0, image.height); + final bottom = (rect.bottom * scale).ceil().clamp(0, image.height); + var luminance = 0.0; + var count = 0; + for (var y = top; y < bottom; y++) { + for (var x = left; x < right; x++) { + final index = (y * image.width + x) * 4; + luminance += Color.fromARGB( + 255, + pixels.getUint8(index), + pixels.getUint8(index + 1), + pixels.getUint8(index + 2), + ).computeLuminance(); + count++; + } + } + if (count == 0) continue; + // Hysteresis avoids flicker when frames hover around the crossover. + final threshold = _colors[entry.key] == Colors.black ? 0.16 : 0.20; + next[entry.key] = luminance / count > threshold + ? Colors.black + : Colors.white; + } + if (next.entries.any((entry) => _colors[entry.key] != entry.value)) { + _colors = next; + widget.onChanged(next); + } + } catch (_) { + // Unreadable surfaces keep the light controls over the dark fade. + } finally { + image?.dispose(); + _sampling = false; + } + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => + RepaintBoundary(key: _background, child: widget.child); +} diff --git a/lib/widgets/mornye_player_artwork.dart b/lib/widgets/mornye_player_artwork.dart index 6d7109b1..4bfa3129 100644 --- a/lib/widgets/mornye_player_artwork.dart +++ b/lib/widgets/mornye_player_artwork.dart @@ -1,21 +1,35 @@ import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/providers/player_artwork_video_provider.dart'; import 'package:spotiflac_android/widgets/motion_header_banner.dart'; import 'package:spotiflac_android/widgets/player_artwork.dart'; +import 'package:video_player/video_player.dart'; -class MornyePlayerArtwork extends StatelessWidget { +class MornyePlayerArtwork extends ConsumerStatefulWidget { const MornyePlayerArtwork({ super.key, required this.mediaItem, this.videoUrl, this.onAspectRatioChanged, this.onError, + this.resolvingVideo = false, }); final MediaItem mediaItem; final String? videoUrl; final ValueChanged? onAspectRatioChanged; final VoidCallback? onError; + final bool resolvingVideo; + + @override + ConsumerState createState() => + _MornyePlayerArtworkState(); +} + +class _MornyePlayerArtworkState extends ConsumerState { + String? _displayedSource; + VideoPlayerController? _displayedController; @override Widget build(BuildContext context) { @@ -24,7 +38,7 @@ class MornyePlayerArtwork extends StatelessWidget { child: AspectRatio( aspectRatio: 1, child: PlayerArtwork( - artUri: mediaItem.artUri?.toString(), + artUri: widget.mediaItem.artUri?.toString(), colorScheme: Theme.of(context).colorScheme, cacheWidth: (MediaQuery.sizeOf(context).width * @@ -33,15 +47,53 @@ class MornyePlayerArtwork extends StatelessWidget { ), ), ); - if (MediaQuery.disableAnimationsOf(context)) return fallback; - return videoUrl == null - ? fallback - : MotionHeaderBanner( - key: ValueKey(videoUrl), - videoUrl: videoUrl!, - fallback: fallback, - onAspectRatioChanged: onAspectRatioChanged, - onError: onError, - ); + if (MediaQuery.disableAnimationsOf(context)) { + _displayedSource = null; + _displayedController = null; + return fallback; + } + final videoUrl = widget.videoUrl; + // Keep one outgoing decoder alive until the next cover is ready. Rapid + // skips must not replace a visible frame with a loading placeholder. + if (_displayedSource != null && _displayedSource != videoUrl) { + ref.watch(playerArtworkVideoProvider(_displayedSource!)); + } + final prepared = videoUrl == null + ? null + : ref.watch(playerArtworkVideoProvider(videoUrl)); + if (prepared?.hasError == true) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.onError?.call(); + }); + } + if (prepared?.value case final controller?) { + _displayedSource = videoUrl; + _displayedController = controller; + } else if (!widget.resolvingVideo && + (videoUrl == null || prepared?.hasError == true)) { + _displayedSource = null; + _displayedController = null; + } + return Stack( + fit: StackFit.expand, + children: [ + fallback, + if (_displayedController != null) + MotionHeaderBanner( + videoUrl: _displayedSource!, + fallback: const SizedBox.shrink(), + controller: _displayedController, + fadeDuration: Duration.zero, + onAspectRatioChanged: (ratio) { + if (_displayedSource == widget.videoUrl) { + widget.onAspectRatioChanged?.call(ratio); + } + }, + onError: () { + if (_displayedSource == widget.videoUrl) widget.onError?.call(); + }, + ), + ], + ); } } diff --git a/lib/widgets/mornye_player_background.dart b/lib/widgets/mornye_player_background.dart index 2398b5da..6d00521a 100644 --- a/lib/widgets/mornye_player_background.dart +++ b/lib/widgets/mornye_player_background.dart @@ -58,9 +58,7 @@ class MornyePlayerBackground extends StatelessWidget { child: Stack( fit: StackFit.expand, children: [ - AnimatedPositioned( - duration: motion, - curve: Curves.easeInOutCubic, + Positioned( left: 0, right: 0, top: 0, diff --git a/lib/widgets/mornye_player_favorite_button.dart b/lib/widgets/mornye_player_favorite_button.dart index 09205358..cf889713 100644 --- a/lib/widgets/mornye_player_favorite_button.dart +++ b/lib/widgets/mornye_player_favorite_button.dart @@ -12,10 +12,12 @@ class MornyePlayerFavoriteButton extends ConsumerStatefulWidget { super.key, required this.mediaItem, this.compact = false, + this.color, }); final MediaItem mediaItem; final bool compact; + final Color? color; @override ConsumerState createState() => @@ -66,7 +68,7 @@ class _MornyePlayerFavoriteButtonState tooltip: loved ? context.l10n.trackOptionRemoveFromLoved : context.l10n.trackOptionAddToLoved, - color: Theme.of(context).colorScheme.onSurface, + color: widget.color ?? Theme.of(context).colorScheme.onSurface, iconSize: widget.compact ? 20 : 24, onPressed: _saving ? null : _toggle, ), diff --git a/lib/widgets/mornye_volume_control.dart b/lib/widgets/mornye_volume_control.dart index fa2b0c5f..9d3af410 100644 --- a/lib/widgets/mornye_volume_control.dart +++ b/lib/widgets/mornye_volume_control.dart @@ -36,7 +36,9 @@ final systemVolumeWriterProvider = Provider Function(double)>(( }); class MornyeVolumeControl extends ConsumerStatefulWidget { - const MornyeVolumeControl({super.key}); + const MornyeVolumeControl({super.key, this.foreground = Colors.white}); + + final Color foreground; @override ConsumerState createState() => @@ -103,10 +105,10 @@ class _MornyeVolumeControlState extends ConsumerState { padding: const EdgeInsets.symmetric(horizontal: 28), child: Row( children: [ - const Icon( + Icon( CupertinoIcons.speaker_fill, size: 16, - color: Colors.white54, + color: widget.foreground.withValues(alpha: 0.54), ), const SizedBox(width: 10), Expanded( @@ -117,8 +119,8 @@ class _MornyeVolumeControlState extends ConsumerState { opacity: volume == null ? 0.4 : 1, child: MornyePlayerSlider( value: value, - activeColor: Colors.white70, - inactiveColor: Colors.white12, + activeColor: widget.foreground.withValues(alpha: 0.70), + inactiveColor: widget.foreground.withValues(alpha: 0.12), onChangeStart: (_) => _dragging = true, onChanged: _setVolume, onChangeEnd: (value) { @@ -131,10 +133,10 @@ class _MornyeVolumeControlState extends ConsumerState { ), ), const SizedBox(width: 10), - const Icon( + Icon( CupertinoIcons.speaker_3_fill, size: 16, - color: Colors.white54, + color: widget.foreground.withValues(alpha: 0.54), ), ], ), diff --git a/lib/widgets/motion_header_banner.dart b/lib/widgets/motion_header_banner.dart index 94c0ab74..d71ee914 100644 --- a/lib/widgets/motion_header_banner.dart +++ b/lib/widgets/motion_header_banner.dart @@ -14,6 +14,8 @@ class MotionHeaderBanner extends StatefulWidget { final Alignment alignment; final ValueChanged? onAspectRatioChanged; final VoidCallback? onError; + final VideoPlayerController? controller; + final Duration fadeDuration; const MotionHeaderBanner({ super.key, @@ -23,6 +25,8 @@ class MotionHeaderBanner extends StatefulWidget { this.alignment = Alignment.topCenter, this.onAspectRatioChanged, this.onError, + this.controller, + this.fadeDuration = const Duration(milliseconds: 400), }); @override @@ -32,6 +36,7 @@ class MotionHeaderBanner extends StatefulWidget { class _MotionHeaderBannerState extends State with WidgetsBindingObserver { VideoPlayerController? _controller; + bool _ownsController = true; bool _ready = false; bool _failed = false; bool _headerVisible = true; @@ -86,7 +91,8 @@ class _MotionHeaderBannerState extends State @override void didUpdateWidget(MotionHeaderBanner oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.videoUrl != widget.videoUrl) { + if (oldWidget.videoUrl != widget.videoUrl || + oldWidget.controller != widget.controller) { _disposeController(); _ready = false; _failed = false; @@ -95,6 +101,19 @@ class _MotionHeaderBannerState extends State } Future _initialize() async { + final prepared = widget.controller; + if (prepared != null && prepared.value.isInitialized) { + _ownsController = false; + _controller = prepared; + _ready = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !identical(prepared, _controller)) return; + widget.onAspectRatioChanged?.call(prepared.value.aspectRatio); + _syncPlayback(); + }); + return; + } + _ownsController = true; final url = widget.videoUrl.trim(); if (url.isEmpty) { setState(() => _failed = true); @@ -136,7 +155,11 @@ class _MotionHeaderBannerState extends State void _disposeController() { final controller = _controller; _controller = null; - controller?.dispose(); + if (_ownsController) { + controller?.dispose(); + } else { + controller?.pause(); + } } @override @@ -163,7 +186,7 @@ class _MotionHeaderBannerState extends State widget.fallback, AnimatedOpacity( opacity: showVideo ? 1.0 : 0.0, - duration: const Duration(milliseconds: 400), + duration: widget.fadeDuration, child: showVideo ? FittedBox( fit: widget.fit, diff --git a/lib/widgets/playback_seek_slider.dart b/lib/widgets/playback_seek_slider.dart index ebbe7da3..368d46fd 100644 --- a/lib/widgets/playback_seek_slider.dart +++ b/lib/widgets/playback_seek_slider.dart @@ -66,6 +66,10 @@ class _PlaybackSeekSliderState extends State { child: IgnorePointer( ignoring: !enabled, child: MornyePlayerSlider( + activeColor: + SliderTheme.of(context).activeTrackColor ?? Colors.white, + inactiveColor: + SliderTheme.of(context).inactiveTrackColor ?? Colors.white12, value: enabled ? currentMs : 0, max: maxMs, onChangeStart: (_) => _gestureGeneration++, diff --git a/lib/widgets/player_artwork.dart b/lib/widgets/player_artwork.dart index 1ac64141..a41d4411 100644 --- a/lib/widgets/player_artwork.dart +++ b/lib/widgets/player_artwork.dart @@ -40,8 +40,9 @@ class PlayerArtwork extends StatelessWidget { fit: BoxFit.cover, cacheManager: CoverCacheManager.instance, memCacheWidth: cacheWidth, - fadeInDuration: const Duration(milliseconds: 150), + fadeInDuration: Duration.zero, fadeOutDuration: const Duration(milliseconds: 0), + useOldImageOnUrlChange: true, placeholder: (_, _) => placeholder, errorWidget: (_, _, _) => placeholder, ); @@ -54,6 +55,7 @@ class PlayerArtwork extends StatelessWidget { File(path), fit: BoxFit.cover, cacheWidth: cacheWidth, + gaplessPlayback: true, errorBuilder: (_, _, _) => placeholder, ); } diff --git a/test/download_motion_artwork_source_test.dart b/test/download_motion_artwork_source_test.dart new file mode 100644 index 00000000..208f17cd --- /dev/null +++ b/test/download_motion_artwork_source_test.dart @@ -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 albumResponse; + late Map trackResponse; + + Future> metadata( + String provider, + String type, + String id, + ) async { + calls.add((provider, type, id)); + return type == 'album' ? albumResponse : trackResponse; + } + + Future resolve({ + Track original = track, + Track downloaded = track, + Map 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': {}}; + 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); + }); +} diff --git a/test/mornye_artwork_contrast_test.dart b/test/mornye_artwork_contrast_test.dart new file mode 100644 index 00000000..303206fd --- /dev/null +++ b/test/mornye_artwork_contrast_test.dart @@ -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 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 sample() async { + await tester.pump(const Duration(milliseconds: 334)); + await tester.runAsync( + () => Future.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()); + }); +} diff --git a/test/motion_artwork_download_test.dart b/test/motion_artwork_download_test.dart new file mode 100644 index 00000000..22d4ec31 --- /dev/null +++ b/test/motion_artwork_download_test.dart @@ -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 playlists; + late List requests; + late List 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 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())); + 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); + }); +} diff --git a/test/motion_artwork_store_test.dart b/test/motion_artwork_store_test.dart index 17e15c4d..ad02885c 100644 --- a/test/motion_artwork_store_test.dart +++ b/test/motion_artwork_store_test.dart @@ -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 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().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 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(); final store = MotionArtworkStore( directory: () async => Directory('${root.path}/motion'), + validate: (_) async => true, download: (_, output) async { started.complete(); await gate.future; diff --git a/test/motion_header_banner_test.dart b/test/motion_header_banner_test.dart index f7ae2a2f..f0949542 100644 --- a/test/motion_header_banner_test.dart +++ b/test/motion_header_banner_test.dart @@ -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>? events; @override Future init() async {} @@ -16,17 +25,21 @@ class _VideoPlatform extends VideoPlayerPlatform { @override Future createWithOptions(VideoCreationOptions options) async { source = options.dataSource; - return 1; + final id = ++creations; + events?[id] = StreamController(); + return id; } @override - Stream videoEventsFor(int playerId) => Stream.value( - VideoEvent( - eventType: VideoEventType.initialized, - duration: const Duration(seconds: 30), - size: const Size(320, 180), - ), - ); + Stream videoEventsFor(int playerId) => + events?[playerId]?.stream ?? + Stream.value( + VideoEvent( + eventType: VideoEventType.initialized, + duration: const Duration(seconds: 30), + size: const Size(320, 180), + ), + ); @override Future play(int playerId) async { @@ -58,10 +71,126 @@ class _VideoPlatform extends VideoPlayerPlatform { Widget buildViewWithOptions(VideoViewOptions options) => const SizedBox(); @override - Future dispose(int playerId) async {} + Future 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( + 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.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(find.byType(MotionHeaderBanner)) + .videoUrl, + source.value, + ); + source.value = 'file:///second.mp4'; + await tester.pump(); + expect( + tester + .widget(find.byType(MotionHeaderBanner)) + .videoUrl, + 'file:///first.mp4', + ); + expect(platform.disposals, 0); + ready(2); + await tester.pump(); + await tester.pump(); + expect( + tester + .widget(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 { diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index 44b60213..f8350f10 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -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? 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().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 { diff --git a/test/player_artwork_test.dart b/test/player_artwork_test.dart new file mode 100644 index 00000000..4dd45b41 --- /dev/null +++ b/test/player_artwork_test.dart @@ -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 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(); + 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(find.byType(RawImage)).image; + expect(displayed, isNotNull); + await tester.pumpWidget(app('/cover-second.png')); + expect( + tester.widget(find.byType(RawImage)).image, + same(displayed), + ); + expect(find.byIcon(Icons.music_note), findsNothing); + pending.complete(ImageInfo(image: second!)); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byType(RawImage)).image, + isNot(same(displayed)), + ); + expect(find.byIcon(Icons.music_note), findsNothing); + await tester.pumpWidget(const SizedBox()); + }, + ); +} diff --git a/test/player_motion_artwork_test.dart b/test/player_motion_artwork_test.dart deleted file mode 100644 index 0de4886d..00000000 --- a/test/player_motion_artwork_test.dart +++ /dev/null @@ -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 = []; - 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); - }); -}