From cec9d5cbce48e0d301cf048deef49e0f7f38810a Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:21:10 +0700 Subject: [PATCH] feat(library): show saved animated artwork on local albums 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. --- .../player_motion_artwork_provider.dart | 4 +- lib/screens/downloaded_album_screen.dart | 19 ++- lib/screens/local_album_screen.dart | 17 ++- test/motion_header_banner_test.dart | 110 ++++++++++++++++++ 4 files changed, 146 insertions(+), 4 deletions(-) diff --git a/lib/providers/player_motion_artwork_provider.dart b/lib/providers/player_motion_artwork_provider.dart index dc3119db..d7c7e982 100644 --- a/lib/providers/player_motion_artwork_provider.dart +++ b/lib/providers/player_motion_artwork_provider.dart @@ -5,8 +5,8 @@ typedef PlayerArtworkAlbum = ({String album, String artist}); final motionArtworkStoreProvider = Provider((ref) => MotionArtworkStore()); -/// Playback only reads artwork already saved with a download. Opening a player -/// must never search other extensions or trigger an artwork download. +/// Player and local album pages read artwork already saved with a download. +/// Opening either must never search extensions or download more artwork. final playerMotionArtworkProvider = FutureProvider.autoDispose .family((ref, album) async { if (album.album.trim().isEmpty || album.artist.trim().isEmpty) { diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 903ac62f..0face0dc 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -22,6 +22,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/playback_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/screens/collapsing_header_scroll_mixin.dart'; import 'package:spotiflac_android/screens/selection_mode_mixin.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; @@ -36,6 +37,7 @@ import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; import 'package:spotiflac_android/widgets/disc_separator_chip.dart'; import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:spotiflac_android/widgets/mornye_artist_header.dart'; +import 'package:spotiflac_android/widgets/motion_header_banner.dart'; class DownloadedAlbumScreen extends ConsumerStatefulWidget { final String albumName; @@ -345,6 +347,18 @@ class _DownloadedAlbumScreenState extends ConsumerState final expandedHeight = calculateExpandedHeight(context); final embeddedCoverPath = _resolveAlbumEmbeddedCoverPath(tracks); final commonQuality = _getCommonQuality(tracks, qualityLabelMode); + final motion = context.isMornye && !MediaQuery.disableAnimationsOf(context) + ? ref + .watch( + playerMotionArtworkProvider(( + album: widget.albumName, + artist: tracks.isEmpty + ? widget.artistName + : tracks.first.artistName, + )), + ) + .value + : null; final cacheWidth = coverCacheWidthForViewport(context); final Widget background = embeddedCoverPath != null @@ -377,9 +391,12 @@ class _DownloadedAlbumScreenState extends ConsumerState return AlbumDetailHeader( title: widget.albumName, immersive: context.isMornye, + squareArtwork: motion == null, expandedHeight: expandedHeight, showTitleInAppBar: showTitleInAppBar, - background: background, + background: motion == null + ? background + : MotionHeaderBanner(videoUrl: motion.source, fallback: background), paletteSource: embeddedCoverPath ?? widget.coverUrl, blurAndScrimBackground: embeddedCoverPath != null || widget.coverUrl != null, diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index e618787b..1f2b2c2b 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -20,6 +20,7 @@ import 'package:spotiflac_android/services/local_track_redownload_service.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/playback_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/screens/collapsing_header_scroll_mixin.dart'; import 'package:spotiflac_android/screens/selection_mode_mixin.dart'; import 'package:spotiflac_android/widgets/collection_scaffold.dart'; @@ -31,6 +32,7 @@ import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; import 'package:spotiflac_android/widgets/disc_separator_chip.dart'; import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:spotiflac_android/widgets/mornye_artist_header.dart'; +import 'package:spotiflac_android/widgets/motion_header_banner.dart'; class LocalAlbumScreen extends ConsumerStatefulWidget { final String albumName; @@ -216,6 +218,16 @@ class _LocalAlbumScreenState extends ConsumerState String qualityLabelMode, ) { final expandedHeight = calculateExpandedHeight(context); + final motion = context.isMornye && !MediaQuery.disableAnimationsOf(context) + ? ref + .watch( + playerMotionArtworkProvider(( + album: widget.albumName, + artist: _sortedTracksCache.first.artistName, + )), + ) + .value + : null; final cacheWidth = coverCacheWidthForViewport(context); final Widget background = widget.coverPath != null @@ -239,9 +251,12 @@ class _LocalAlbumScreenState extends ConsumerState return AlbumDetailHeader( title: widget.albumName, immersive: context.isMornye, + squareArtwork: motion == null, expandedHeight: expandedHeight, showTitleInAppBar: showTitleInAppBar, - background: background, + background: motion == null + ? background + : MotionHeaderBanner(videoUrl: motion.source, fallback: background), paletteSource: widget.coverPath, blurAndScrimBackground: widget.coverPath != null, coverBuilder: (context, coverSize) => widget.coverPath != null diff --git a/test/motion_header_banner_test.dart b/test/motion_header_banner_test.dart index 65979cb7..280e3992 100644 --- a/test/motion_header_banner_test.dart +++ b/test/motion_header_banner_test.dart @@ -2,9 +2,19 @@ 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'; @@ -78,6 +88,106 @@ class _VideoPlatform extends VideoPlayerPlatform { } 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 {