diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 349b9083..2c2ff316 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -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": { "description": "System volume control label in the Mornye player" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 16f4e6c3..62675f21 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -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": { "description": "Label kontrol volume sistem di pemutar Mornye" diff --git a/lib/models/track.dart b/lib/models/track.dart index 3fbede99..ef983bc6 100644 --- a/lib/models/track.dart +++ b/lib/models/track.dart @@ -14,6 +14,7 @@ class Track { final String? artistId; final String? albumId; final String? coverUrl; + final String? headerVideoUrl; final String? isrc; final String? previewUrl; final int duration; @@ -46,6 +47,7 @@ class Track { this.artistId, this.albumId, this.coverUrl, + this.headerVideoUrl, this.isrc, this.previewUrl, required this.duration, @@ -128,6 +130,7 @@ class Track { coverUrl: normalizeCoverReference( (data['cover_url'] ?? data['images'])?.toString(), ), + headerVideoUrl: normalizeRemoteHttpUrl(data['header_video']?.toString()), isrc: data['isrc']?.toString(), duration: (durationMs / 1000).round(), trackNumber: data['track_number'] as int?, @@ -162,6 +165,7 @@ class Track { String? artistId, String? albumId, String? coverUrl, + String? headerVideoUrl, String? isrc, String? previewUrl, int? duration, @@ -194,6 +198,7 @@ class Track { artistId: artistId ?? this.artistId, albumId: albumId ?? this.albumId, coverUrl: coverUrl ?? this.coverUrl, + headerVideoUrl: headerVideoUrl ?? this.headerVideoUrl, isrc: isrc ?? this.isrc, previewUrl: previewUrl ?? this.previewUrl, duration: duration ?? this.duration, diff --git a/lib/models/track.g.dart b/lib/models/track.g.dart index 2bb8d0f5..a9777038 100644 --- a/lib/models/track.g.dart +++ b/lib/models/track.g.dart @@ -15,6 +15,7 @@ Track _$TrackFromJson(Map json) => Track( artistId: json['artistId'] as String?, albumId: json['albumId'] as String?, coverUrl: json['coverUrl'] as String?, + headerVideoUrl: json['headerVideoUrl'] as String?, isrc: json['isrc'] as String?, previewUrl: json['previewUrl'] as String?, duration: (json['duration'] as num).toInt(), @@ -52,6 +53,7 @@ Map _$TrackToJson(Track instance) => { 'artistId': instance.artistId, 'albumId': instance.albumId, 'coverUrl': instance.coverUrl, + 'headerVideoUrl': instance.headerVideoUrl, 'isrc': instance.isrc, 'previewUrl': instance.previewUrl, 'duration': instance.duration, diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 9630382f..176f8e5e 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -17,6 +17,7 @@ import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/services/app_navigation_service.dart'; import 'package:spotiflac_android/providers/settings_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_queue_state.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/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 c1919700..e221ba9f 100644 --- a/lib/providers/download_queue_provider_finalization.dart +++ b/lib/providers/download_queue_provider_finalization.dart @@ -83,6 +83,100 @@ Future _normalizeDecryptedIsoBmffAudioPath( } extension _DownloadQueueFinalization on DownloadQueueNotifier { + Future _saveDownloadedMotionArtwork( + Ref ref, + DownloadItem item, + Track track, + Map 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 = + { + 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({ required String itemId, required String filePath, diff --git a/lib/providers/download_queue_provider_native_worker.dart b/lib/providers/download_queue_provider_native_worker.dart index ade44e91..5fb53398 100644 --- a/lib/providers/download_queue_provider_native_worker.dart +++ b/lib/providers/download_queue_provider_native_worker.dart @@ -1245,6 +1245,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { if (result['native_finalized'] == true) { final nativeFinalizedFilePath = filePath; + await _saveDownloadedMotionArtwork(ref, item, item.track, result); await persistBeforePublishingDownloadCompletion( persist: () async { if (!settings.saveDownloadHistory) return; @@ -1532,6 +1533,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { lowerFilePath.endsWith('.ogg'); final completedFilePath = filePath; + await _saveDownloadedMotionArtwork(ref, item, trackToDownload, result); final lyricsAvailability = await _resolveFinalLyricsAvailability( filePath: completedFilePath, externalLrcWritten: externalLrcWritten, diff --git a/lib/providers/download_queue_provider_single_item.dart b/lib/providers/download_queue_provider_single_item.dart index bcc6ec54..2a26420f 100644 --- a/lib/providers/download_queue_provider_single_item.dart +++ b/lib/providers/download_queue_provider_single_item.dart @@ -1717,6 +1717,12 @@ class _DownloadRun { externalLrcWritten: externalLrcWritten, ); + await n._saveDownloadedMotionArtwork( + n.ref, + item, + trackToDownload, + result, + ); await persistBeforePublishingDownloadCompletion( persist: () async { if (!settings.saveDownloadHistory) return; diff --git a/lib/providers/player_motion_artwork_provider.dart b/lib/providers/player_motion_artwork_provider.dart index 5e6338e9..0fc99ea9 100644 --- a/lib/providers/player_motion_artwork_provider.dart +++ b/lib/providers/player_motion_artwork_provider.dart @@ -4,11 +4,14 @@ 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, @@ -17,10 +20,13 @@ final _motionCache = TtlCache>( /// Resolve the same album motion artwork used by collection headers. Cache by /// album so advancing through its tracks does not repeat metadata requests. final playerMotionArtworkProvider = FutureProvider.autoDispose - .family((ref, album) { + .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), @@ -37,7 +43,10 @@ final playerMotionArtworkProvider = FutureProvider.autoDispose if (providers.isEmpty) return null; final key = jsonEncode([album.album, album.artist, providers]); 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( album: album, providerIds: providers, @@ -53,7 +62,8 @@ final playerMotionArtworkProvider = FutureProvider.autoDispose ).timeout(const Duration(seconds: 6)), ); _motionCache.set(key, request); - return request; + final source = await request; + return source == null ? null : MotionArtwork(source); }); Future findPlayerMotionArtwork({ diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index d875dde5..241baefb 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -229,13 +229,16 @@ class TrackNotifier extends Notifier { result['tracks'] != null) { final trackList = result['tracks'] as List; final collectionName = result['name'] as String?; + final headerVideo = type == 'album' + ? normalizeRemoteHttpUrl(result['header_video']?.toString()) + : null; final tracks = trackList .map( (t) => Track.fromBackendMap( t as Map, source: extensionId, playlistName: type == 'playlist' ? collectionName : null, - ), + ).copyWith(headerVideoUrl: headerVideo), ) .toList(); state = TrackState( diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 98145a9f..586e1eba 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -212,7 +212,7 @@ class _AlbumScreenState extends ConsumerState t as Map, albumTypeFallback: albumType, totalTracksFallback: totalTracks, - ), + ).copyWith(headerVideoUrl: normalizeRemoteHttpUrl(headerVideo)), ) .toList(); diff --git a/lib/screens/home_tab_search_results.dart b/lib/screens/home_tab_search_results.dart index c0a95899..2da84cd1 100644 --- a/lib/screens/home_tab_search_results.dart +++ b/lib/screens/home_tab_search_results.dart @@ -497,14 +497,17 @@ extension _HomeTabSearchResultsUI on _HomeTabState { height: 32, child: TextButton.icon( onPressed: () => _showSortOptions(colorScheme), - icon: Icon( - mornye ? mornyeIconFor(Icons.swap_vert) : Icons.swap_vert, - size: 18, - color: - _searchSortOption != HomeSearchSortOption.defaultOrder - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - ), + icon: mornye + ? null + : Icon( + Icons.swap_vert, + size: 18, + color: + _searchSortOption != + HomeSearchSortOption.defaultOrder + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), label: Text( _searchSortOption != HomeSearchSortOption.defaultOrder ? _sortOptionLabel(_searchSortOption) @@ -535,10 +538,12 @@ extension _HomeTabSearchResultsUI on _HomeTabState { final isLast = index == itemCount - 1; if (mornye) { return Padding( - padding: EdgeInsets.fromLTRB(16, 0, 16, isLast ? 0 : 8), + padding: const EdgeInsets.symmetric(horizontal: 16), child: MornyeGlassPanel.overlay( radius: 24, - child: itemBuilder(index, false), + firstInGroup: isFirst, + lastInGroup: isLast, + child: itemBuilder(index, !isLast), ), ); } diff --git a/lib/screens/home_tab_widgets.dart b/lib/screens/home_tab_widgets.dart index f0bb1d5e..2fa4d8d6 100644 --- a/lib/screens/home_tab_widgets.dart +++ b/lib/screens/home_tab_widgets.dart @@ -532,13 +532,12 @@ class _CollectionItemWidget extends StatelessWidget { ], ), ), - Icon( - context.isMornye - ? mornyeIconFor(Icons.chevron_right) - : Icons.chevron_right, - color: colorScheme.onSurfaceVariant, - size: context.isMornye ? 18 : 24, - ), + if (!context.isMornye) + Icon( + Icons.chevron_right, + color: colorScheme.onSurfaceVariant, + size: 24, + ), ], ), ), diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 916346e7..39f60159 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -218,6 +218,11 @@ class _NowPlayingScreenState extends ConsumerState { bool _bottomDragForwarding = false; double _bottomDragTotal = 0; bool _queueSheetShowing = false; + String? _measuredMotionSource; + double? _motionAspectRatio; + String? _failedMotionSource; + bool _lyricsControlsHidden = false; + double _lyricsScrollDistance = 0; @override void initState() { @@ -407,7 +412,7 @@ class _NowPlayingScreenState extends ConsumerState { } final source = mediaItem.extras?['source']?.toString() ?? ''; - final motionArtwork = mornye && !MediaQuery.disableAnimationsOf(context) + final resolvedMotion = mornye && !MediaQuery.disableAnimationsOf(context) ? ref .watch( playerMotionArtworkProvider(( @@ -417,6 +422,36 @@ class _NowPlayingScreenState extends ConsumerState { ) .value : 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, // delayed content fade would make dismissal appear to pause partway down. @@ -437,21 +472,27 @@ class _NowPlayingScreenState extends ConsumerState { foregroundColor: colorScheme.onSurface, surfaceTintColor: Colors.transparent, systemOverlayStyle: mornye ? SystemUiOverlayStyle.light : null, - toolbarHeight: mornye ? 36 : kToolbarHeight, + toolbarHeight: mornye + ? (MediaQuery.orientationOf(context) == Orientation.landscape + ? 0 + : 36) + : kToolbarHeight, automaticallyImplyLeading: false, title: mornye - ? IconButton( - tooltip: context.l10n.nowPlayingMinimize, - onPressed: () => Navigator.of(context).maybePop(), - icon: Container( - width: 36, - height: 5, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(3), - ), - ), - ) + ? MediaQuery.orientationOf(context) == Orientation.landscape + ? null + : IconButton( + tooltip: context.l10n.nowPlayingMinimize, + onPressed: () => Navigator.of(context).maybePop(), + icon: Container( + width: 36, + height: 5, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(3), + ), + ), + ) : Text(context.l10n.nowPlayingTitle), centerTitle: true, leading: mornye @@ -495,7 +536,9 @@ class _NowPlayingScreenState extends ConsumerState { mediaItem, controller, colorScheme, - squareArtwork: motionArtwork == null, + squareArtwork: squareArtwork, + motionArtwork: artwork(), + artworkAspectRatio: motionRatio, ) : PageView( controller: _pageController, @@ -592,15 +635,10 @@ class _NowPlayingScreenState extends ConsumerState { RepaintBoundary( child: MornyePlayerBackground( artUri: mediaItem.artUri, - squareArtwork: motionArtwork == null, + squareArtwork: squareArtwork, + artworkAspectRatio: motionRatio, artwork: !_landscapeLyrics && _currentPage == 0 - ? Hero( - tag: kNowPlayingArtworkHeroTag, - child: MornyePlayerArtwork( - mediaItem: mediaItem, - videoUrl: motionArtwork, - ), - ) + ? Hero(tag: kNowPlayingArtworkHeroTag, child: artwork()) : null, ), ), @@ -769,7 +807,11 @@ class _NowPlayingScreenState extends ConsumerState { } void _toggleMornyeLyrics() { - setState(() => _currentPage = _currentPage == 1 ? 0 : 1); + setState(() { + _currentPage = _currentPage == 1 ? 0 : 1; + _lyricsControlsHidden = false; + _lyricsScrollDistance = 0; + }); if (_currentPage == 1) { _loadMetadataForItem( ref.read(currentMediaItemProvider).value, @@ -783,6 +825,8 @@ class _NowPlayingScreenState extends ConsumerState { MusicPlayerController controller, ColorScheme colorScheme, { required bool squareArtwork, + required Widget motionArtwork, + double? artworkAspectRatio, }) { final showLyrics = _landscapeLyrics || _currentPage == 1; final showQueue = !_landscapeLyrics && _currentPage == 2; @@ -790,217 +834,272 @@ class _NowPlayingScreenState extends ConsumerState { final motion = MediaQuery.disableAnimationsOf(context) ? Duration.zero : const Duration(milliseconds: 380); - return 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), - ); - return Stack( - children: [ - Positioned.fill( - top: 76, - child: IgnorePointer( - ignoring: !compact, - child: ExcludeSemantics( - excluding: !compact, + return NotificationListener( + onNotification: (notification) { + if (!showLyrics || + _landscapeLyrics || + notification.dragDetails == null || + notification.metrics.axis != Axis.vertical) { + return false; + } + final delta = notification.scrollDelta ?? 0; + if (delta.sign != _lyricsScrollDistance.sign) _lyricsScrollDistance = 0; + _lyricsScrollDistance += delta; + if (_lyricsScrollDistance.abs() >= 16) { + final hidden = _lyricsScrollDistance > 0; + if (hidden != _lyricsControlsHidden) { + setState(() => _lyricsControlsHidden = hidden); + } + _lyricsScrollDistance = 0; + } + return false; + }, + 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( 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: SlideTransition( - position: Tween( - begin: const Offset(0, 0.035), - end: Offset.zero, + child: ScaleTransition( + scale: Tween( + begin: 0.85, + end: 1.0, ).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 - 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(), + child: !artworkOnly && !compactStage + ? null + : _artworkDragRegion( + context, + Hero( + tag: kNowPlayingArtworkHeroTag, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: artworkOnly + ? motionArtwork + : PlayerArtwork( + artUri: mediaItem.artUri + ?.toString(), + colorScheme: colorScheme, + cacheWidth: + (360 * + MediaQuery.devicePixelRatioOf( + context, + )) + .round(), + ), ), ), ), - ), + ), ), ), - ), - Positioned( - left: 88, - right: 28, - top: 4, - child: IgnorePointer( - ignoring: !compact, - child: ExcludeSemantics( - excluding: !compact, - child: AnimatedOpacity( - opacity: compact ? 1 : 0, - duration: motion, - child: _trackHeader( - mediaItem, - colorScheme, - compact: true, + Positioned( + left: 88, + right: 28, + top: 4, + child: IgnorePointer( + ignoring: !compact, + child: ExcludeSemantics( + excluding: !compact, + child: AnimatedOpacity( + opacity: compact ? 1 : 0, + duration: motion, + child: _trackHeader( + mediaItem, + colorScheme, + 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()), ); - }, - ); - - 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(); - }, + } + return content(); + }, + ), ); } @@ -1991,6 +2090,9 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { bool _hasStarted = false; bool _userScrolling = false; static const double _estimatedLyricExtent = 64; + List? _lineExtents; + Object? _lineLayoutKey; + double? _viewportHeight; @override void initState() { @@ -2018,6 +2120,8 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { void _resetLineKeys() { _hasStarted = false; + _lineExtents = null; + _lineLayoutKey = null; _lineKeys = List.generate( widget.lyrics.lines.length, (index) => GlobalKey(debugLabel: 'lyric-line-$index'), @@ -2034,6 +2138,8 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { _playingSubscription = null; _loadingSubscription = null; if (!widget.isActive) return; + _userScrollIdleTimer?.cancel(); + _userScrolling = false; final position = ref.read(playbackPositionProvider); _playing = ref.read(playbackPlayingProvider); @@ -2041,7 +2147,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { _active = _activeIndexAt(position); _activeTransitionPosition = position; WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) unawaited(_maybeAutoScroll(_active)); + if (mounted) unawaited(_maybeAutoScroll(_active, immediate: true)); }); _scheduleNextLine(position); _positionSubscription = ref.listenManual( @@ -2133,8 +2239,64 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { super.dispose(); } - Future _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 = []; + 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 _maybeAutoScroll(int index, {bool immediate = false}) async { 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) { final lineContext = _lineKeys[index].currentContext; if (lineContext != null) { @@ -2197,19 +2359,37 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { _userScrolling = true; _userScrollIdleTimer?.cancel(); _userScrollIdleTimer = Timer(const Duration(seconds: 4), () { - if (mounted) _userScrolling = false; + if (!mounted) return; + _userScrolling = false; + unawaited(_maybeAutoScroll(_active)); }); } return false; }, child: LayoutBuilder( 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( viewportDimension: constraints.maxHeight, estimatedLineExtent: _estimatedLyricExtent, ); return ListView.builder( controller: _scroll, + itemExtentBuilder: mornye + ? (index, _) => _lineExtents![index] + : null, padding: EdgeInsets.fromLTRB(24, centerPadding, 24, centerPadding), itemCount: lines.length, itemBuilder: (context, index) { diff --git a/lib/screens/settings/cache_management_page.dart b/lib/screens/settings/cache_management_page.dart index 8b5ce546..0cccc949 100644 --- a/lib/screens/settings/cache_management_page.dart +++ b/lib/screens/settings/cache_management_page.dart @@ -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/settings_provider.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/platform_bridge.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; @@ -72,6 +74,9 @@ class _CacheManagementPageState extends ConsumerState { final tempDirFuture = getTemporaryDirectory(); final appSupportDirFuture = getApplicationSupportDirectory(); final coverStatsFuture = CoverCacheManager.getStats(); + final motionStatsFuture = MotionArtworkStore.defaultDirectory().then( + _scanDirectory, + ); final prefsFuture = SharedPreferences.getInstance(); final trackCacheEntriesFuture = _getTrackCacheSizeSafe(); @@ -120,6 +125,7 @@ class _CacheManagementPageState extends ConsumerState { tempStats: tempStats, tempIsSameAsAppCache: tempIsSameAsAppCache, coverStats: coverStats, + motionStats: await motionStatsFuture, libraryCoverStats: libraryCoverStats, audioAnalysisStats: audioAnalysisStats, exploreCacheBytes: exploreBytes, @@ -205,6 +211,11 @@ class _CacheManagementPageState extends ConsumerState { await CoverCacheManager.clearCache(); } + Future _clearMotionArtwork() async { + await ref.read(motionArtworkStoreProvider).clear(); + if (mounted) ref.invalidate(playerMotionArtworkProvider); + } + Future _clearLibraryCoverCache() async { final appSupportDir = await getApplicationSupportDirectory(); final libraryCoverDir = Directory('${appSupportDir.path}/library_covers'); @@ -624,6 +635,24 @@ class _CacheManagementPageState extends ConsumerState { ), ), ), + 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( icon: Icons.explore_outlined, title: context.l10n.cacheExploreFeed, @@ -707,6 +736,7 @@ class _CacheOverview { final _DirectoryStats? tempStats; final bool tempIsSameAsAppCache; final CacheStats coverStats; + final _DirectoryStats motionStats; final _DirectoryStats libraryCoverStats; final _DirectoryStats audioAnalysisStats; final int exploreCacheBytes; @@ -720,6 +750,7 @@ class _CacheOverview { this.tempStats, required this.tempIsSameAsAppCache, required this.coverStats, + required this.motionStats, required this.libraryCoverStats, required this.audioAnalysisStats, required this.exploreCacheBytes, diff --git a/lib/services/motion_artwork_store.dart b/lib/services/motion_artwork_store.dart new file mode 100644 index 00000000..869ea031 --- /dev/null +++ b/lib/services/motion_artwork_store.dart @@ -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 Function()? directory, + Future Function(String source, String output)? download, + }) : _directory = directory ?? defaultDirectory, + _download = download ?? _downloadVideo; + + final Future Function() _directory; + final Future Function(String source, String output) _download; + final _pending = >{}; + bool _clearing = false; + static final _log = AppLogger('MotionArtwork'); + + static Future 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 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 save( + MotionArtworkAlbum album, { + required Future 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 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 _save( + MotionArtworkAlbum album, + String key, + Future 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 _downloadVideo(String source, String output) async { + final completed = Completer(); + // 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; + } +} diff --git a/lib/widgets/animation_utils.dart b/lib/widgets/animation_utils.dart index 9ae544b3..8491b138 100644 --- a/lib/widgets/animation_utils.dart +++ b/lib/widgets/animation_utils.dart @@ -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) - for (var index = 0; index < rows.length; index++) - Container( - margin: EdgeInsets.fromLTRB( - 16, - 0, - 16, - index == rows.length - 1 ? 0 : 8, - ), - decoration: decoration, - child: rows[index], - ) - else - Container( - margin: const EdgeInsets.symmetric(horizontal: 16), - decoration: decoration, - child: Column(children: rows), + Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: decoration, + child: Column( + children: [ + for (var index = 0; index < rows.length; index++) ...[ + if (mornye && index > 0) + const Divider(height: 1, indent: 80, endIndent: 12), + rows[index], + ], + ], ), + ), ], ); } diff --git a/lib/widgets/mornye_chrome.dart b/lib/widgets/mornye_chrome.dart index a1f49845..07d30008 100644 --- a/lib/widgets/mornye_chrome.dart +++ b/lib/widgets/mornye_chrome.dart @@ -123,6 +123,8 @@ class MornyeGlassPanel extends ConsumerWidget { super.key, required this.child, this.radius = 32, + this.firstInGroup = true, + this.lastInGroup = true, this.strongTint = false, this.tintOpacity, }); @@ -132,17 +134,23 @@ class MornyeGlassPanel extends ConsumerWidget { super.key, required this.child, this.radius = 32, + this.firstInGroup = true, + this.lastInGroup = true, this.tintOpacity = 0.78, }) : strongTint = false; final Widget child; final double radius; + final bool firstInGroup; + final bool lastInGroup; final bool strongTint; final double? tintOpacity; @override Widget build(BuildContext context, WidgetRef ref) => MornyeGlass.navigation( radius: radius, + firstInGroup: firstInGroup, + lastInGroup: lastInGroup, strongTint: strongTint, tintOpacity: tintOpacity, blurEnabled: diff --git a/lib/widgets/mornye_landscape_player.dart b/lib/widgets/mornye_landscape_player.dart index 214b653b..21385f9f 100644 --- a/lib/widgets/mornye_landscape_player.dart +++ b/lib/widgets/mornye_landscape_player.dart @@ -83,7 +83,7 @@ class _MornyeLandscapePlayerState extends State { behavior: HitTestBehavior.translucent, onTap: _reveal, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), child: Row( children: [ Expanded(child: widget.artwork), @@ -91,7 +91,7 @@ class _MornyeLandscapePlayerState extends State { child: Column( children: [ Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), + padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), child: widget.header, ), const SizedBox(height: 8), diff --git a/lib/widgets/mornye_player_artwork.dart b/lib/widgets/mornye_player_artwork.dart index 8504c0cf..6d7109b1 100644 --- a/lib/widgets/mornye_player_artwork.dart +++ b/lib/widgets/mornye_player_artwork.dart @@ -8,20 +8,30 @@ class MornyePlayerArtwork extends StatelessWidget { super.key, required this.mediaItem, this.videoUrl, + this.onAspectRatioChanged, + this.onError, }); final MediaItem mediaItem; final String? videoUrl; + final ValueChanged? onAspectRatioChanged; + final VoidCallback? onError; @override Widget build(BuildContext context) { - final fallback = PlayerArtwork( - artUri: mediaItem.artUri?.toString(), - colorScheme: Theme.of(context).colorScheme, - cacheWidth: - (MediaQuery.sizeOf(context).width * - MediaQuery.devicePixelRatioOf(context)) - .round(), + final fallback = Align( + alignment: Alignment.topCenter, + child: AspectRatio( + aspectRatio: 1, + child: PlayerArtwork( + artUri: mediaItem.artUri?.toString(), + colorScheme: Theme.of(context).colorScheme, + cacheWidth: + (MediaQuery.sizeOf(context).width * + MediaQuery.devicePixelRatioOf(context)) + .round(), + ), + ), ); if (MediaQuery.disableAnimationsOf(context)) return fallback; return videoUrl == null @@ -30,6 +40,8 @@ class MornyePlayerArtwork extends StatelessWidget { key: ValueKey(videoUrl), videoUrl: videoUrl!, fallback: fallback, + onAspectRatioChanged: onAspectRatioChanged, + onError: onError, ); } } diff --git a/lib/widgets/mornye_player_background.dart b/lib/widgets/mornye_player_background.dart index 7bfe23c6..2398b5da 100644 --- a/lib/widgets/mornye_player_background.dart +++ b/lib/widgets/mornye_player_background.dart @@ -9,11 +9,13 @@ class MornyePlayerBackground extends StatelessWidget { required this.artUri, this.artwork, this.squareArtwork = true, + this.artworkAspectRatio, }); final Uri? artUri; final Widget? artwork; final bool squareArtwork; + final double? artworkAspectRatio; @override Widget build(BuildContext context) { @@ -56,11 +58,19 @@ class MornyePlayerBackground extends StatelessWidget { child: Stack( fit: StackFit.expand, children: [ - Positioned( + AnimatedPositioned( + duration: motion, + curve: Curves.easeInOutCubic, left: 0, right: 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).height * 0.66, child: ShaderMask( diff --git a/lib/widgets/motion_header_banner.dart b/lib/widgets/motion_header_banner.dart index 38290d97..94c0ab74 100644 --- a/lib/widgets/motion_header_banner.dart +++ b/lib/widgets/motion_header_banner.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; @@ -10,6 +12,8 @@ class MotionHeaderBanner extends StatefulWidget { final Widget fallback; final BoxFit fit; final Alignment alignment; + final ValueChanged? onAspectRatioChanged; + final VoidCallback? onError; const MotionHeaderBanner({ super.key, @@ -17,6 +21,8 @@ class MotionHeaderBanner extends StatefulWidget { required this.fallback, this.fit = BoxFit.cover, this.alignment = Alignment.topCenter, + this.onAspectRatioChanged, + this.onError, }); @override @@ -95,28 +101,35 @@ class _MotionHeaderBannerState extends State return; } - final controller = VideoPlayerController.networkUrl( - Uri.parse(url), - formatHint: Uri.parse(url).path.toLowerCase().endsWith('.m3u8') - ? VideoFormat.hls - : null, - videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true), - ); + final uri = Uri.tryParse(url); + final options = VideoPlayerOptions(mixWithOthers: true); + final controller = uri?.scheme == 'file' || url.startsWith('/') + ? VideoPlayerController.file( + uri?.scheme == 'file' ? File.fromUri(uri!) : File(url), + videoPlayerOptions: options, + ) + : VideoPlayerController.networkUrl( + Uri.parse(url), + formatHint: uri?.path.toLowerCase().endsWith('.m3u8') == true + ? VideoFormat.hls + : null, + videoPlayerOptions: options, + ); _controller = controller; try { await controller.initialize(); - if (!mounted) { - await controller.dispose(); - return; - } + if (!mounted || !identical(controller, _controller)) return; await controller.setVolume(0); await controller.setLooping(true); + if (!mounted || !identical(controller, _controller)) return; setState(() => _ready = true); + widget.onAspectRatioChanged?.call(controller.value.aspectRatio); _syncPlayback(); } catch (e) { _log.w('Failed to play motion banner: $e'); - if (!mounted) return; + if (!mounted || !identical(controller, _controller)) return; setState(() => _failed = true); + widget.onError?.call(); } } diff --git a/test/mornye_search_glass_test.dart b/test/mornye_search_glass_test.dart index 431d4cee..52866860 100644 --- a/test/mornye_search_glass_test.dart +++ b/test/mornye_search_glass_test.dart @@ -7,7 +7,7 @@ import 'package:spotiflac_android/widgets/mornye_chrome.dart'; void main() { for (final brightness in Brightness.values) { testWidgets( - 'search glass cards scroll and filters remain usable in $brightness', + 'search glass sections scroll and filters remain usable in $brightness', (tester) async { var selected = false; await tester.pumpWidget( @@ -31,16 +31,26 @@ void main() { Expanded( child: ListView.builder( itemCount: 12, - itemBuilder: (context, index) => Padding( - padding: const EdgeInsets.only(bottom: 8), - child: MornyeGlassPanel.overlay( - radius: 24, - child: SizedBox( - height: 88, - child: Text('Result $index'), + itemBuilder: (context, index) => + MornyeGlassPanel.overlay( + radius: 24, + firstInGroup: index == 0, + lastInGroup: index == 11, + child: Column( + children: [ + SizedBox( + height: 88, + child: Text('Result $index'), + ), + if (index < 11) + const Divider( + height: 1, + indent: 80, + endIndent: 12, + ), + ], + ), ), - ), - ), ), ), ], diff --git a/test/motion_artwork_store_test.dart b/test/motion_artwork_store_test.dart new file mode 100644 index 00000000..17e15c4d --- /dev/null +++ b/test/motion_artwork_store_test.dart @@ -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(); + 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 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().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 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(); + final started = Completer(); + 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'); + }, + ); +} diff --git a/test/motion_header_banner_test.dart b/test/motion_header_banner_test.dart index 2e8a6848..f7ae2a2f 100644 --- a/test/motion_header_banner_test.dart +++ b/test/motion_header_banner_test.dart @@ -8,12 +8,16 @@ class _VideoPlatform extends VideoPlayerPlatform { int playCalls = 0; bool looping = false; double volume = 1; + DataSource? source; @override Future init() async {} @override - Future createWithOptions(VideoCreationOptions options) async => 1; + Future createWithOptions(VideoCreationOptions options) async { + source = options.dataSource; + return 1; + } @override Stream videoEventsFor(int playerId) => Stream.value( @@ -58,6 +62,37 @@ class _VideoPlatform extends VideoPlayerPlatform { } 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', ( tester, ) async { diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index dc2f7bc6..44b60213 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -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(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', ( tester, ) async {