From 95f5ae610eb0bab33541f1656cedb3b72682846c Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 28 Jun 2026 06:06:54 +0700 Subject: [PATCH] feat(banner): HLS motion-artwork header banners and audio-quality badges Add a MotionHeaderBanner (video_player) that plays a looping muted HLS header video with a static image fallback for artist, album, and playlist screens. The Go backend now exposes header_video, header_image, and audio_traits from extensions. Album/playlist headers show the release year and Dolby Atmos / Lossless badges inline, full date and song count in a footer, a centered square cover when no video is present, and a full-bleed video when one is. --- go_backend/exports.go | 27 +- go_backend/extension_providers.go | 120 +++++--- lib/screens/album_screen.dart | 390 ++++++++++++++++++++------ lib/screens/artist_screen.dart | 62 +++- lib/screens/home_tab.dart | 3 + lib/screens/home_tab_widgets.dart | 38 +++ lib/screens/playlist_screen.dart | 124 +++++++- lib/widgets/motion_header_banner.dart | 131 +++++++++ pubspec.lock | 112 ++++++++ pubspec.yaml | 3 + 10 files changed, 866 insertions(+), 144 deletions(-) create mode 100644 lib/widgets/motion_header_banner.dart diff --git a/go_backend/exports.go b/go_backend/exports.go index 3990892b..e60a6688 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -2077,6 +2077,7 @@ func normalizeExtensionTrackMetadataMap( "duration_ms": track.DurationMS, "images": coverURL, "cover_url": coverURL, + "preview_url": track.PreviewURL, "release_date": track.ReleaseDate, "track_number": trackNum, "total_tracks": track.TotalTracks, @@ -2105,9 +2106,12 @@ func normalizeExtensionAlbumInfoMap(album *ExtAlbumMetadata) map[string]interfac "artist_id": album.ArtistID, "images": album.CoverURL, "cover_url": album.CoverURL, + "header_image": album.HeaderImage, + "header_video": album.HeaderVideo, "release_date": album.ReleaseDate, "total_tracks": album.TotalTracks, "album_type": album.AlbumType, + "audio_traits": album.AudioTraits, "provider_id": album.ProviderID, } } @@ -2192,11 +2196,13 @@ func getExtensionProviderMetadataResponse( return map[string]interface{}{ "playlist_info": map[string]interface{}{ - "id": playlist.ID, - "name": playlist.Name, - "images": playlist.CoverURL, - "cover_url": playlist.CoverURL, - "provider_id": playlist.ProviderID, + "id": playlist.ID, + "name": playlist.Name, + "images": playlist.CoverURL, + "cover_url": playlist.CoverURL, + "header_image": playlist.HeaderImage, + "header_video": playlist.HeaderVideo, + "provider_id": playlist.ProviderID, "owner": map[string]interface{}{ "name": playlist.Artists, "images": playlist.CoverURL, @@ -2225,6 +2231,7 @@ func getExtensionProviderMetadataResponse( "images": firstNonEmptyTrimmed(artist.HeaderImage, artist.ImageURL), "cover_url": artist.ImageURL, "header_image": artist.HeaderImage, + "header_video": artist.HeaderVideo, "provider_id": artist.ProviderID, }, "albums": albums, @@ -3448,6 +3455,7 @@ func CustomSearchWithExtensionJSONWithRequestID(extensionID, query string, optio "album_artist": track.AlbumArtist, "duration_ms": track.DurationMS, "images": track.ResolvedCoverURL(), + "preview_url": track.PreviewURL, "release_date": track.ReleaseDate, "track_number": track.TrackNumber, "total_tracks": track.TotalTracks, @@ -3513,6 +3521,8 @@ func HandleURLWithExtensionJSON(url string) (string, error) { "extension_id": extensionID, "name": result.Name, "cover_url": result.CoverURL, + "header_image": result.HeaderImage, + "header_video": result.HeaderVideo, } if result.Track != nil { @@ -3524,6 +3534,7 @@ func HandleURLWithExtensionJSON(url string) (string, error) { "album_artist": result.Track.AlbumArtist, "duration_ms": result.Track.DurationMS, "images": result.Track.ResolvedCoverURL(), + "preview_url": result.Track.PreviewURL, "release_date": result.Track.ReleaseDate, "track_number": result.Track.TrackNumber, "total_tracks": result.Track.TotalTracks, @@ -3546,6 +3557,7 @@ func HandleURLWithExtensionJSON(url string) (string, error) { "album_artist": track.AlbumArtist, "duration_ms": track.DurationMS, "images": track.ResolvedCoverURL(), + "preview_url": track.PreviewURL, "release_date": track.ReleaseDate, "track_number": track.TrackNumber, "total_tracks": track.TotalTracks, @@ -3567,6 +3579,9 @@ func HandleURLWithExtensionJSON(url string) (string, error) { "name": result.Album.Name, "artists": result.Album.Artists, "cover_url": result.Album.CoverURL, + "header_image": result.Album.HeaderImage, + "header_video": result.Album.HeaderVideo, + "audio_traits": result.Album.AudioTraits, "release_date": result.Album.ReleaseDate, "total_tracks": result.Album.TotalTracks, "album_type": result.Album.AlbumType, @@ -3580,6 +3595,7 @@ func HandleURLWithExtensionJSON(url string) (string, error) { "name": result.Artist.Name, "image_url": result.Artist.ImageURL, "header_image": result.Artist.HeaderImage, + "header_video": result.Artist.HeaderVideo, "listeners": result.Artist.Listeners, "provider_id": result.Artist.ProviderID, } @@ -3639,6 +3655,7 @@ func HandleURLWithExtensionJSON(url string) (string, error) { "album_artist": track.AlbumArtist, "duration_ms": track.DurationMS, "images": track.ResolvedCoverURL(), + "preview_url": track.PreviewURL, "release_date": track.ReleaseDate, "track_number": track.TrackNumber, "total_tracks": track.TotalTracks, diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 7110abf8..201b7fd4 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -29,6 +29,7 @@ type ExtTrackMetadata struct { ExternalURL string `json:"external_urls,omitempty"` DurationMS int `json:"duration_ms"` CoverURL string `json:"cover_url,omitempty"` + PreviewURL string `json:"preview_url,omitempty"` Images string `json:"images,omitempty"` ReleaseDate string `json:"release_date,omitempty"` TrackNumber int `json:"track_number,omitempty"` @@ -68,9 +69,12 @@ type ExtAlbumMetadata struct { Artists string `json:"artists"` ArtistID string `json:"artist_id,omitempty"` CoverURL string `json:"cover_url,omitempty"` + HeaderImage string `json:"header_image,omitempty"` + HeaderVideo string `json:"header_video,omitempty"` ReleaseDate string `json:"release_date,omitempty"` TotalTracks int `json:"total_tracks"` AlbumType string `json:"album_type,omitempty"` + AudioTraits []string `json:"audio_traits,omitempty"` Tracks []ExtTrackMetadata `json:"tracks"` ProviderID string `json:"provider_id"` } @@ -80,6 +84,7 @@ type ExtArtistMetadata struct { Name string `json:"name"` ImageURL string `json:"image_url,omitempty"` HeaderImage string `json:"header_image,omitempty"` + HeaderVideo string `json:"header_video,omitempty"` Listeners int `json:"listeners,omitempty"` Albums []ExtAlbumMetadata `json:"albums,omitempty"` Releases []ExtAlbumMetadata `json:"releases,omitempty"` @@ -737,6 +742,32 @@ func gojaObjectStringMap(vm *goja.Runtime, obj *goja.Object, keys ...string) map return result } +func gojaObjectStringSlice(obj *goja.Object, keys ...string) []string { + value := gojaObjectValue(obj, keys...) + if gojaValueIsEmpty(value) { + return nil + } + exported, ok := value.Export().([]interface{}) + if !ok || len(exported) == 0 { + return nil + } + result := make([]string, 0, len(exported)) + for _, item := range exported { + str, ok := item.(string) + if !ok { + continue + } + str = strings.TrimSpace(str) + if str != "" { + result = append(result, str) + } + } + if len(result) == 0 { + return nil + } + return result +} + func gojaArrayLength(value goja.Value, vm *goja.Runtime) (int, error) { if gojaValueIsEmpty(value) { return 0, nil @@ -767,6 +798,7 @@ func parseExtensionTrackValue(vm *goja.Runtime, value goja.Value) ExtTrackMetada ExternalURL: gojaObjectString(obj, "external_urls", "externalUrls", "external_url", "externalUrl", "url"), DurationMS: gojaObjectInt(obj, "duration_ms", "durationMs"), CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"), + PreviewURL: gojaObjectString(obj, "preview_url", "previewUrl"), Images: gojaObjectString(obj, "images"), ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"), TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"), @@ -833,9 +865,12 @@ func parseExtensionAlbumValue(vm *goja.Runtime, value goja.Value) (ExtAlbumMetad Artists: gojaObjectString(obj, "artists"), ArtistID: gojaObjectString(obj, "artist_id", "artistId"), CoverURL: gojaObjectString(obj, "cover_url", "coverUrl", "images"), + HeaderImage: gojaObjectString(obj, "header_image", "headerImage"), + HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"), ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"), TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"), AlbumType: gojaObjectString(obj, "album_type", "albumType"), + AudioTraits: gojaObjectStringSlice(obj, "audio_traits", "audioTraits"), Tracks: tracks, ProviderID: gojaObjectString(obj, "provider_id", "providerId"), }, nil @@ -904,6 +939,7 @@ func parseExtensionArtistValue(vm *goja.Runtime, value goja.Value) (ExtArtistMet Name: gojaObjectString(obj, "name"), ImageURL: gojaObjectString(obj, "image_url", "imageUrl"), HeaderImage: gojaObjectString(obj, "header_image", "headerImage"), + HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"), Listeners: gojaObjectInt(obj, "listeners"), Albums: albums, Releases: releases, @@ -996,9 +1032,11 @@ func parseExtensionDownloadResultValue(vm *goja.Runtime, value goja.Value) ExtDo func parseExtensionURLHandleValue(vm *goja.Runtime, value goja.Value) (ExtURLHandleResult, error) { obj := value.ToObject(vm) handleResult := ExtURLHandleResult{ - Type: gojaObjectString(obj, "type"), - Name: gojaObjectString(obj, "name"), - CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"), + Type: gojaObjectString(obj, "type"), + Name: gojaObjectString(obj, "name"), + CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"), + HeaderImage: gojaObjectString(obj, "header_image", "headerImage"), + HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"), } if trackValue := gojaObjectValue(obj, "track"); !gojaValueIsEmpty(trackValue) { @@ -2702,22 +2740,22 @@ func buildOutputPath(req DownloadRequest) string { } metadata := map[string]interface{}{ - "title": req.TrackName, - "artist": req.ArtistName, - "album": req.AlbumName, - "album_artist": req.AlbumArtist, - "track": req.TrackNumber, - "track_number": req.TrackNumber, - "total_tracks": req.TotalTracks, + "title": req.TrackName, + "artist": req.ArtistName, + "album": req.AlbumName, + "album_artist": req.AlbumArtist, + "track": req.TrackNumber, + "track_number": req.TrackNumber, + "total_tracks": req.TotalTracks, "playlist_position": req.PlaylistPosition, - "disc": req.DiscNumber, - "disc_number": req.DiscNumber, - "total_discs": req.TotalDiscs, - "year": extractYear(req.ReleaseDate), - "date": req.ReleaseDate, - "release_date": req.ReleaseDate, - "isrc": req.ISRC, - "composer": req.Composer, + "disc": req.DiscNumber, + "disc_number": req.DiscNumber, + "total_discs": req.TotalDiscs, + "year": extractYear(req.ReleaseDate), + "date": req.ReleaseDate, + "release_date": req.ReleaseDate, + "isrc": req.ISRC, + "composer": req.Composer, } filename := buildFilenameFromTemplate(req.FilenameFormat, metadata) @@ -2762,22 +2800,22 @@ func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) stri AddAllowedDownloadDir(tempDir) metadata := map[string]interface{}{ - "title": req.TrackName, - "artist": req.ArtistName, - "album": req.AlbumName, - "album_artist": req.AlbumArtist, - "track": req.TrackNumber, - "track_number": req.TrackNumber, - "total_tracks": req.TotalTracks, + "title": req.TrackName, + "artist": req.ArtistName, + "album": req.AlbumName, + "album_artist": req.AlbumArtist, + "track": req.TrackNumber, + "track_number": req.TrackNumber, + "total_tracks": req.TotalTracks, "playlist_position": req.PlaylistPosition, - "disc": req.DiscNumber, - "disc_number": req.DiscNumber, - "total_discs": req.TotalDiscs, - "year": extractYear(req.ReleaseDate), - "date": req.ReleaseDate, - "release_date": req.ReleaseDate, - "isrc": req.ISRC, - "composer": req.Composer, + "disc": req.DiscNumber, + "disc_number": req.DiscNumber, + "total_discs": req.TotalDiscs, + "year": extractYear(req.ReleaseDate), + "date": req.ReleaseDate, + "release_date": req.ReleaseDate, + "isrc": req.ISRC, + "composer": req.Composer, } filename := buildFilenameFromTemplate(req.FilenameFormat, metadata) @@ -2934,13 +2972,15 @@ func (p *extensionProviderWrapper) customSearch(query string, options map[string } type ExtURLHandleResult struct { - Type string `json:"type"` - Track *ExtTrackMetadata `json:"track,omitempty"` - Tracks []ExtTrackMetadata `json:"tracks,omitempty"` - Album *ExtAlbumMetadata `json:"album,omitempty"` - Artist *ExtArtistMetadata `json:"artist,omitempty"` - Name string `json:"name,omitempty"` - CoverURL string `json:"cover_url,omitempty"` + Type string `json:"type"` + Track *ExtTrackMetadata `json:"track,omitempty"` + Tracks []ExtTrackMetadata `json:"tracks,omitempty"` + Album *ExtAlbumMetadata `json:"album,omitempty"` + Artist *ExtArtistMetadata `json:"artist,omitempty"` + Name string `json:"name,omitempty"` + CoverURL string `json:"cover_url,omitempty"` + HeaderImage string `json:"header_image,omitempty"` + HeaderVideo string `json:"header_video,omitempty"` } func (p *extensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) { diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 8cc53f7e..a968a76e 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -1,3 +1,4 @@ +import 'dart:ui' show ImageFilter; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; @@ -23,6 +24,8 @@ import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/cross_extension_share_sheet.dart'; +import 'package:spotiflac_android/widgets/preview_button.dart'; +import 'package:spotiflac_android/widgets/motion_header_banner.dart'; class _AlbumCache { static final Map _cache = {}; @@ -53,6 +56,9 @@ class AlbumScreen extends ConsumerStatefulWidget { final String albumId; final String albumName; final String? coverUrl; + final String? headerVideoUrl; + final String? headerImageUrl; + final List? audioTraits; final List? tracks; final String? extensionId; final String? artistId; @@ -63,6 +69,9 @@ class AlbumScreen extends ConsumerStatefulWidget { required this.albumId, required this.albumName, this.coverUrl, + this.headerVideoUrl, + this.headerImageUrl, + this.audioTraits, this.tracks, this.extensionId, this.artistId, @@ -81,6 +90,10 @@ class _AlbumScreenState extends ConsumerState { String? _artistId; String? _albumType; int? _albumTotalTracks; + String? _headerVideoUrl; + String? _headerImageUrl; + List _audioTraits = const []; + bool _tallHeader = false; final ScrollController _scrollController = ScrollController(); String _legacyProviderIdFromResourceId(String value) { @@ -139,6 +152,9 @@ class _AlbumScreenState extends ConsumerState { _artistId = widget.artistId; _albumType = _tracks?.firstOrNull?.albumType; _albumTotalTracks = _tracks?.firstOrNull?.totalTracks; + _headerVideoUrl = widget.headerVideoUrl; + _headerImageUrl = widget.headerImageUrl; + _audioTraits = widget.audioTraits ?? const []; if (_tracks == null || _tracks!.isEmpty) { _fetchTracks(); @@ -153,7 +169,7 @@ class _AlbumScreenState extends ConsumerState { } void _onScroll() { - final expandedHeight = _calculateExpandedHeight(context); + final expandedHeight = _calculateExpandedHeight(context, tall: _tallHeader); final shouldShow = _scrollController.offset > (expandedHeight - kToolbarHeight - 20); if (shouldShow != _showTitleInAppBar) { @@ -161,9 +177,12 @@ class _AlbumScreenState extends ConsumerState { } } - double _calculateExpandedHeight(BuildContext context) { + double _calculateExpandedHeight(BuildContext context, {bool tall = false}) { final mediaSize = MediaQuery.of(context).size; - return (mediaSize.height * 0.55).clamp(360.0, 520.0); + if (tall) { + return (mediaSize.height * 0.68).clamp(440.0, 660.0); + } + return (mediaSize.height * 0.6).clamp(400.0, 580.0); } String? _highResCoverUrl(String? url) { @@ -214,6 +233,11 @@ class _AlbumScreenState extends ConsumerState { albumInfo?['album_type']?.toString(), ); final totalTracks = albumInfo?['total_tracks'] as int?; + final headerVideo = albumInfo?['header_video']?.toString(); + final headerImage = albumInfo?['header_image']?.toString(); + final audioTraits = (albumInfo?['audio_traits'] as List?) + ?.map((e) => e.toString()) + .toList(); final tracks = trackList .map( (t) => _parseTrack( @@ -232,6 +256,17 @@ class _AlbumScreenState extends ConsumerState { _artistId = artistId; _albumType = albumType; _albumTotalTracks = totalTracks; + _headerVideoUrl = + (headerVideo != null && headerVideo.isNotEmpty) + ? headerVideo + : _headerVideoUrl; + _headerImageUrl = + (headerImage != null && headerImage.isNotEmpty) + ? headerImage + : _headerImageUrl; + _audioTraits = (audioTraits != null && audioTraits.isNotEmpty) + ? audioTraits + : _audioTraits; _isLoading = false; }); } @@ -251,6 +286,14 @@ class _AlbumScreenState extends ConsumerState { albumInfo?['album_type']?.toString(), ); final totalTracks = albumInfo?['total_tracks'] as int?; + final headerVideo = + (albumInfo?['header_video'] ?? result['header_video'])?.toString(); + final headerImage = + (albumInfo?['header_image'] ?? result['header_image'])?.toString(); + final audioTraits = + ((albumInfo?['audio_traits'] ?? result['audio_traits']) as List?) + ?.map((e) => e.toString()) + .toList(); final tracks = trackList .map( (t) => _parseTrack( @@ -269,6 +312,17 @@ class _AlbumScreenState extends ConsumerState { _artistId = artistId; _albumType = albumType; _albumTotalTracks = totalTracks; + _headerVideoUrl = + (headerVideo != null && headerVideo.isNotEmpty) + ? headerVideo + : _headerVideoUrl; + _headerImageUrl = + (headerImage != null && headerImage.isNotEmpty) + ? headerImage + : _headerImageUrl; + _audioTraits = (audioTraits != null && audioTraits.isNotEmpty) + ? audioTraits + : _audioTraits; _isLoading = false; }); } @@ -293,6 +347,98 @@ class _AlbumScreenState extends ConsumerState { return _stripPrefixedResourceId(widget.albumId); } + double _albumTitleFontSize() { + final length = widget.albumName.trim().length; + if (length > 45) return 18; + if (length > 30) return 21; + return 24; + } + + Widget _metaInlineItem(IconData? icon, String label) { + const textStyle = TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500, + ); + if (icon == null) { + return Text(label, style: textStyle); + } + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 15, color: Colors.white), + const SizedBox(width: 4), + Text(label, style: textStyle), + ], + ); + } + + List _audioTraitInline() { + final traits = _audioTraits + .map((t) => t.toLowerCase().trim()) + .where((t) => t.isNotEmpty) + .toSet(); + if (traits.isEmpty) return const []; + + bool has(List keys) => keys.any(traits.contains); + + final items = []; + if (has(['atmos', 'dolby_atmos', 'dolby-atmos'])) { + items.add(_metaInlineItem(Icons.surround_sound, 'Dolby Atmos')); + } else if (has(['spatial'])) { + items.add(_metaInlineItem(Icons.surround_sound, 'Spatial Audio')); + } + + if (has(['hi-res-lossless', 'hi_res_lossless', 'hires-lossless'])) { + items.add(_metaInlineItem(Icons.graphic_eq, 'Hi-Res Lossless')); + } else if (has(['lossless'])) { + items.add(_metaInlineItem(Icons.graphic_eq, 'Lossless')); + } + + return items; + } + + Widget _buildHeaderMeta(BuildContext context, String? releaseDate) { + final items = []; + + void add(Widget widget) { + if (items.isNotEmpty) { + items.add( + const Padding( + padding: EdgeInsets.symmetric(horizontal: 6), + child: Text( + '•', + style: TextStyle(color: Colors.white70, fontSize: 12), + ), + ), + ); + } + items.add(widget); + } + + final year = _releaseYear(releaseDate); + if (year != null) { + add(_metaInlineItem(null, year)); + } + for (final trait in _audioTraitInline()) { + add(trait); + } + + return Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 0, + runSpacing: 4, + children: items, + ); + } + + String? _releaseYear(String? date) { + if (date == null || date.isEmpty) return null; + final match = RegExp(r'(\d{4})').firstMatch(date); + return match?.group(1); + } + Track _parseTrack( Map data, { String? albumTypeFallback, @@ -325,6 +471,7 @@ class _AlbumScreenState extends ConsumerState { composer: data['composer']?.toString(), audioQuality: data['audio_quality']?.toString(), audioModes: data['audio_modes']?.toString(), + previewUrl: data['preview_url']?.toString(), ); } @@ -362,6 +509,7 @@ class _AlbumScreenState extends ConsumerState { ), if (!_isLoading && _error == null && tracks.isNotEmpty) ...[ _buildTrackList(context, colorScheme, tracks), + _buildAlbumFooter(context, colorScheme, tracks), ], SliverToBoxAdapter(child: SizedBox(height: 32 + bottomInset)), ], @@ -374,7 +522,6 @@ class _AlbumScreenState extends ConsumerState { ColorScheme colorScheme, Color pageBackgroundColor, ) { - final expandedHeight = _calculateExpandedHeight(context); final tracks = _tracks ?? []; final artistName = widget.artistName ?? @@ -383,6 +530,16 @@ class _AlbumScreenState extends ConsumerState { : null); final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null; + final motionUrl = _headerVideoUrl ?? widget.headerVideoUrl; + final hasMotion = + motionUrl != null && + motionUrl.trim().isNotEmpty && + Uri.tryParse(motionUrl)?.hasAuthority == true; + final coverThumbUrl = widget.coverUrl ?? _headerImageUrl; + final showSquareCover = !hasMotion && coverThumbUrl != null; + _tallHeader = false; + final expandedHeight = _calculateExpandedHeight(context); + return SliverAppBar( expandedHeight: expandedHeight, pinned: true, @@ -410,33 +567,46 @@ class _AlbumScreenState extends ConsumerState { (expandedHeight - kToolbarHeight); final showContent = collapseRatio > 0.3; final cacheWidth = coverCacheWidthForViewport(context); + final headerBgUrl = + _headerImageUrl ?? widget.headerImageUrl ?? widget.coverUrl; + final Widget headerBgImage = headerBgUrl != null + ? CachedNetworkImage( + imageUrl: _highResCoverUrl(headerBgUrl) ?? headerBgUrl, + fit: BoxFit.cover, + memCacheWidth: cacheWidth, + cacheManager: CoverCacheManager.instance, + placeholder: (_, _) => Container(color: colorScheme.surface), + errorWidget: (_, _, _) => + Container(color: colorScheme.surface), + ) + : Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.album, + size: 80, + color: colorScheme.onSurfaceVariant, + ), + ); return FlexibleSpaceBar( collapseMode: CollapseMode.pin, background: Stack( fit: StackFit.expand, children: [ - if (widget.coverUrl != null) - CachedNetworkImage( - imageUrl: - _highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!, - fit: BoxFit.cover, - memCacheWidth: cacheWidth, - cacheManager: CoverCacheManager.instance, - placeholder: (_, _) => - Container(color: colorScheme.surface), - errorWidget: (_, _, _) => - Container(color: colorScheme.surface), + if (hasMotion) + MotionHeaderBanner( + videoUrl: motionUrl, + fallback: headerBgImage, + ) + else if (showSquareCover) + ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32), + child: headerBgImage, ) else - Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.album, - size: 80, - color: colorScheme.onSurfaceVariant, - ), - ), + headerBgImage, + if (showSquareCover) + Container(color: Colors.black.withValues(alpha: 0.35)), Positioned( left: 0, right: 0, @@ -466,11 +636,61 @@ class _AlbumScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ + if (showSquareCover) ...[ + Builder( + builder: (context) { + final coverSize = (constraints.maxWidth * 0.5) + .clamp(150.0, 210.0) + .toDouble(); + return Container( + width: coverSize, + height: coverSize, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues( + alpha: 0.45, + ), + blurRadius: 24, + offset: const Offset(0, 8), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: CachedNetworkImage( + imageUrl: + _highResCoverUrl(coverThumbUrl) ?? + coverThumbUrl, + fit: BoxFit.cover, + width: coverSize, + height: coverSize, + memCacheWidth: cacheWidth, + cacheManager: CoverCacheManager.instance, + placeholder: (_, _) => Container( + color: colorScheme.surfaceContainerHighest, + ), + errorWidget: (_, _, _) => Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.album, + size: 48, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ); + }, + ), + const SizedBox(height: 20), + ], Text( widget.albumName, - style: const TextStyle( + style: TextStyle( color: Colors.white, - fontSize: 24, + fontSize: _albumTitleFontSize(), fontWeight: FontWeight.bold, height: 1.2, ), @@ -497,72 +717,7 @@ class _AlbumScreenState extends ConsumerState { ], if (tracks.isNotEmpty) ...[ const SizedBox(height: 12), - Wrap( - alignment: WrapAlignment.center, - spacing: 8, - runSpacing: 8, - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.music_note, - size: 14, - color: Colors.white, - ), - const SizedBox(width: 4), - Text( - context.l10n.tracksCount(tracks.length), - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ], - ), - ), - if (releaseDate != null && releaseDate.isNotEmpty) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.calendar_today, - size: 14, - color: Colors.white, - ), - const SizedBox(width: 4), - Text( - _formatReleaseDate(releaseDate), - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ], - ), - ), - ], - ), + _buildHeaderMeta(context, releaseDate), const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.center, @@ -641,6 +796,51 @@ class _AlbumScreenState extends ConsumerState { return const SliverToBoxAdapter(child: SizedBox.shrink()); } + Widget _buildAlbumFooter( + BuildContext context, + ColorScheme colorScheme, + List tracks, + ) { + final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null; + final totalSeconds = tracks.fold( + 0, + (sum, t) => sum + (t.duration > 0 ? t.duration : 0), + ); + final totalMinutes = (totalSeconds / 60).round(); + + final lines = []; + if (releaseDate != null && releaseDate.isNotEmpty) { + lines.add(_formatReleaseDate(releaseDate)); + } + final countText = context.l10n.tracksCount(tracks.length); + lines.add( + totalMinutes > 0 ? '$countText • $totalMinutes min' : countText, + ); + + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final line in lines) + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + line, + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ); + } + Widget _buildTrackList( BuildContext context, ColorScheme colorScheme, @@ -1117,7 +1317,13 @@ class _AlbumTrackItem extends ConsumerWidget { ], ], ), - trailing: TrackCollectionQuickActions(track: track), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + PreviewButton(track: track), + TrackCollectionQuickActions(track: track), + ], + ), onTap: () => _handleTap(context, ref, isQueued: isQueued), onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet( context, diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 4a6ab9a4..caa16083 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -24,6 +24,7 @@ import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; +import 'package:spotiflac_android/widgets/motion_header_banner.dart'; import 'package:spotiflac_android/widgets/cross_extension_share_sheet.dart'; class _ArtistCache { @@ -46,6 +47,7 @@ class _ArtistCache { List? releases, List? topTracks, String? headerImageUrl, + String? headerVideoUrl, int? monthlyListeners, }) { _cache[artistId] = _CacheEntry( @@ -53,6 +55,7 @@ class _ArtistCache { releases: releases, topTracks: topTracks, headerImageUrl: headerImageUrl, + headerVideoUrl: headerVideoUrl, monthlyListeners: monthlyListeners, expiresAt: DateTime.now().add(_ttl), ); @@ -64,6 +67,7 @@ class _CacheEntry { final List? releases; final List? topTracks; final String? headerImageUrl; + final String? headerVideoUrl; final int? monthlyListeners; final DateTime expiresAt; @@ -72,6 +76,7 @@ class _CacheEntry { this.releases, this.topTracks, this.headerImageUrl, + this.headerVideoUrl, this.monthlyListeners, required this.expiresAt, }); @@ -82,6 +87,7 @@ class ArtistScreen extends ConsumerStatefulWidget { final String artistName; final String? coverUrl; final String? headerImageUrl; + final String? headerVideoUrl; final int? monthlyListeners; final List? albums; final List? topTracks; @@ -93,6 +99,7 @@ class ArtistScreen extends ConsumerStatefulWidget { required this.artistName, this.coverUrl, this.headerImageUrl, + this.headerVideoUrl, this.monthlyListeners, this.albums, this.topTracks, @@ -109,6 +116,7 @@ class _ArtistScreenState extends ConsumerState { List? _releases; List? _topTracks; String? _headerImageUrl; + String? _headerVideoUrl; int? _monthlyListeners; String? _error; @@ -217,6 +225,7 @@ class _ArtistScreenState extends ConsumerState { _albums = widget.albums; _topTracks = widget.topTracks; _headerImageUrl = widget.headerImageUrl; + _headerVideoUrl = widget.headerVideoUrl; _monthlyListeners = widget.monthlyListeners; if ((_albums == null || _albums!.isEmpty) || @@ -232,6 +241,7 @@ class _ArtistScreenState extends ConsumerState { _albums = widget.albums; _topTracks = widget.topTracks; _headerImageUrl = widget.headerImageUrl; + _headerVideoUrl = widget.headerVideoUrl; _monthlyListeners = widget.monthlyListeners; if (_topTracks == null || _topTracks!.isEmpty) { @@ -242,6 +252,7 @@ class _ArtistScreenState extends ConsumerState { _releases = cached.releases; _topTracks = cached.topTracks; _headerImageUrl = cached.headerImageUrl; + _headerVideoUrl = cached.headerVideoUrl; _monthlyListeners = cached.monthlyListeners; if (_topTracks == null || _topTracks!.isEmpty) { @@ -274,6 +285,7 @@ class _ArtistScreenState extends ConsumerState { List? releases; List? topTracks; String? headerImage; + String? headerVideo; int? listeners; if (_directMetadataProviderId() != null) { @@ -310,6 +322,9 @@ class _ArtistScreenState extends ConsumerState { artistData['header_image'] as String? ?? artistData['cover_url'] as String? ?? artistData['image_url'] as String?; + headerVideo = + artistInfo?['header_video'] as String? ?? + artistData['header_video'] as String?; listeners = artistInfo?['listeners'] as int? ?? artistData['listeners'] as int?; } else { @@ -332,6 +347,7 @@ class _ArtistScreenState extends ConsumerState { } headerImage = artistData['header_image'] as String?; + headerVideo = artistData['header_video'] as String?; listeners = artistData['listeners'] as int?; } else { throw StateError('Failed to load artist metadata from extension'); @@ -340,6 +356,8 @@ class _ArtistScreenState extends ConsumerState { final finalHeaderImage = headerImage ?? _headerImageUrl ?? widget.headerImageUrl; + final finalHeaderVideo = + headerVideo ?? _headerVideoUrl ?? widget.headerVideoUrl; final finalListeners = listeners ?? _monthlyListeners ?? widget.monthlyListeners; @@ -349,6 +367,7 @@ class _ArtistScreenState extends ConsumerState { releases: releases, topTracks: topTracks, headerImageUrl: finalHeaderImage, + headerVideoUrl: finalHeaderVideo, monthlyListeners: finalListeners, ); @@ -358,6 +377,7 @@ class _ArtistScreenState extends ConsumerState { _releases = releases; _topTracks = topTracks; _headerImageUrl = finalHeaderImage; + _headerVideoUrl = finalHeaderVideo; _monthlyListeners = finalListeners; _isLoadingDiscography = false; }); @@ -410,6 +430,7 @@ class _ArtistScreenState extends ConsumerState { totalTracks: data['total_tracks'] as int? ?? album?.totalTracks, composer: data['composer']?.toString(), source: data['provider_id']?.toString() ?? widget.extensionId, + previewUrl: data['preview_url']?.toString(), ); } @@ -1127,6 +1148,15 @@ class _ArtistScreenState extends ConsumerState { imageUrl.isNotEmpty && Uri.tryParse(imageUrl)?.hasAuthority == true; + String? headerVideoUrl = _headerVideoUrl; + if (headerVideoUrl == null || headerVideoUrl.isEmpty) { + headerVideoUrl = widget.headerVideoUrl; + } + final hasMotionBanner = + headerVideoUrl != null && + headerVideoUrl.isNotEmpty && + Uri.tryParse(headerVideoUrl)?.hasAuthority == true; + final isDark = Theme.of(context).brightness == Brightness.dark; String? listenersText; @@ -1174,7 +1204,37 @@ class _ArtistScreenState extends ConsumerState { background: Stack( fit: StackFit.expand, children: [ - if (hasValidImage) + if (hasMotionBanner) + MotionHeaderBanner( + videoUrl: headerVideoUrl!, + fallback: hasValidImage + ? CachedCoverImage( + imageUrl: imageUrl!, + fit: BoxFit.cover, + alignment: Alignment.topCenter, + memCacheWidth: 800, + placeholder: (context, url) => Container( + color: colorScheme.surfaceContainerHighest, + ), + errorWidget: (context, url, error) => Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.person, + size: 80, + color: colorScheme.onSurfaceVariant, + ), + ), + ) + : Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.person, + size: 80, + color: colorScheme.onSurfaceVariant, + ), + ), + ) + else if (hasValidImage) CachedCoverImage( imageUrl: imageUrl, fit: BoxFit.cover, diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 4686a47e..11f20895 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -32,6 +32,7 @@ import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; +import 'package:spotiflac_android/widgets/preview_button.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; part 'home_tab_helpers.dart'; @@ -820,6 +821,8 @@ class _HomeTabState extends ConsumerState artistId: trackState.artistId!, artistName: trackState.artistName!, coverUrl: trackState.coverUrl, + headerImageUrl: trackState.headerImageUrl, + headerVideoUrl: trackState.headerVideoUrl, albums: trackState.artistAlbums!, extensionId: extensionId, ), diff --git a/lib/screens/home_tab_widgets.dart b/lib/screens/home_tab_widgets.dart index d7ecc974..f6dcfbed 100644 --- a/lib/screens/home_tab_widgets.dart +++ b/lib/screens/home_tab_widgets.dart @@ -376,6 +376,7 @@ class _TrackItemWithStatus extends ConsumerWidget { ], ), ), + PreviewButton(track: track), TrackCollectionQuickActions(track: track), ], ), @@ -992,6 +993,9 @@ class _ExtensionAlbumScreenState extends ConsumerState { String? _artistName; String? _albumType; int? _albumTotalTracks; + String? _headerVideoUrl; + String? _headerImageUrl; + List _audioTraits = const []; @override void initState() { @@ -1036,6 +1040,11 @@ class _ExtensionAlbumScreenState extends ConsumerState { _albumType; final totalTracks = albumInfo['total_tracks'] as int? ?? _albumTotalTracks; + final headerVideo = albumInfo['header_video']?.toString(); + final headerImage = albumInfo['header_image']?.toString(); + final audioTraits = (albumInfo['audio_traits'] as List?) + ?.map((e) => e.toString()) + .toList(); final tracks = trackList .map( (t) => _parseTrack( @@ -1052,6 +1061,15 @@ class _ExtensionAlbumScreenState extends ConsumerState { _artistName = artistName; _albumType = albumType; _albumTotalTracks = totalTracks; + _headerVideoUrl = (headerVideo != null && headerVideo.isNotEmpty) + ? headerVideo + : _headerVideoUrl; + _headerImageUrl = (headerImage != null && headerImage.isNotEmpty) + ? headerImage + : _headerImageUrl; + _audioTraits = (audioTraits != null && audioTraits.isNotEmpty) + ? audioTraits + : _audioTraits; _isLoading = false; }); } catch (e) { @@ -1107,6 +1125,7 @@ class _ExtensionAlbumScreenState extends ConsumerState { source: widget.extensionId, audioQuality: data['audio_quality']?.toString(), audioModes: data['audio_modes']?.toString(), + previewUrl: data['preview_url']?.toString(), ); } @@ -1153,6 +1172,9 @@ class _ExtensionAlbumScreenState extends ConsumerState { albumId: widget.albumId, albumName: widget.albumName, coverUrl: widget.coverUrl, + headerVideoUrl: _headerVideoUrl, + headerImageUrl: _headerImageUrl, + audioTraits: _audioTraits, tracks: _tracks, extensionId: widget.extensionId, artistId: _artistId, @@ -1186,6 +1208,7 @@ class _ExtensionPlaylistScreenState List? _tracks; bool _isLoading = true; String? _error; + String? _headerVideoUrl; @override void initState() { @@ -1222,8 +1245,14 @@ class _ExtensionPlaylistScreenState .map((t) => _parseTrack(t as Map)) .toList(); + final playlistInfo = result['playlist_info'] as Map?; + final headerVideo = playlistInfo?['header_video']?.toString(); + setState(() { _tracks = tracks; + _headerVideoUrl = (headerVideo != null && headerVideo.isNotEmpty) + ? headerVideo + : _headerVideoUrl; _isLoading = false; }); } catch (e) { @@ -1266,6 +1295,7 @@ class _ExtensionPlaylistScreenState source: widget.extensionId, audioQuality: data['audio_quality']?.toString(), audioModes: data['audio_modes']?.toString(), + previewUrl: data['preview_url']?.toString(), ); } @@ -1308,6 +1338,7 @@ class _ExtensionPlaylistScreenState return PlaylistScreen( playlistName: widget.playlistName, coverUrl: widget.coverUrl, + headerVideoUrl: _headerVideoUrl, tracks: _tracks!, recommendedService: widget.extensionId, ); @@ -1337,6 +1368,7 @@ class _ExtensionArtistScreenState extends ConsumerState { List? _albums; List? _topTracks; String? _headerImageUrl; + String? _headerVideoUrl; int? _monthlyListeners; bool _isLoading = true; String? _error; @@ -1383,6 +1415,9 @@ class _ExtensionArtistScreenState extends ConsumerState { artistInfo['header_image'] as String? ?? artistInfo['cover_url'] as String? ?? result['header_image'] as String?; + final headerVideo = + artistInfo['header_video'] as String? ?? + result['header_video'] as String?; final listeners = artistInfo['listeners'] as int? ?? result['listeners'] as int?; @@ -1390,6 +1425,7 @@ class _ExtensionArtistScreenState extends ConsumerState { _albums = albums; _topTracks = topTracks; _headerImageUrl = headerImage; + _headerVideoUrl = headerVideo; _monthlyListeners = listeners; _isLoading = false; }); @@ -1446,6 +1482,7 @@ class _ExtensionArtistScreenState extends ConsumerState { totalTracks: data['total_tracks'] as int?, composer: data['composer']?.toString(), source: (data['provider_id'] ?? widget.extensionId).toString(), + previewUrl: data['preview_url']?.toString(), ); } @@ -1485,6 +1522,7 @@ class _ExtensionArtistScreenState extends ConsumerState { artistName: widget.artistName, coverUrl: widget.coverUrl, headerImageUrl: _headerImageUrl, + headerVideoUrl: _headerVideoUrl, monthlyListeners: _monthlyListeners, albums: _albums, topTracks: _topTracks, diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 532fb3e2..ef02fb5c 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -20,10 +20,13 @@ import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; +import 'package:spotiflac_android/widgets/motion_header_banner.dart'; +import 'package:spotiflac_android/widgets/preview_button.dart'; class PlaylistScreen extends ConsumerStatefulWidget { final String playlistName; final String? coverUrl; + final String? headerVideoUrl; final List tracks; final String? playlistId; final String? recommendedService; @@ -32,6 +35,7 @@ class PlaylistScreen extends ConsumerStatefulWidget { super.key, required this.playlistName, this.coverUrl, + this.headerVideoUrl, required this.tracks, this.playlistId, this.recommendedService, @@ -49,10 +53,13 @@ class _PlaylistScreenState extends ConsumerState { String? _error; String? _resolvedPlaylistName; String? _resolvedCoverUrl; + String? _resolvedHeaderVideoUrl; List get _tracks => _fetchedTracks ?? widget.tracks; String get _playlistName => _resolvedPlaylistName ?? widget.playlistName; String? get _coverUrl => _resolvedCoverUrl ?? widget.coverUrl; + String? get _headerVideoUrl => + _resolvedHeaderVideoUrl ?? widget.headerVideoUrl; String? _legacyProviderIdFromResourceId(String value) { if (value.startsWith('deezer:')) return 'deezer'; @@ -165,12 +172,18 @@ class _PlaylistScreenState extends ConsumerState { .map((t) => _parseTrack(t as Map)) .toList(); + final headerVideo = playlistInfo?['header_video']?.toString(); + setState(() { _fetchedTracks = tracks; _resolvedPlaylistName = (playlistInfo?['name'] ?? owner?['name']) ?.toString(); _resolvedCoverUrl = (playlistInfo?['images'] ?? owner?['images']) ?.toString(); + _resolvedHeaderVideoUrl = + (headerVideo != null && headerVideo.isNotEmpty) + ? headerVideo + : _resolvedHeaderVideoUrl; _isLoading = false; }); } catch (e) { @@ -212,6 +225,7 @@ class _PlaylistScreenState extends ConsumerState { composer: data['composer']?.toString(), audioQuality: data['audio_quality']?.toString(), audioModes: data['audio_modes']?.toString(), + previewUrl: data['preview_url']?.toString(), ); } @@ -255,6 +269,7 @@ class _PlaylistScreenState extends ConsumerState { _buildAppBar(context, colorScheme), _buildInfoCard(context, colorScheme), _buildTrackList(context, colorScheme), + _buildPlaylistFooter(context, colorScheme), SliverToBoxAdapter( child: SizedBox(height: 32 + context.navBarBottomInset), ), @@ -293,13 +308,33 @@ class _PlaylistScreenState extends ConsumerState { (expandedHeight - kToolbarHeight); final showContent = collapseRatio > 0.3; final cacheWidth = coverCacheWidthForViewport(context); + final motionUrl = _headerVideoUrl; + final hasMotion = + motionUrl != null && + motionUrl.trim().isNotEmpty && + Uri.tryParse(motionUrl)?.hasAuthority == true; return FlexibleSpaceBar( collapseMode: CollapseMode.pin, background: Stack( fit: StackFit.expand, children: [ - if (_coverUrl != null) + if (hasMotion) + MotionHeaderBanner( + videoUrl: motionUrl, + fallback: _coverUrl != null + ? CachedCoverImage( + imageUrl: _highResCoverUrl(_coverUrl) ?? _coverUrl!, + fit: BoxFit.cover, + memCacheWidth: cacheWidth, + placeholder: (_, _) => + Container(color: colorScheme.surface), + errorWidget: (_, _, _) => + Container(color: colorScheme.surface), + ) + : Container(color: colorScheme.surface), + ) + else if (_coverUrl != null) ImageFiltered( imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32), child: CachedCoverImage( @@ -353,10 +388,12 @@ class _PlaylistScreenState extends ConsumerState { 28, ), child: Column( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: hasMotion + ? MainAxisAlignment.end + : MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ - if (_coverUrl != null) ...[ + if (_coverUrl != null && !hasMotion) ...[ Builder( builder: (context) { final coverSize = @@ -455,7 +492,9 @@ class _PlaylistScreenState extends ConsumerState { children: [ _buildLoveAllButton(), const SizedBox(width: 12), - _buildDownloadAllCenterButton(context), + Flexible( + child: _buildDownloadAllCenterButton(context), + ), const SizedBox(width: 12), _buildAddToPlaylistButton(context), ], @@ -491,6 +530,69 @@ class _PlaylistScreenState extends ConsumerState { return const SliverToBoxAdapter(child: SizedBox.shrink()); } + String _formatReleaseDate(String date) { + if (date.length >= 10) { + final parts = date.substring(0, 10).split('-'); + if (parts.length == 3) { + return '${parts[2]}/${parts[1]}/${parts[0]}'; + } + } else if (date.length >= 7) { + final parts = date.split('-'); + if (parts.length >= 2) { + return '${parts[1]}/${parts[0]}'; + } + } + return date; + } + + Widget _buildPlaylistFooter(BuildContext context, ColorScheme colorScheme) { + final tracks = _tracks; + if (tracks.isEmpty) { + return const SliverToBoxAdapter(child: SizedBox.shrink()); + } + + final releaseDate = tracks.first.releaseDate; + final totalSeconds = tracks.fold( + 0, + (sum, t) => sum + (t.duration > 0 ? t.duration : 0), + ); + final totalMinutes = (totalSeconds / 60).round(); + + final lines = []; + if (releaseDate != null && + releaseDate.isNotEmpty && + !releaseDate.startsWith('1970')) { + lines.add(_formatReleaseDate(releaseDate)); + } + final countText = context.l10n.tracksCount(tracks.length); + lines.add( + totalMinutes > 0 ? '$countText • $totalMinutes min' : countText, + ); + + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final line in lines) + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + line, + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ); + } + Widget _buildTrackList(BuildContext context, ColorScheme colorScheme) { if (_isLoading) { return const SliverToBoxAdapter( @@ -693,7 +795,11 @@ class _PlaylistScreenState extends ConsumerState { return FilledButton.icon( onPressed: _tracks.isEmpty ? null : () => _confirmDownloadAll(context), icon: const Icon(Icons.download_rounded, size: 18), - label: Text(context.l10n.downloadAllCount(_tracks.length)), + label: Text( + context.l10n.downloadAllCount(_tracks.length), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), style: FilledButton.styleFrom( backgroundColor: Colors.white, foregroundColor: Colors.black87, @@ -1004,7 +1110,13 @@ class _PlaylistTrackItem extends ConsumerWidget { ], ], ), - trailing: TrackCollectionQuickActions(track: track), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + PreviewButton(track: track), + TrackCollectionQuickActions(track: track), + ], + ), onTap: () => _handleTap(context, ref, isQueued: isQueued), onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet( context, diff --git a/lib/widgets/motion_header_banner.dart b/lib/widgets/motion_header_banner.dart new file mode 100644 index 00000000..a37ca2de --- /dev/null +++ b/lib/widgets/motion_header_banner.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +final _log = AppLogger('MotionHeaderBanner'); + +class MotionHeaderBanner extends StatefulWidget { + final String videoUrl; + final Widget fallback; + final BoxFit fit; + final Alignment alignment; + + const MotionHeaderBanner({ + super.key, + required this.videoUrl, + required this.fallback, + this.fit = BoxFit.cover, + this.alignment = Alignment.topCenter, + }); + + @override + State createState() => _MotionHeaderBannerState(); +} + +class _MotionHeaderBannerState extends State + with WidgetsBindingObserver { + VideoPlayerController? _controller; + bool _ready = false; + bool _failed = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _initialize(); + } + + @override + void didUpdateWidget(MotionHeaderBanner oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.videoUrl != widget.videoUrl) { + _disposeController(); + _ready = false; + _failed = false; + _initialize(); + } + } + + Future _initialize() async { + final url = widget.videoUrl.trim(); + if (url.isEmpty) { + setState(() => _failed = true); + return; + } + + final controller = VideoPlayerController.networkUrl( + Uri.parse(url), + formatHint: VideoFormat.hls, + ); + _controller = controller; + try { + await controller.initialize(); + if (!mounted) { + await controller.dispose(); + return; + } + await controller.setVolume(0); + await controller.setLooping(true); + await controller.play(); + setState(() => _ready = true); + } catch (e) { + _log.w('Failed to play motion banner: $e'); + if (!mounted) return; + setState(() => _failed = true); + } + } + + void _disposeController() { + final controller = _controller; + _controller = null; + controller?.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + final controller = _controller; + if (controller == null || !_ready) return; + if (state == AppLifecycleState.resumed) { + controller.play(); + } else if (state == AppLifecycleState.paused || + state == AppLifecycleState.inactive) { + controller.pause(); + } + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _disposeController(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final controller = _controller; + final showVideo = _ready && !_failed && controller != null; + + return Stack( + fit: StackFit.expand, + children: [ + widget.fallback, + AnimatedOpacity( + opacity: showVideo ? 1.0 : 0.0, + duration: const Duration(milliseconds: 400), + child: showVideo + ? FittedBox( + fit: widget.fit, + alignment: widget.alignment, + clipBehavior: Clip.hardEdge, + child: SizedBox( + width: controller.value.size.width, + height: controller.value.size.height, + child: VideoPlayer(controller), + ), + ) + : const SizedBox.shrink(), + ), + ], + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index b57b8d8a..c0f27b48 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -65,6 +65,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: "2ba4bb2944baacbdd5372ff8254a8e7feb8c10d7739545e392f5605a8f618745" + url: "https://pub.dev" + source: hosted + version: "6.8.1" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: f5ff5b15620fbab8cb0849e9636c48e2b96c3f0f71723bbbe2ad3c761b205f05 + url: "https://pub.dev" + source: hosted + version: "5.3.0" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: "1ca553add991384ecf421b9569da850f3ab2472ffb83f6970b0416365abc51be" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: "15178b726b7cdee5364d0463c8d445630c4e0fb7d26612b73c767e7d25de9417" + url: "https://pub.dev" + source: hosted + version: "4.3.0" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "765f6f0e6dca55cb471c9483fc77700564b3484d19198aca4ebb5147c6c85acb" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: ae1e0103c865a03e273f6d13d97b93f5595eac09915729cd5e37ef96e2857319 + url: "https://pub.dev" + source: hosted + version: "5.3.0" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: a70ae82bba2dfcb6eb03dd4815d737a2d46d33ea5a96a03f535cfcaac490e413 + url: "https://pub.dev" + source: hosted + version: "4.4.1" boolean_selector: dependency: transitive description: @@ -257,6 +313,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" dart_style: dependency: transitive description: @@ -557,6 +621,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" http: dependency: "direct main" description: @@ -1362,6 +1434,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + video_player: + dependency: "direct main" + description: + name: video_player + sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: "8401adac6f33e78a0f255a75a7e7c9e2f9713ffcd87ff4e6c00d9bd92b5d99db" + url: "https://pub.dev" + source: hosted + version: "2.9.7" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: "76097729ef0c976937945afa53f1ca3afa9b50c9a95909ba347bcf93270466fd" + url: "https://pub.dev" + source: hosted + version: "2.10.0" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + sha256: e4ae5bc934b528e5b95c5e47be2812860186260cd3eed3ac62f5ed380fdd1613 + url: "https://pub.dev" + source: hosted + version: "6.8.0" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" + url: "https://pub.dev" + source: hosted + version: "2.4.0" vm_service: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8abc2f0c..ed15873c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,8 +59,11 @@ dependencies: ffmpeg_kit_flutter_new_full: ^2.1.0 open_filex: ^4.7.0 + video_player: ^2.8.0 + # Notifications flutter_local_notifications: ^22.0.1 + audioplayers: ^6.8.1 dev_dependencies: flutter_test: