mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 04:42:02 +02:00
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.
This commit is contained in:
@@ -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<MotionArtwork?, PlayerArtworkAlbum>((ref, album) async {
|
||||
if (album.album.trim().isEmpty || album.artist.trim().isEmpty) {
|
||||
|
||||
@@ -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<DownloadedAlbumScreen>
|
||||
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<DownloadedAlbumScreen>
|
||||
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,
|
||||
|
||||
@@ -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<LocalAlbumScreen>
|
||||
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<LocalAlbumScreen>
|
||||
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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user