feat(mornye): cache motion covers and refine player and search

Save album motion videos in app storage during downloads and prefer offline playback with an aspect-aware layout. Add cache management for saved videos.

Center active lyrics on opening, expand lyrics during manual scrolling, and adjust landscape header spacing. Group search rows and skeletons with dividers and remove redundant result icons.

Validation: Flutter analysis clean; 723 tests passed, 4 skipped; iOS simulator build installed.
This commit is contained in:
zarzet committed 2026-09-19 17:26:26 +07:00
1 parent d5ac292d36
commit e61ff64adc
26 files changed
+1209 -291

No files matched your search

+2
View File
@@ -1,4 +1,6 @@
{ {
"cacheMotionArtwork": "Offline motion covers",
"cacheMotionArtworkDesc": "Album videos saved with downloads. Removing them keeps your music files and static covers.",
"nowPlayingVolume": "Volume", "nowPlayingVolume": "Volume",
"@nowPlayingVolume": { "@nowPlayingVolume": {
"description": "System volume control label in the Mornye player" "description": "System volume control label in the Mornye player"
+2
View File
@@ -1,4 +1,6 @@
{ {
"cacheMotionArtwork": "Cover bergerak offline",
"cacheMotionArtworkDesc": "Video album yang disimpan saat download. Menghapusnya tetap menyimpan file musik dan cover biasa.",
"nowPlayingVolume": "Volume", "nowPlayingVolume": "Volume",
"@nowPlayingVolume": { "@nowPlayingVolume": {
"description": "Label kontrol volume sistem di pemutar Mornye" "description": "Label kontrol volume sistem di pemutar Mornye"
+5
View File
@@ -14,6 +14,7 @@ class Track {
final String? artistId; final String? artistId;
final String? albumId; final String? albumId;
final String? coverUrl; final String? coverUrl;
final String? headerVideoUrl;
final String? isrc; final String? isrc;
final String? previewUrl; final String? previewUrl;
final int duration; final int duration;
@@ -46,6 +47,7 @@ class Track {
this.artistId, this.artistId,
this.albumId, this.albumId,
this.coverUrl, this.coverUrl,
this.headerVideoUrl,
this.isrc, this.isrc,
this.previewUrl, this.previewUrl,
required this.duration, required this.duration,
@@ -128,6 +130,7 @@ class Track {
coverUrl: normalizeCoverReference( coverUrl: normalizeCoverReference(
(data['cover_url'] ?? data['images'])?.toString(), (data['cover_url'] ?? data['images'])?.toString(),
), ),
headerVideoUrl: normalizeRemoteHttpUrl(data['header_video']?.toString()),
isrc: data['isrc']?.toString(), isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(), duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?, trackNumber: data['track_number'] as int?,
@@ -162,6 +165,7 @@ class Track {
String? artistId, String? artistId,
String? albumId, String? albumId,
String? coverUrl, String? coverUrl,
String? headerVideoUrl,
String? isrc, String? isrc,
String? previewUrl, String? previewUrl,
int? duration, int? duration,
@@ -194,6 +198,7 @@ class Track {
artistId: artistId ?? this.artistId, artistId: artistId ?? this.artistId,
albumId: albumId ?? this.albumId, albumId: albumId ?? this.albumId,
coverUrl: coverUrl ?? this.coverUrl, coverUrl: coverUrl ?? this.coverUrl,
headerVideoUrl: headerVideoUrl ?? this.headerVideoUrl,
isrc: isrc ?? this.isrc, isrc: isrc ?? this.isrc,
previewUrl: previewUrl ?? this.previewUrl, previewUrl: previewUrl ?? this.previewUrl,
duration: duration ?? this.duration, duration: duration ?? this.duration,
+2
View File
@@ -15,6 +15,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
artistId: json['artistId'] as String?, artistId: json['artistId'] as String?,
albumId: json['albumId'] as String?, albumId: json['albumId'] as String?,
coverUrl: json['coverUrl'] as String?, coverUrl: json['coverUrl'] as String?,
headerVideoUrl: json['headerVideoUrl'] as String?,
isrc: json['isrc'] as String?, isrc: json['isrc'] as String?,
previewUrl: json['previewUrl'] as String?, previewUrl: json['previewUrl'] as String?,
duration: (json['duration'] as num).toInt(), duration: (json['duration'] as num).toInt(),
@@ -52,6 +53,7 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
'artistId': instance.artistId, 'artistId': instance.artistId,
'albumId': instance.albumId, 'albumId': instance.albumId,
'coverUrl': instance.coverUrl, 'coverUrl': instance.coverUrl,
'headerVideoUrl': instance.headerVideoUrl,
'isrc': instance.isrc, 'isrc': instance.isrc,
'previewUrl': instance.previewUrl, 'previewUrl': instance.previewUrl,
'duration': instance.duration, 'duration': instance.duration,
@@ -17,6 +17,7 @@ import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/app_navigation_service.dart'; import 'package:spotiflac_android/services/app_navigation_service.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/player_motion_artwork_provider.dart';
import 'package:spotiflac_android/providers/download_verification_retry_guard.dart'; import 'package:spotiflac_android/providers/download_verification_retry_guard.dart';
import 'package:spotiflac_android/providers/download_queue_state.dart'; import 'package:spotiflac_android/providers/download_queue_state.dart';
import 'package:spotiflac_android/services/app_state_database.dart'; import 'package:spotiflac_android/services/app_state_database.dart';
@@ -37,6 +38,7 @@ import 'package:spotiflac_android/utils/extension_auth_launcher.dart';
import 'package:spotiflac_android/utils/download_error_type.dart'; import 'package:spotiflac_android/utils/download_error_type.dart';
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/utils/progress_stream_poller.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'; import 'package:spotiflac_android/providers/download_history_provider.dart';
@@ -83,6 +83,100 @@ Future<String> _normalizeDecryptedIsoBmffAudioPath(
} }
extension _DownloadQueueFinalization on DownloadQueueNotifier { extension _DownloadQueueFinalization on DownloadQueueNotifier {
Future<void> _saveDownloadedMotionArtwork(
Ref ref,
DownloadItem item,
Track track,
Map<String, dynamic> result,
) async {
// Store independently of history preferences and the selected UI theme.
// A later switch to Mornye must still work without network access.
try {
final album = (
album:
_resolveMetadataText(
track.albumName,
result['album']?.toString(),
) ??
item.track.albumName,
artist:
_resolveMetadataText(
track.artistName,
result['artist']?.toString(),
) ??
item.track.artistName,
);
final extensions = ref.read(extensionProvider).extensions;
final preferred = ref.read(settingsProvider).searchProvider;
final providers =
<String>{
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)),
);
},
);
if (artwork != null && ref.mounted) {
ref.invalidate(playerMotionArtworkProvider(album));
}
} catch (error) {
_log.w('Optional motion artwork was not saved: ${error.runtimeType}');
}
}
Future<_AutoConversionOutcome> _autoConvertDownloadedFile({ Future<_AutoConversionOutcome> _autoConvertDownloadedFile({
required String itemId, required String itemId,
required String filePath, required String filePath,
@@ -1245,6 +1245,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
if (result['native_finalized'] == true) { if (result['native_finalized'] == true) {
final nativeFinalizedFilePath = filePath; final nativeFinalizedFilePath = filePath;
await _saveDownloadedMotionArtwork(ref, item, item.track, result);
await persistBeforePublishingDownloadCompletion( await persistBeforePublishingDownloadCompletion(
persist: () async { persist: () async {
if (!settings.saveDownloadHistory) return; if (!settings.saveDownloadHistory) return;
@@ -1532,6 +1533,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
lowerFilePath.endsWith('.ogg'); lowerFilePath.endsWith('.ogg');
final completedFilePath = filePath; final completedFilePath = filePath;
await _saveDownloadedMotionArtwork(ref, item, trackToDownload, result);
final lyricsAvailability = await _resolveFinalLyricsAvailability( final lyricsAvailability = await _resolveFinalLyricsAvailability(
filePath: completedFilePath, filePath: completedFilePath,
externalLrcWritten: externalLrcWritten, externalLrcWritten: externalLrcWritten,
@@ -1717,6 +1717,12 @@ class _DownloadRun {
externalLrcWritten: externalLrcWritten, externalLrcWritten: externalLrcWritten,
); );
await n._saveDownloadedMotionArtwork(
n.ref,
item,
trackToDownload,
result,
);
await persistBeforePublishingDownloadCompletion( await persistBeforePublishingDownloadCompletion(
persist: () async { persist: () async {
if (!settings.saveDownloadHistory) return; if (!settings.saveDownloadHistory) return;
@@ -4,11 +4,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.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/string_utils.dart';
import 'package:spotiflac_android/utils/ttl_cache.dart'; import 'package:spotiflac_android/utils/ttl_cache.dart';
typedef PlayerArtworkAlbum = ({String album, String artist}); typedef PlayerArtworkAlbum = ({String album, String artist});
final motionArtworkStoreProvider = Provider((ref) => MotionArtworkStore());
final _motionCache = TtlCache<Future<String?>>( final _motionCache = TtlCache<Future<String?>>(
const Duration(minutes: 10), const Duration(minutes: 10),
maxEntries: 32, maxEntries: 32,
@@ -17,10 +20,13 @@ final _motionCache = TtlCache<Future<String?>>(
/// Resolve the same album motion artwork used by collection headers. Cache by /// Resolve the same album motion artwork used by collection headers. Cache by
/// album so advancing through its tracks does not repeat metadata requests. /// album so advancing through its tracks does not repeat metadata requests.
final playerMotionArtworkProvider = FutureProvider.autoDispose final playerMotionArtworkProvider = FutureProvider.autoDispose
.family<String?, PlayerArtworkAlbum>((ref, album) { .family<MotionArtwork?, PlayerArtworkAlbum>((ref, album) async {
if (album.album.trim().isEmpty || album.artist.trim().isEmpty) { if (album.album.trim().isEmpty || album.artist.trim().isEmpty) {
return null; 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 extensions = ref.watch(extensionProvider);
final preferred = ref.watch( final preferred = ref.watch(
settingsProvider.select((s) => s.searchProvider), settingsProvider.select((s) => s.searchProvider),
@@ -37,7 +43,10 @@ final playerMotionArtworkProvider = FutureProvider.autoDispose
if (providers.isEmpty) return null; if (providers.isEmpty) return null;
final key = jsonEncode([album.album, album.artist, providers]); final key = jsonEncode([album.album, album.artist, providers]);
final cached = _motionCache.get(key); final cached = _motionCache.get(key);
if (cached != null) return cached; if (cached != null) {
final source = await cached;
return source == null ? null : MotionArtwork(source);
}
final request = findPlayerMotionArtwork( final request = findPlayerMotionArtwork(
album: album, album: album,
providerIds: providers, providerIds: providers,
@@ -53,7 +62,8 @@ final playerMotionArtworkProvider = FutureProvider.autoDispose
).timeout(const Duration(seconds: 6)), ).timeout(const Duration(seconds: 6)),
); );
_motionCache.set(key, request); _motionCache.set(key, request);
return request; final source = await request;
return source == null ? null : MotionArtwork(source);
}); });
Future<String?> findPlayerMotionArtwork({ Future<String?> findPlayerMotionArtwork({
+4 -1
View File
@@ -229,13 +229,16 @@ class TrackNotifier extends Notifier<TrackState> {
result['tracks'] != null) { result['tracks'] != null) {
final trackList = result['tracks'] as List<dynamic>; final trackList = result['tracks'] as List<dynamic>;
final collectionName = result['name'] as String?; final collectionName = result['name'] as String?;
final headerVideo = type == 'album'
? normalizeRemoteHttpUrl(result['header_video']?.toString())
: null;
final tracks = trackList final tracks = trackList
.map( .map(
(t) => Track.fromBackendMap( (t) => Track.fromBackendMap(
t as Map<String, dynamic>, t as Map<String, dynamic>,
source: extensionId, source: extensionId,
playlistName: type == 'playlist' ? collectionName : null, playlistName: type == 'playlist' ? collectionName : null,
), ).copyWith(headerVideoUrl: headerVideo),
) )
.toList(); .toList();
state = TrackState( state = TrackState(
+1 -1
View File
@@ -212,7 +212,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
t as Map<String, dynamic>, t as Map<String, dynamic>,
albumTypeFallback: albumType, albumTypeFallback: albumType,
totalTracksFallback: totalTracks, totalTracksFallback: totalTracks,
), ).copyWith(headerVideoUrl: normalizeRemoteHttpUrl(headerVideo)),
) )
.toList(); .toList();
+15 -10
View File
@@ -497,14 +497,17 @@ extension _HomeTabSearchResultsUI on _HomeTabState {
height: 32, height: 32,
child: TextButton.icon( child: TextButton.icon(
onPressed: () => _showSortOptions(colorScheme), onPressed: () => _showSortOptions(colorScheme),
icon: Icon( icon: mornye
mornye ? mornyeIconFor(Icons.swap_vert) : Icons.swap_vert, ? null
size: 18, : Icon(
color: Icons.swap_vert,
_searchSortOption != HomeSearchSortOption.defaultOrder size: 18,
? colorScheme.primary color:
: colorScheme.onSurfaceVariant, _searchSortOption !=
), HomeSearchSortOption.defaultOrder
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
label: Text( label: Text(
_searchSortOption != HomeSearchSortOption.defaultOrder _searchSortOption != HomeSearchSortOption.defaultOrder
? _sortOptionLabel(_searchSortOption) ? _sortOptionLabel(_searchSortOption)
@@ -535,10 +538,12 @@ extension _HomeTabSearchResultsUI on _HomeTabState {
final isLast = index == itemCount - 1; final isLast = index == itemCount - 1;
if (mornye) { if (mornye) {
return Padding( return Padding(
padding: EdgeInsets.fromLTRB(16, 0, 16, isLast ? 0 : 8), padding: const EdgeInsets.symmetric(horizontal: 16),
child: MornyeGlassPanel.overlay( child: MornyeGlassPanel.overlay(
radius: 24, radius: 24,
child: itemBuilder(index, false), firstInGroup: isFirst,
lastInGroup: isLast,
child: itemBuilder(index, !isLast),
), ),
); );
} }
+6 -7
View File
@@ -532,13 +532,12 @@ class _CollectionItemWidget extends StatelessWidget {
], ],
), ),
), ),
Icon( if (!context.isMornye)
context.isMornye Icon(
? mornyeIconFor(Icons.chevron_right) Icons.chevron_right,
: Icons.chevron_right, color: colorScheme.onSurfaceVariant,
color: colorScheme.onSurfaceVariant, size: 24,
size: context.isMornye ? 18 : 24, ),
),
], ],
), ),
), ),
+397 -217
View File
@@ -218,6 +218,11 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
bool _bottomDragForwarding = false; bool _bottomDragForwarding = false;
double _bottomDragTotal = 0; double _bottomDragTotal = 0;
bool _queueSheetShowing = false; bool _queueSheetShowing = false;
String? _measuredMotionSource;
double? _motionAspectRatio;
String? _failedMotionSource;
bool _lyricsControlsHidden = false;
double _lyricsScrollDistance = 0;
@override @override
void initState() { void initState() {
@@ -407,7 +412,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
} }
final source = mediaItem.extras?['source']?.toString() ?? ''; final source = mediaItem.extras?['source']?.toString() ?? '';
final motionArtwork = mornye && !MediaQuery.disableAnimationsOf(context) final resolvedMotion = mornye && !MediaQuery.disableAnimationsOf(context)
? ref ? ref
.watch( .watch(
playerMotionArtworkProvider(( playerMotionArtworkProvider((
@@ -417,6 +422,36 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
) )
.value .value
: null; : null;
final motionArtwork = resolvedMotion?.source == _failedMotionSource
? null
: resolvedMotion;
final motionRatio =
motionArtwork?.aspectRatio ??
(_measuredMotionSource == motionArtwork?.source
? _motionAspectRatio
: null);
final squareArtwork =
motionArtwork == null || (motionRatio != null && motionRatio >= 0.95);
Widget artwork() => MornyePlayerArtwork(
mediaItem: mediaItem,
videoUrl: motionArtwork?.source,
onAspectRatioChanged: (ratio) {
if (!mounted ||
(_measuredMotionSource == motionArtwork?.source &&
_motionAspectRatio == ratio)) {
return;
}
setState(() {
_measuredMotionSource = motionArtwork?.source;
_motionAspectRatio = ratio;
});
},
onError: () {
if (mounted) {
setState(() => _failedMotionSource = motionArtwork?.source);
}
},
);
// The Mornye route moves the entire player together. A second, // The Mornye route moves the entire player together. A second,
// delayed content fade would make dismissal appear to pause partway down. // delayed content fade would make dismissal appear to pause partway down.
@@ -437,21 +472,27 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
foregroundColor: colorScheme.onSurface, foregroundColor: colorScheme.onSurface,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
systemOverlayStyle: mornye ? SystemUiOverlayStyle.light : null, systemOverlayStyle: mornye ? SystemUiOverlayStyle.light : null,
toolbarHeight: mornye ? 36 : kToolbarHeight, toolbarHeight: mornye
? (MediaQuery.orientationOf(context) == Orientation.landscape
? 0
: 36)
: kToolbarHeight,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
title: mornye title: mornye
? IconButton( ? MediaQuery.orientationOf(context) == Orientation.landscape
tooltip: context.l10n.nowPlayingMinimize, ? null
onPressed: () => Navigator.of(context).maybePop(), : IconButton(
icon: Container( tooltip: context.l10n.nowPlayingMinimize,
width: 36, onPressed: () => Navigator.of(context).maybePop(),
height: 5, icon: Container(
decoration: BoxDecoration( width: 36,
color: Colors.white.withValues(alpha: 0.4), height: 5,
borderRadius: BorderRadius.circular(3), decoration: BoxDecoration(
), color: Colors.white.withValues(alpha: 0.4),
), borderRadius: BorderRadius.circular(3),
) ),
),
)
: Text(context.l10n.nowPlayingTitle), : Text(context.l10n.nowPlayingTitle),
centerTitle: true, centerTitle: true,
leading: mornye leading: mornye
@@ -495,7 +536,9 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
mediaItem, mediaItem,
controller, controller,
colorScheme, colorScheme,
squareArtwork: motionArtwork == null, squareArtwork: squareArtwork,
motionArtwork: artwork(),
artworkAspectRatio: motionRatio,
) )
: PageView( : PageView(
controller: _pageController, controller: _pageController,
@@ -592,15 +635,10 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
RepaintBoundary( RepaintBoundary(
child: MornyePlayerBackground( child: MornyePlayerBackground(
artUri: mediaItem.artUri, artUri: mediaItem.artUri,
squareArtwork: motionArtwork == null, squareArtwork: squareArtwork,
artworkAspectRatio: motionRatio,
artwork: !_landscapeLyrics && _currentPage == 0 artwork: !_landscapeLyrics && _currentPage == 0
? Hero( ? Hero(tag: kNowPlayingArtworkHeroTag, child: artwork())
tag: kNowPlayingArtworkHeroTag,
child: MornyePlayerArtwork(
mediaItem: mediaItem,
videoUrl: motionArtwork,
),
)
: null, : null,
), ),
), ),
@@ -769,7 +807,11 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
} }
void _toggleMornyeLyrics() { void _toggleMornyeLyrics() {
setState(() => _currentPage = _currentPage == 1 ? 0 : 1); setState(() {
_currentPage = _currentPage == 1 ? 0 : 1;
_lyricsControlsHidden = false;
_lyricsScrollDistance = 0;
});
if (_currentPage == 1) { if (_currentPage == 1) {
_loadMetadataForItem( _loadMetadataForItem(
ref.read(currentMediaItemProvider).value, ref.read(currentMediaItemProvider).value,
@@ -783,6 +825,8 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
MusicPlayerController controller, MusicPlayerController controller,
ColorScheme colorScheme, { ColorScheme colorScheme, {
required bool squareArtwork, required bool squareArtwork,
required Widget motionArtwork,
double? artworkAspectRatio,
}) { }) {
final showLyrics = _landscapeLyrics || _currentPage == 1; final showLyrics = _landscapeLyrics || _currentPage == 1;
final showQueue = !_landscapeLyrics && _currentPage == 2; final showQueue = !_landscapeLyrics && _currentPage == 2;
@@ -790,217 +834,272 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
final motion = MediaQuery.disableAnimationsOf(context) final motion = MediaQuery.disableAnimationsOf(context)
? Duration.zero ? Duration.zero
: const Duration(milliseconds: 380); : const Duration(milliseconds: 380);
return LayoutBuilder( return NotificationListener<ScrollUpdateNotification>(
builder: (context, constraints) { onNotification: (notification) {
final screenSize = MediaQuery.sizeOf(context); if (!showLyrics ||
final landscape = screenSize.width > screenSize.height; _landscapeLyrics ||
final scale = MediaQuery.textScalerOf(context).scale(17) / 17; notification.dragDetails == null ||
Widget stage({bool artworkOnly = false}) => LayoutBuilder( notification.metrics.axis != Axis.vertical) {
builder: (context, stage) { return false;
final compact = compactStage && !artworkOnly; }
final artSize = (stage.maxWidth - 56).clamp( final delta = notification.scrollDelta ?? 0;
0.0, if (delta.sign != _lyricsScrollDistance.sign) _lyricsScrollDistance = 0;
(stage.maxHeight - 24).clamp(0.0, 360.0), _lyricsScrollDistance += delta;
); if (_lyricsScrollDistance.abs() >= 16) {
return Stack( final hidden = _lyricsScrollDistance > 0;
children: [ if (hidden != _lyricsControlsHidden) {
Positioned.fill( setState(() => _lyricsControlsHidden = hidden);
top: 76, }
child: IgnorePointer( _lyricsScrollDistance = 0;
ignoring: !compact, }
child: ExcludeSemantics( return false;
excluding: !compact, },
child: LayoutBuilder(
builder: (context, constraints) {
final screenSize = MediaQuery.sizeOf(context);
final landscape = screenSize.width > screenSize.height;
final scale = MediaQuery.textScalerOf(context).scale(17) / 17;
Widget stage({bool artworkOnly = false}) => LayoutBuilder(
builder: (context, stage) {
final compact = compactStage && !artworkOnly;
final artSize = (stage.maxWidth - 56).clamp(
0.0,
(stage.maxHeight - 24).clamp(0.0, 360.0),
);
final ratio = artworkAspectRatio ?? 1.0;
final artWidth = ratio < 1 ? artSize * ratio : artSize;
final artHeight = ratio > 1 ? artSize / ratio : artSize;
return Stack(
children: [
Positioned.fill(
top: 76,
child: IgnorePointer(
ignoring: !compact,
child: ExcludeSemantics(
excluding: !compact,
child: AnimatedSwitcher(
duration: motion,
switchInCurve: Curves.easeInOutCubic,
switchOutCurve: Curves.easeInOutCubic,
transitionBuilder: (child, animation) =>
FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween(
begin: const Offset(0, 0.035),
end: Offset.zero,
).animate(animation),
child: child,
),
),
layoutBuilder: (currentChild, previousChildren) =>
Stack(
fit: StackFit.expand,
children: [
for (final child in previousChildren)
IgnorePointer(
child: ExcludeSemantics(child: child),
),
?currentChild,
],
),
// Keep the outgoing panel until it fades out;
// closing the queue must never substitute lyrics.
child: compact
? KeyedSubtree(
key: ValueKey(_currentPage),
child: showQueue
? MornyePlayerQueue(
colorScheme: colorScheme,
onShuffleLibrary: () =>
_shuffleLibrary(controller),
)
: _lyricsSection(
colorScheme,
isActive: showLyrics,
),
)
: null,
),
),
),
),
Positioned(
top: artworkOnly ? (stage.maxHeight - artHeight) / 2 : 8,
left: artworkOnly ? (stage.maxWidth - artWidth) / 2 : 28,
width: artworkOnly ? artWidth : 48,
height: artworkOnly ? artHeight : 48,
child: HeroMode(
enabled: artworkOnly || compactStage,
child: AnimatedSwitcher( child: AnimatedSwitcher(
duration: motion, duration: motion,
switchInCurve: Curves.easeInOutCubic, switchInCurve: Curves.easeInOutCubic,
switchOutCurve: Curves.easeInOutCubic, switchOutCurve: Curves.easeInOutCubic,
layoutBuilder: (current, previous) => Stack(
fit: StackFit.expand,
children: [
for (final child in previous)
HeroMode(
enabled: false,
child: IgnorePointer(child: child),
),
?current,
],
),
transitionBuilder: (child, animation) => FadeTransition( transitionBuilder: (child, animation) => FadeTransition(
opacity: animation, opacity: animation,
child: SlideTransition( child: ScaleTransition(
position: Tween( scale: Tween(
begin: const Offset(0, 0.035), begin: 0.85,
end: Offset.zero, end: 1.0,
).animate(animation), ).animate(animation),
child: child, child: child,
), ),
), ),
layoutBuilder: (currentChild, previousChildren) => child: !artworkOnly && !compactStage
Stack( ? null
fit: StackFit.expand, : _artworkDragRegion(
children: [ context,
for (final child in previousChildren) Hero(
IgnorePointer( tag: kNowPlayingArtworkHeroTag,
child: ExcludeSemantics(child: child), child: ClipRRect(
), borderRadius: BorderRadius.circular(12),
?currentChild, child: artworkOnly
], ? motionArtwork
), : PlayerArtwork(
// Keep the outgoing panel until it fades out; artUri: mediaItem.artUri
// closing the queue must never substitute lyrics. ?.toString(),
child: compact colorScheme: colorScheme,
? KeyedSubtree( cacheWidth:
key: ValueKey(_currentPage), (360 *
child: showQueue MediaQuery.devicePixelRatioOf(
? MornyePlayerQueue( context,
colorScheme: colorScheme, ))
onShuffleLibrary: () => .round(),
_shuffleLibrary(controller), ),
)
: _lyricsSection(
colorScheme,
isActive: showLyrics,
),
)
: null,
),
),
),
),
Positioned(
top: artworkOnly ? (stage.maxHeight - artSize) / 2 : 8,
left: artworkOnly ? (stage.maxWidth - artSize) / 2 : 28,
width: artworkOnly ? artSize : 48,
height: artworkOnly ? artSize : 48,
child: HeroMode(
enabled: artworkOnly || compactStage,
child: AnimatedSwitcher(
duration: motion,
switchInCurve: Curves.easeInOutCubic,
switchOutCurve: Curves.easeInOutCubic,
layoutBuilder: (current, previous) => Stack(
fit: StackFit.expand,
children: [
for (final child in previous)
HeroMode(
enabled: false,
child: IgnorePointer(child: child),
),
?current,
],
),
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: Tween(
begin: 0.85,
end: 1.0,
).animate(animation),
child: child,
),
),
child: !artworkOnly && !compactStage
? null
: _artworkDragRegion(
context,
Hero(
tag: kNowPlayingArtworkHeroTag,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: PlayerArtwork(
artUri: mediaItem.artUri?.toString(),
colorScheme: colorScheme,
cacheWidth:
(360 *
MediaQuery.devicePixelRatioOf(
context,
))
.round(),
), ),
), ),
), ),
), ),
), ),
), ),
), Positioned(
Positioned( left: 88,
left: 88, right: 28,
right: 28, top: 4,
top: 4, child: IgnorePointer(
child: IgnorePointer( ignoring: !compact,
ignoring: !compact, child: ExcludeSemantics(
child: ExcludeSemantics( excluding: !compact,
excluding: !compact, child: AnimatedOpacity(
child: AnimatedOpacity( opacity: compact ? 1 : 0,
opacity: compact ? 1 : 0, duration: motion,
duration: motion, child: _trackHeader(
child: _trackHeader( mediaItem,
mediaItem, colorScheme,
colorScheme, compact: true,
compact: true, ),
),
),
),
),
],
);
},
);
final volumeGap =
8 +
(squareArtwork
? (constraints.maxHeight - 480).clamp(0.0, 48.0)
: 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(
mediaId: mediaItem.id,
duration: mediaItem.duration ?? Duration.zero,
controller: controller,
colorScheme: colorScheme,
qualityLabel: _qualityLabel(),
compact: landscape,
transportTopPadding: 16 + transportShift,
);
if (landscape) {
return MornyeLandscapePlayer(
artwork: stage(artworkOnly: true),
header: _trackHeader(mediaItem, colorScheme, compact: true),
lyrics: _lyricsSection(colorScheme, isActive: true),
queue: MornyePlayerQueue(
colorScheme: colorScheme,
onShuffleLibrary: () => _shuffleLibrary(controller),
),
controls: controls(),
volume: const MornyeVolumeControl(),
);
}
Widget content() => Column(
children: [
Expanded(child: stage()),
AnimatedSize(
duration: motion,
curve: Curves.easeInOutCubic,
child: compactStage
? const SizedBox(width: double.infinity)
: Padding(
padding: const EdgeInsets.fromLTRB(28, 12, 28, 8),
child: _trackHeader(mediaItem, colorScheme),
),
),
AnimatedSize(
duration: motion,
curve: Curves.easeInOutCubic,
alignment: Alignment.topCenter,
child: ClipRect(
child: Align(
heightFactor: showLyrics && _lyricsControlsHidden ? 0 : 1,
child: IgnorePointer(
ignoring: showLyrics && _lyricsControlsHidden,
child: ExcludeSemantics(
excluding: showLyrics && _lyricsControlsHidden,
child: AnimatedOpacity(
opacity: showLyrics && _lyricsControlsHidden ? 0 : 1,
duration: motion,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
controls(),
SizedBox(height: volumeGap - transportShift),
const MornyeVolumeControl(),
const SizedBox(height: 8),
],
),
), ),
), ),
), ),
), ),
), ),
], ),
],
);
// Controls keep their intrinsic height. Small portrait screens and
// large accessibility text can scroll instead of clipping the volume.
final minHeight =
(showLyrics && _lyricsControlsHidden ? 250.0 : 480.0) +
(scale - 1).clamp(0, 3) * 120;
if (constraints.maxHeight < minHeight) {
return SingleChildScrollView(
child: SizedBox(height: minHeight, child: content()),
); );
}, }
); return content();
},
final volumeGap = ),
8 +
(squareArtwork
? (constraints.maxHeight - 480).clamp(0.0, 48.0)
: 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(
mediaId: mediaItem.id,
duration: mediaItem.duration ?? Duration.zero,
controller: controller,
colorScheme: colorScheme,
qualityLabel: _qualityLabel(),
compact: landscape,
transportTopPadding: 16 + transportShift,
);
if (landscape) {
return MornyeLandscapePlayer(
artwork: stage(artworkOnly: true),
header: _trackHeader(mediaItem, colorScheme, compact: true),
lyrics: _lyricsSection(colorScheme, isActive: true),
queue: MornyePlayerQueue(
colorScheme: colorScheme,
onShuffleLibrary: () => _shuffleLibrary(controller),
),
controls: controls(),
volume: const MornyeVolumeControl(),
);
}
Widget content() => Column(
children: [
Expanded(child: stage()),
AnimatedSize(
duration: motion,
curve: Curves.easeInOutCubic,
child: compactStage
? const SizedBox(width: double.infinity)
: Padding(
padding: const EdgeInsets.fromLTRB(28, 12, 28, 8),
child: _trackHeader(mediaItem, colorScheme),
),
),
controls(),
SizedBox(height: volumeGap - transportShift),
const MornyeVolumeControl(),
const SizedBox(height: 8),
],
);
// Controls keep their intrinsic height. Small portrait screens and
// large accessibility text can scroll instead of clipping the volume.
final minHeight = 480.0 + (scale - 1).clamp(0, 3) * 120;
if (constraints.maxHeight < minHeight) {
return SingleChildScrollView(
child: SizedBox(height: minHeight, child: content()),
);
}
return content();
},
); );
} }
@@ -1991,6 +2090,9 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
bool _hasStarted = false; bool _hasStarted = false;
bool _userScrolling = false; bool _userScrolling = false;
static const double _estimatedLyricExtent = 64; static const double _estimatedLyricExtent = 64;
List<double>? _lineExtents;
Object? _lineLayoutKey;
double? _viewportHeight;
@override @override
void initState() { void initState() {
@@ -2018,6 +2120,8 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
void _resetLineKeys() { void _resetLineKeys() {
_hasStarted = false; _hasStarted = false;
_lineExtents = null;
_lineLayoutKey = null;
_lineKeys = List<GlobalKey>.generate( _lineKeys = List<GlobalKey>.generate(
widget.lyrics.lines.length, widget.lyrics.lines.length,
(index) => GlobalKey(debugLabel: 'lyric-line-$index'), (index) => GlobalKey(debugLabel: 'lyric-line-$index'),
@@ -2034,6 +2138,8 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
_playingSubscription = null; _playingSubscription = null;
_loadingSubscription = null; _loadingSubscription = null;
if (!widget.isActive) return; if (!widget.isActive) return;
_userScrollIdleTimer?.cancel();
_userScrolling = false;
final position = ref.read(playbackPositionProvider); final position = ref.read(playbackPositionProvider);
_playing = ref.read(playbackPlayingProvider); _playing = ref.read(playbackPlayingProvider);
@@ -2041,7 +2147,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
_active = _activeIndexAt(position); _active = _activeIndexAt(position);
_activeTransitionPosition = position; _activeTransitionPosition = position;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(_maybeAutoScroll(_active)); if (mounted) unawaited(_maybeAutoScroll(_active, immediate: true));
}); });
_scheduleNextLine(position); _scheduleNextLine(position);
_positionSubscription = ref.listenManual<Duration>( _positionSubscription = ref.listenManual<Duration>(
@@ -2133,8 +2239,64 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
super.dispose(); super.dispose();
} }
Future<void> _maybeAutoScroll(int index) async { void _measureMornyeLines(double width) {
final style = Theme.of(context).textTheme.headlineSmall?.copyWith(
height: 1.3,
fontSize: 28,
fontWeight: FontWeight.bold,
);
final scaler = MediaQuery.textScalerOf(context);
final direction = Directionality.of(context);
final locale = Localizations.maybeLocaleOf(context);
final key = (width, style, scaler, direction, locale);
if (_lineLayoutKey == key) return;
_lineLayoutKey = key;
final painter = TextPainter(
textDirection: direction,
textScaler: scaler,
locale: locale,
);
final extents = <double>[];
for (final line in widget.lyrics.lines) {
painter.text = TextSpan(
text: line.text.trim().isEmpty ? '\u00b7\u00b7\u00b7' : line.text,
style: style,
);
painter.layout(maxWidth: width);
extents.add(painter.height + 32);
}
_lineExtents = extents;
painter.dispose();
}
Future<void> _maybeAutoScroll(int index, {bool immediate = false}) async {
if (_userScrolling || index < 0 || !_scroll.hasClients) return; if (_userScrolling || index < 0 || !_scroll.hasClients) return;
final extents = _lineExtents;
if (context.isMornye && extents != null && index < extents.length) {
final position = _scroll.position;
final padding = syncedLyricsCenterPadding(
viewportDimension: position.viewportDimension,
estimatedLineExtent: _estimatedLyricExtent,
);
final target =
extents.take(index).fold(0.0, (sum, extent) => sum + extent) +
padding -
(position.viewportDimension - extents[index]) / 2;
final offset = target.clamp(
position.minScrollExtent,
position.maxScrollExtent,
);
if (immediate || MediaQuery.disableAnimationsOf(context)) {
_scroll.jumpTo(offset);
} else {
await _scroll.animateTo(
offset,
duration: const Duration(milliseconds: 380),
curve: Curves.easeOutCubic,
);
}
return;
}
if (index < _lineKeys.length) { if (index < _lineKeys.length) {
final lineContext = _lineKeys[index].currentContext; final lineContext = _lineKeys[index].currentContext;
if (lineContext != null) { if (lineContext != null) {
@@ -2197,19 +2359,37 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
_userScrolling = true; _userScrolling = true;
_userScrollIdleTimer?.cancel(); _userScrollIdleTimer?.cancel();
_userScrollIdleTimer = Timer(const Duration(seconds: 4), () { _userScrollIdleTimer = Timer(const Duration(seconds: 4), () {
if (mounted) _userScrolling = false; if (!mounted) return;
_userScrolling = false;
unawaited(_maybeAutoScroll(_active));
}); });
} }
return false; return false;
}, },
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
if (mornye) {
_measureMornyeLines(
(constraints.maxWidth - 48).clamp(0, double.infinity),
);
if (_viewportHeight != constraints.maxHeight) {
_viewportHeight = constraints.maxHeight;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
unawaited(_maybeAutoScroll(_active, immediate: true));
}
});
}
}
final centerPadding = syncedLyricsCenterPadding( final centerPadding = syncedLyricsCenterPadding(
viewportDimension: constraints.maxHeight, viewportDimension: constraints.maxHeight,
estimatedLineExtent: _estimatedLyricExtent, estimatedLineExtent: _estimatedLyricExtent,
); );
return ListView.builder( return ListView.builder(
controller: _scroll, controller: _scroll,
itemExtentBuilder: mornye
? (index, _) => _lineExtents![index]
: null,
padding: EdgeInsets.fromLTRB(24, centerPadding, 24, centerPadding), padding: EdgeInsets.fromLTRB(24, centerPadding, 24, centerPadding),
itemCount: lines.length, itemCount: lines.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
@@ -14,6 +14,8 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/providers/player_motion_artwork_provider.dart';
import 'package:spotiflac_android/services/motion_artwork_store.dart';
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/utils/string_utils.dart';
@@ -72,6 +74,9 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
final tempDirFuture = getTemporaryDirectory(); final tempDirFuture = getTemporaryDirectory();
final appSupportDirFuture = getApplicationSupportDirectory(); final appSupportDirFuture = getApplicationSupportDirectory();
final coverStatsFuture = CoverCacheManager.getStats(); final coverStatsFuture = CoverCacheManager.getStats();
final motionStatsFuture = MotionArtworkStore.defaultDirectory().then(
_scanDirectory,
);
final prefsFuture = SharedPreferences.getInstance(); final prefsFuture = SharedPreferences.getInstance();
final trackCacheEntriesFuture = _getTrackCacheSizeSafe(); final trackCacheEntriesFuture = _getTrackCacheSizeSafe();
@@ -120,6 +125,7 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
tempStats: tempStats, tempStats: tempStats,
tempIsSameAsAppCache: tempIsSameAsAppCache, tempIsSameAsAppCache: tempIsSameAsAppCache,
coverStats: coverStats, coverStats: coverStats,
motionStats: await motionStatsFuture,
libraryCoverStats: libraryCoverStats, libraryCoverStats: libraryCoverStats,
audioAnalysisStats: audioAnalysisStats, audioAnalysisStats: audioAnalysisStats,
exploreCacheBytes: exploreBytes, exploreCacheBytes: exploreBytes,
@@ -205,6 +211,11 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
await CoverCacheManager.clearCache(); await CoverCacheManager.clearCache();
} }
Future<void> _clearMotionArtwork() async {
await ref.read(motionArtworkStoreProvider).clear();
if (mounted) ref.invalidate(playerMotionArtworkProvider);
}
Future<void> _clearLibraryCoverCache() async { Future<void> _clearLibraryCoverCache() async {
final appSupportDir = await getApplicationSupportDirectory(); final appSupportDir = await getApplicationSupportDirectory();
final libraryCoverDir = Directory('${appSupportDir.path}/library_covers'); final libraryCoverDir = Directory('${appSupportDir.path}/library_covers');
@@ -624,6 +635,24 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
), ),
), ),
), ),
SettingsItem(
icon: Icons.video_library_outlined,
title: context.l10n.cacheMotionArtwork,
subtitle: _buildSubtitle(
context.l10n.cacheMotionArtworkDesc,
overview.motionStats.totalSizeBytes > 0
? formatBytes(overview.motionStats.totalSizeBytes)
: context.l10n.cacheNoData,
),
trailing: _buildClearTrailing(
'clear_motion_artwork',
() => _confirmAndRunAction(
actionKey: 'clear_motion_artwork',
targetLabel: context.l10n.cacheMotionArtwork,
action: _clearMotionArtwork,
),
),
),
SettingsItem( SettingsItem(
icon: Icons.explore_outlined, icon: Icons.explore_outlined,
title: context.l10n.cacheExploreFeed, title: context.l10n.cacheExploreFeed,
@@ -707,6 +736,7 @@ class _CacheOverview {
final _DirectoryStats? tempStats; final _DirectoryStats? tempStats;
final bool tempIsSameAsAppCache; final bool tempIsSameAsAppCache;
final CacheStats coverStats; final CacheStats coverStats;
final _DirectoryStats motionStats;
final _DirectoryStats libraryCoverStats; final _DirectoryStats libraryCoverStats;
final _DirectoryStats audioAnalysisStats; final _DirectoryStats audioAnalysisStats;
final int exploreCacheBytes; final int exploreCacheBytes;
@@ -720,6 +750,7 @@ class _CacheOverview {
this.tempStats, this.tempStats,
required this.tempIsSameAsAppCache, required this.tempIsSameAsAppCache,
required this.coverStats, required this.coverStats,
required this.motionStats,
required this.libraryCoverStats, required this.libraryCoverStats,
required this.audioAnalysisStats, required this.audioAnalysisStats,
required this.exploreCacheBytes, required this.exploreCacheBytes,
+196
View File
@@ -0,0 +1,196 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
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/utils/logger.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
typedef MotionArtworkAlbum = ({String album, String artist});
class MotionArtwork {
const MotionArtwork(this.source, {this.aspectRatio});
final String source;
final double? aspectRatio;
}
/// Durable, album-scoped artwork. Relative filenames survive iOS reinstalls
/// that relocate the app container; temporary cache cleanup does not remove it.
class MotionArtworkStore {
MotionArtworkStore({
Future<Directory> Function()? directory,
Future<double?> Function(String source, String output)? download,
}) : _directory = directory ?? defaultDirectory,
_download = download ?? _downloadVideo;
final Future<Directory> Function() _directory;
final Future<double?> Function(String source, String output) _download;
final _pending = <String, Future<MotionArtwork?>>{};
bool _clearing = false;
static final _log = AppLogger('MotionArtwork');
static Future<Directory> defaultDirectory() async => Directory(
'${(await getApplicationSupportDirectory()).path}/motion_artwork',
);
String _key(MotionArtworkAlbum album) {
String normalize(String value) =>
value.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' ');
return sha256
.convert(
utf8.encode(
jsonEncode([normalize(album.album), normalize(album.artist)]),
),
)
.toString();
}
Future<MotionArtwork?> find(MotionArtworkAlbum album) async {
try {
final root = await _directory();
final key = _key(album);
final file = File('${root.path}/$key.mp4');
if (!await file.exists() || await file.length() == 0) return null;
double? ratio;
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;
}
}
return MotionArtwork(file.uri.toString(), aspectRatio: ratio);
} catch (_) {
return null;
}
}
Future<MotionArtwork?> save(
MotionArtworkAlbum album, {
required Future<String?> Function() resolveSource,
}) {
if (_clearing ||
album.album.trim().isEmpty ||
album.artist.trim().isEmpty) {
return Future.value();
}
final key = _key(album);
return _pending.putIfAbsent(key, () async {
try {
return await _save(album, key, resolveSource);
} finally {
_pending.remove(key);
}
});
}
Future<void> clear() async {
_clearing = true;
try {
await Future.wait(_pending.values.toList());
final root = await _directory();
if (await root.exists()) await root.delete(recursive: true);
} finally {
_clearing = false;
}
}
Future<MotionArtwork?> _save(
MotionArtworkAlbum album,
String key,
Future<String?> Function() resolveSource,
) async {
File? temporary;
File? infoTemporary;
try {
final existing = await find(album);
if (existing != null) return existing;
final source = normalizeRemoteHttpUrl(await resolveSource());
if (source == null) return null;
final root = await _directory();
await root.create(recursive: true);
temporary = File('${root.path}/$key.partial.mp4');
final ratio = await _download(source, temporary.path);
if (ratio == null ||
!ratio.isFinite ||
ratio <= 0 ||
!await temporary.exists() ||
await temporary.length() == 0) {
return null;
}
infoTemporary = File('${root.path}/$key.partial.json');
await infoTemporary.writeAsString(
jsonEncode({'aspectRatio': ratio}),
flush: true,
);
final file = await temporary.rename('${root.path}/$key.mp4');
await infoTemporary.rename('${root.path}/$key.json');
return MotionArtwork(file.uri.toString(), aspectRatio: ratio);
} catch (error) {
_log.w('Could not save optional motion artwork: ${error.runtimeType}');
return null;
} finally {
for (final file in [temporary, infoTemporary]) {
try {
if (file != null && await file.exists()) await file.delete();
} catch (_) {}
}
}
}
static Future<double?> _downloadVideo(String source, String output) async {
final completed = Completer<bool>();
// 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,
],
(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),
onTimeout: () async {
await FFmpegKit.cancel(session.getSessionId());
// Wait for this session to release its file before removing partials.
await completed.future.timeout(
const Duration(seconds: 5),
onTimeout: () => false,
);
return false;
},
);
if (!success) return null;
final dimensions = await FFmpegService.probeImageDimensions(output);
return dimensions == null ? null : dimensions.width / dimensions.height;
}
}
+14 -18
View File
@@ -869,7 +869,8 @@ class HomeSearchSkeleton extends StatelessWidget {
], ],
), ),
), ),
const SkeletonBox(width: 20, height: 20, borderRadius: 10), if (!mornye)
const SkeletonBox(width: 20, height: 20, borderRadius: 10),
], ],
), ),
); );
@@ -893,24 +894,19 @@ class HomeSearchSkeleton extends StatelessWidget {
], ],
), ),
), ),
if (mornye) Container(
for (var index = 0; index < rows.length; index++) margin: const EdgeInsets.symmetric(horizontal: 16),
Container( decoration: decoration,
margin: EdgeInsets.fromLTRB( child: Column(
16, children: [
0, for (var index = 0; index < rows.length; index++) ...[
16, if (mornye && index > 0)
index == rows.length - 1 ? 0 : 8, const Divider(height: 1, indent: 80, endIndent: 12),
), rows[index],
decoration: decoration, ],
child: rows[index], ],
)
else
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: decoration,
child: Column(children: rows),
), ),
),
], ],
); );
} }
+8
View File
@@ -123,6 +123,8 @@ class MornyeGlassPanel extends ConsumerWidget {
super.key, super.key,
required this.child, required this.child,
this.radius = 32, this.radius = 32,
this.firstInGroup = true,
this.lastInGroup = true,
this.strongTint = false, this.strongTint = false,
this.tintOpacity, this.tintOpacity,
}); });
@@ -132,17 +134,23 @@ class MornyeGlassPanel extends ConsumerWidget {
super.key, super.key,
required this.child, required this.child,
this.radius = 32, this.radius = 32,
this.firstInGroup = true,
this.lastInGroup = true,
this.tintOpacity = 0.78, this.tintOpacity = 0.78,
}) : strongTint = false; }) : strongTint = false;
final Widget child; final Widget child;
final double radius; final double radius;
final bool firstInGroup;
final bool lastInGroup;
final bool strongTint; final bool strongTint;
final double? tintOpacity; final double? tintOpacity;
@override @override
Widget build(BuildContext context, WidgetRef ref) => MornyeGlass.navigation( Widget build(BuildContext context, WidgetRef ref) => MornyeGlass.navigation(
radius: radius, radius: radius,
firstInGroup: firstInGroup,
lastInGroup: lastInGroup,
strongTint: strongTint, strongTint: strongTint,
tintOpacity: tintOpacity, tintOpacity: tintOpacity,
blurEnabled: blurEnabled:
+2 -2
View File
@@ -83,7 +83,7 @@ class _MornyeLandscapePlayerState extends State<MornyeLandscapePlayer> {
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
onTap: _reveal, onTap: _reveal,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
child: Row( child: Row(
children: [ children: [
Expanded(child: widget.artwork), Expanded(child: widget.artwork),
@@ -91,7 +91,7 @@ class _MornyeLandscapePlayerState extends State<MornyeLandscapePlayer> {
child: Column( child: Column(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
child: widget.header, child: widget.header,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
+19 -7
View File
@@ -8,20 +8,30 @@ class MornyePlayerArtwork extends StatelessWidget {
super.key, super.key,
required this.mediaItem, required this.mediaItem,
this.videoUrl, this.videoUrl,
this.onAspectRatioChanged,
this.onError,
}); });
final MediaItem mediaItem; final MediaItem mediaItem;
final String? videoUrl; final String? videoUrl;
final ValueChanged<double>? onAspectRatioChanged;
final VoidCallback? onError;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final fallback = PlayerArtwork( final fallback = Align(
artUri: mediaItem.artUri?.toString(), alignment: Alignment.topCenter,
colorScheme: Theme.of(context).colorScheme, child: AspectRatio(
cacheWidth: aspectRatio: 1,
(MediaQuery.sizeOf(context).width * child: PlayerArtwork(
MediaQuery.devicePixelRatioOf(context)) artUri: mediaItem.artUri?.toString(),
.round(), colorScheme: Theme.of(context).colorScheme,
cacheWidth:
(MediaQuery.sizeOf(context).width *
MediaQuery.devicePixelRatioOf(context))
.round(),
),
),
); );
if (MediaQuery.disableAnimationsOf(context)) return fallback; if (MediaQuery.disableAnimationsOf(context)) return fallback;
return videoUrl == null return videoUrl == null
@@ -30,6 +40,8 @@ class MornyePlayerArtwork extends StatelessWidget {
key: ValueKey(videoUrl), key: ValueKey(videoUrl),
videoUrl: videoUrl!, videoUrl: videoUrl!,
fallback: fallback, fallback: fallback,
onAspectRatioChanged: onAspectRatioChanged,
onError: onError,
); );
} }
} }
+12 -2
View File
@@ -9,11 +9,13 @@ class MornyePlayerBackground extends StatelessWidget {
required this.artUri, required this.artUri,
this.artwork, this.artwork,
this.squareArtwork = true, this.squareArtwork = true,
this.artworkAspectRatio,
}); });
final Uri? artUri; final Uri? artUri;
final Widget? artwork; final Widget? artwork;
final bool squareArtwork; final bool squareArtwork;
final double? artworkAspectRatio;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -56,11 +58,19 @@ class MornyePlayerBackground extends StatelessWidget {
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
Positioned( AnimatedPositioned(
duration: motion,
curve: Curves.easeInOutCubic,
left: 0, left: 0,
right: 0, right: 0,
top: 0, top: 0,
height: squareArtwork height: artworkAspectRatio != null && artworkAspectRatio! > 0
? (MediaQuery.sizeOf(context).width / artworkAspectRatio!)
.clamp(
0.0,
MediaQuery.sizeOf(context).height * 0.75,
)
: squareArtwork
? MediaQuery.sizeOf(context).width ? MediaQuery.sizeOf(context).width
: MediaQuery.sizeOf(context).height * 0.66, : MediaQuery.sizeOf(context).height * 0.66,
child: ShaderMask( child: ShaderMask(
+25 -12
View File
@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/foundation.dart' show ValueListenable;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
@@ -10,6 +12,8 @@ class MotionHeaderBanner extends StatefulWidget {
final Widget fallback; final Widget fallback;
final BoxFit fit; final BoxFit fit;
final Alignment alignment; final Alignment alignment;
final ValueChanged<double>? onAspectRatioChanged;
final VoidCallback? onError;
const MotionHeaderBanner({ const MotionHeaderBanner({
super.key, super.key,
@@ -17,6 +21,8 @@ class MotionHeaderBanner extends StatefulWidget {
required this.fallback, required this.fallback,
this.fit = BoxFit.cover, this.fit = BoxFit.cover,
this.alignment = Alignment.topCenter, this.alignment = Alignment.topCenter,
this.onAspectRatioChanged,
this.onError,
}); });
@override @override
@@ -95,28 +101,35 @@ class _MotionHeaderBannerState extends State<MotionHeaderBanner>
return; return;
} }
final controller = VideoPlayerController.networkUrl( final uri = Uri.tryParse(url);
Uri.parse(url), final options = VideoPlayerOptions(mixWithOthers: true);
formatHint: Uri.parse(url).path.toLowerCase().endsWith('.m3u8') final controller = uri?.scheme == 'file' || url.startsWith('/')
? VideoFormat.hls ? VideoPlayerController.file(
: null, uri?.scheme == 'file' ? File.fromUri(uri!) : File(url),
videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true), videoPlayerOptions: options,
); )
: VideoPlayerController.networkUrl(
Uri.parse(url),
formatHint: uri?.path.toLowerCase().endsWith('.m3u8') == true
? VideoFormat.hls
: null,
videoPlayerOptions: options,
);
_controller = controller; _controller = controller;
try { try {
await controller.initialize(); await controller.initialize();
if (!mounted) { if (!mounted || !identical(controller, _controller)) return;
await controller.dispose();
return;
}
await controller.setVolume(0); await controller.setVolume(0);
await controller.setLooping(true); await controller.setLooping(true);
if (!mounted || !identical(controller, _controller)) return;
setState(() => _ready = true); setState(() => _ready = true);
widget.onAspectRatioChanged?.call(controller.value.aspectRatio);
_syncPlayback(); _syncPlayback();
} catch (e) { } catch (e) {
_log.w('Failed to play motion banner: $e'); _log.w('Failed to play motion banner: $e');
if (!mounted) return; if (!mounted || !identical(controller, _controller)) return;
setState(() => _failed = true); setState(() => _failed = true);
widget.onError?.call();
} }
} }
+20 -10
View File
@@ -7,7 +7,7 @@ import 'package:spotiflac_android/widgets/mornye_chrome.dart';
void main() { void main() {
for (final brightness in Brightness.values) { for (final brightness in Brightness.values) {
testWidgets( testWidgets(
'search glass cards scroll and filters remain usable in $brightness', 'search glass sections scroll and filters remain usable in $brightness',
(tester) async { (tester) async {
var selected = false; var selected = false;
await tester.pumpWidget( await tester.pumpWidget(
@@ -31,16 +31,26 @@ void main() {
Expanded( Expanded(
child: ListView.builder( child: ListView.builder(
itemCount: 12, itemCount: 12,
itemBuilder: (context, index) => Padding( itemBuilder: (context, index) =>
padding: const EdgeInsets.only(bottom: 8), MornyeGlassPanel.overlay(
child: MornyeGlassPanel.overlay( radius: 24,
radius: 24, firstInGroup: index == 0,
child: SizedBox( lastInGroup: index == 11,
height: 88, child: Column(
child: Text('Result $index'), children: [
SizedBox(
height: 88,
child: Text('Result $index'),
),
if (index < 11)
const Divider(
height: 1,
indent: 80,
endIndent: 12,
),
],
),
), ),
),
),
), ),
), ),
], ],
+196
View File
@@ -0,0 +1,196 @@
import 'dart:async';
import 'dart:io';
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/services/motion_artwork_store.dart';
void main() {
const album = (album: 'An Album', artist: 'An Artist');
late Directory root;
setUp(
() async => root = await Directory.systemTemp.createTemp('motion-test-'),
);
tearDown(() async => root.delete(recursive: true));
test(
'saved video survives restart and app directory relocation offline',
() async {
var downloads = 0;
var location = Directory('${root.path}/old-container');
final store = MotionArtworkStore(
directory: () async => location,
download: (_, output) async {
downloads++;
await File(output).writeAsBytes([1, 2, 3, 4]);
return 0.75;
},
);
final saved = await store.save(
album,
resolveSource: () async => 'https://example.com/cover.m3u8',
);
expect(saved?.aspectRatio, 0.75);
expect(await File.fromUri(Uri.parse(saved!.source)).readAsBytes(), [
1,
2,
3,
4,
]);
location = await location.rename('${root.path}/new-container');
final restarted = MotionArtworkStore(
directory: () async => location,
download: (_, _) async => throw StateError('offline'),
);
final cached = await restarted.save((
album: ' AN ALBUM ',
artist: 'AN ARTIST',
), resolveSource: () async => throw StateError('offline'));
expect(cached?.source, contains('new-container'));
expect(cached?.aspectRatio, 0.75);
expect(downloads, 1);
final container = ProviderContainer(
overrides: [motionArtworkStoreProvider.overrideWithValue(restarted)],
);
addTearDown(container.dispose);
final playback = await container.read(
playerMotionArtworkProvider(album).future,
);
expect(playback?.source, cached?.source);
expect(container.exists(extensionProvider), isFalse);
},
);
test('concurrent tracks in one album download one complete video', () async {
final gate = Completer<void>();
var downloads = 0;
var lookups = 0;
final store = MotionArtworkStore(
directory: () async => root,
download: (_, output) async {
downloads++;
await gate.future;
await File(output).writeAsBytes([1]);
return 0.75;
},
);
Future<String?> resolve() async {
lookups++;
return 'https://example.com/video.mp4';
}
final first = store.save(album, resolveSource: resolve);
final second = store.save(album, resolveSource: resolve);
gate.complete();
final saved = await Future.wait([first, second]);
expect(saved.first?.source, saved.last?.source);
expect(downloads, 1);
expect(lookups, 1);
expect(
root.listSync().whereType<File>().where((f) => f.path.endsWith('.mp4')),
hasLength(1),
);
});
test(
'failed optional transfers leave no partial file and can retry',
() async {
var fail = true;
final store = MotionArtworkStore(
directory: () async => root,
download: (_, output) async {
await File(output).writeAsBytes([1]);
if (fail) throw const SocketException('offline');
return 1.0;
},
);
Future<String?> resolve() async => 'https://example.com/video.mp4';
expect(await store.save(album, resolveSource: resolve), isNull);
expect(root.listSync(), isEmpty);
expect(await store.find(album), isNull);
fail = false;
expect(await store.save(album, resolveSource: resolve), isNotNull);
},
);
test(
'only remote artwork is downloaded and unknown albums keep static art',
() async {
final store = MotionArtworkStore(
directory: () async => root,
download: (_, _) async => throw StateError('must not download'),
);
expect(
await store.save(
album,
resolveSource: () async => 'file:///private/video.mp4',
),
isNull,
);
expect(
await store.save((
album: '',
artist: '',
), resolveSource: () async => throw StateError('no album')),
isNull,
);
expect(root.listSync(), isEmpty);
},
);
test(
'clearing saved motion waits for writes and keeps the music file',
() async {
final music = File('${root.path}/song.flac');
await music.writeAsBytes([9, 8, 7]);
final gate = Completer<void>();
final started = Completer<void>();
final store = MotionArtworkStore(
directory: () async => Directory('${root.path}/motion'),
download: (_, output) async {
started.complete();
await gate.future;
await File(output).writeAsBytes([1]);
return 0.75;
},
);
final save = store.save(
album,
resolveSource: () async => 'https://example.com/cover.mp4',
);
await started.future;
final clear = store.clear();
expect(
await store.save(
album,
resolveSource: () async => throw StateError('clearing'),
),
isNull,
);
gate.complete();
await save;
await clear;
expect(await store.find(album), isNull);
expect(await music.readAsBytes(), [9, 8, 7]);
},
);
test(
'download queue track serialization retains extension motion artwork',
() {
final track = Track.fromBackendMap({
'id': 'track',
'name': 'Track',
'artists': 'Artist',
'album_name': 'Album',
'header_video': 'https://example.com/cover.m3u8',
}, source: 'example-provider');
final restored = Track.fromJson(track.copyWith(name: 'Updated').toJson());
expect(restored.headerVideoUrl, 'https://example.com/cover.m3u8');
expect(restored.source, 'example-provider');
},
);
}
+36 -1
View File
@@ -8,12 +8,16 @@ class _VideoPlatform extends VideoPlayerPlatform {
int playCalls = 0; int playCalls = 0;
bool looping = false; bool looping = false;
double volume = 1; double volume = 1;
DataSource? source;
@override @override
Future<void> init() async {} Future<void> init() async {}
@override @override
Future<int?> createWithOptions(VideoCreationOptions options) async => 1; Future<int?> createWithOptions(VideoCreationOptions options) async {
source = options.dataSource;
return 1;
}
@override @override
Stream<VideoEvent> videoEventsFor(int playerId) => Stream.value( Stream<VideoEvent> videoEventsFor(int playerId) => Stream.value(
@@ -58,6 +62,37 @@ class _VideoPlatform extends VideoPlayerPlatform {
} }
void main() { void main() {
testWidgets(
'offline cover uses a silent looping file and reports its ratio',
(tester) async {
final previousPlatform = VideoPlayerPlatform.instance;
final platform = _VideoPlatform();
VideoPlayerPlatform.instance = platform;
addTearDown(() => VideoPlayerPlatform.instance = previousPlatform);
double? ratio;
await tester.pumpWidget(
MaterialApp(
home: SizedBox(
width: 320,
height: 180,
child: MotionHeaderBanner(
videoUrl: 'file:///app/motion_artwork/cover.mp4',
fallback: const ColoredBox(color: Colors.black),
onAspectRatioChanged: (value) => ratio = value,
),
),
),
);
await tester.pump();
expect(platform.source?.sourceType, DataSourceType.file);
expect(platform.looping, isTrue);
expect(platform.volume, 0);
expect(platform.playing, isTrue);
expect(ratio, closeTo(320 / 180, 0.001));
await tester.pumpWidget(const SizedBox());
},
);
testWidgets('collapsed headers pause video and visible headers resume', ( testWidgets('collapsed headers pause video and visible headers resume', (
tester, tester,
) async { ) async {
@@ -137,6 +137,105 @@ void main() {
); );
} }
testWidgets('opening Mornye lyrics centers the current wrapped line', (
tester,
) async {
final lyrics = List.generate(100, (index) {
final seconds = index * 2;
final time =
'${(seconds ~/ 60).toString().padLeft(2, '0')}:'
'${(seconds % 60).toString().padLeft(2, '0')}.00';
return '[$time]Line $index with enough words to wrap across several rows';
}).join('\n');
metadataOverrides = {'lyrics': lyrics};
await pumpNowPlaying(
tester,
theme: MornyeTheme.build(Brightness.dark),
size: const Size(393, 780),
playback: PlaybackState(updatePosition: const Duration(seconds: 140)),
);
mediaItems.add(item('many'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(CupertinoIcons.quote_bubble));
await tester.pumpAndSettle();
final current = find.text(
'Line 70 with enough words to wrap across several rows',
);
expect(current, findsOneWidget);
final list = find.ancestor(of: current, matching: find.byType(ListView));
expect(tester.getCenter(current).dy, closeTo(tester.getCenter(list).dy, 2));
await tester.drag(list, const Offset(0, 200));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(CupertinoIcons.quote_bubble));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(CupertinoIcons.quote_bubble));
await tester.pumpAndSettle();
expect(tester.getCenter(current).dy, closeTo(tester.getCenter(list).dy, 2));
expect(tester.takeException(), isNull);
});
testWidgets('Mornye landscape has no top handle or reserved toolbar height', (
tester,
) async {
await pumpNowPlaying(
tester,
theme: MornyeTheme.build(
Brightness.dark,
).copyWith(platform: TargetPlatform.android),
size: const Size(900, 420),
);
mediaItems.add(item('first'));
await tester.pumpAndSettle();
final bar = tester.widget<AppBar>(find.byType(AppBar));
expect(bar.toolbarHeight, 0);
expect(bar.title, isNull);
});
testWidgets('manual lyric scrolling hides controls down and restores them up', (
tester,
) async {
metadataOverrides = {
'lyrics': List.generate(
20,
(index) =>
'[00:${(index * 3).toString().padLeft(2, '0')}.00]Lyric $index with several words on this line',
).join('\n'),
};
await pumpNowPlaying(
tester,
theme: MornyeTheme.build(Brightness.dark),
size: const Size(393, 780),
playback: PlaybackState(updatePosition: const Duration(seconds: 30)),
);
mediaItems.add(item('many'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(CupertinoIcons.quote_bubble));
await tester.pumpAndSettle();
final list = find.byType(ListView);
final initialHeight = tester.getSize(list).height;
final headerTop = tester.getTopLeft(find.text('Second')).dy;
final transport = find.byWidgetPredicate(
(widget) =>
widget is MornyePlaybackButton &&
const [
CupertinoIcons.play_fill,
CupertinoIcons.backward_fill,
CupertinoIcons.forward_fill,
].contains(widget.icon),
);
expect(transport.hitTestable(), findsNWidgets(3));
await tester.drag(list, const Offset(0, -140));
await tester.pumpAndSettle();
expect(tester.getSize(list).height, greaterThan(initialHeight + 100));
expect(tester.getTopLeft(find.text('Second')).dy, closeTo(headerTop, 1));
expect(transport.hitTestable(), findsNothing);
await tester.drag(list, const Offset(0, 140));
await tester.pumpAndSettle();
expect(tester.getSize(list).height, closeTo(initialHeight, 1));
expect(transport.hitTestable(), findsNWidgets(3));
expect(tester.takeException(), isNull);
});
testWidgets('Mornye player background does not reveal the page below', ( testWidgets('Mornye player background does not reveal the page below', (
tester, tester,
) async { ) async {