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.
This commit is contained in:
zarzet
2026-06-28 06:06:54 +07:00
parent 2e806a28b9
commit 95f5ae610e
10 changed files with 866 additions and 144 deletions
+22 -5
View File
@@ -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,
+80 -40
View File
@@ -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) {
+298 -92
View File
@@ -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<String, _CacheEntry> _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<String>? audioTraits;
final List<Track>? 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<AlbumScreen> {
String? _artistId;
String? _albumType;
int? _albumTotalTracks;
String? _headerVideoUrl;
String? _headerImageUrl;
List<String> _audioTraits = const [];
bool _tallHeader = false;
final ScrollController _scrollController = ScrollController();
String _legacyProviderIdFromResourceId(String value) {
@@ -139,6 +152,9 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
_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<AlbumScreen> {
}
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<AlbumScreen> {
}
}
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<AlbumScreen> {
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<AlbumScreen> {
_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<AlbumScreen> {
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<AlbumScreen> {
_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<AlbumScreen> {
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<Widget> _audioTraitInline() {
final traits = _audioTraits
.map((t) => t.toLowerCase().trim())
.where((t) => t.isNotEmpty)
.toSet();
if (traits.isEmpty) return const [];
bool has(List<String> keys) => keys.any(traits.contains);
final items = <Widget>[];
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 = <Widget>[];
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<String, dynamic> data, {
String? albumTypeFallback,
@@ -325,6 +471,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
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<AlbumScreen> {
),
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<AlbumScreen> {
ColorScheme colorScheme,
Color pageBackgroundColor,
) {
final expandedHeight = _calculateExpandedHeight(context);
final tracks = _tracks ?? [];
final artistName =
widget.artistName ??
@@ -383,6 +530,16 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
: 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<AlbumScreen> {
(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<AlbumScreen> {
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<AlbumScreen> {
],
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<AlbumScreen> {
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
Widget _buildAlbumFooter(
BuildContext context,
ColorScheme colorScheme,
List<Track> tracks,
) {
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
final totalSeconds = tracks.fold<int>(
0,
(sum, t) => sum + (t.duration > 0 ? t.duration : 0),
);
final totalMinutes = (totalSeconds / 60).round();
final lines = <String>[];
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,
+61 -1
View File
@@ -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<ArtistAlbum>? releases,
List<Track>? 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<ArtistAlbum>? releases;
final List<Track>? 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<ArtistAlbum>? albums;
final List<Track>? 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<ArtistScreen> {
List<ArtistAlbum>? _releases;
List<Track>? _topTracks;
String? _headerImageUrl;
String? _headerVideoUrl;
int? _monthlyListeners;
String? _error;
@@ -217,6 +225,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
_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<ArtistScreen> {
_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<ArtistScreen> {
_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<ArtistScreen> {
List<ArtistAlbum>? releases;
List<Track>? topTracks;
String? headerImage;
String? headerVideo;
int? listeners;
if (_directMetadataProviderId() != null) {
@@ -310,6 +322,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
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<ArtistScreen> {
}
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<ArtistScreen> {
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<ArtistScreen> {
releases: releases,
topTracks: topTracks,
headerImageUrl: finalHeaderImage,
headerVideoUrl: finalHeaderVideo,
monthlyListeners: finalListeners,
);
@@ -358,6 +377,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
_releases = releases;
_topTracks = topTracks;
_headerImageUrl = finalHeaderImage;
_headerVideoUrl = finalHeaderVideo;
_monthlyListeners = finalListeners;
_isLoadingDiscography = false;
});
@@ -410,6 +430,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
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<ArtistScreen> {
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<ArtistScreen> {
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,
+3
View File
@@ -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<HomeTab>
artistId: trackState.artistId!,
artistName: trackState.artistName!,
coverUrl: trackState.coverUrl,
headerImageUrl: trackState.headerImageUrl,
headerVideoUrl: trackState.headerVideoUrl,
albums: trackState.artistAlbums!,
extensionId: extensionId,
),
+38
View File
@@ -376,6 +376,7 @@ class _TrackItemWithStatus extends ConsumerWidget {
],
),
),
PreviewButton(track: track),
TrackCollectionQuickActions(track: track),
],
),
@@ -992,6 +993,9 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
String? _artistName;
String? _albumType;
int? _albumTotalTracks;
String? _headerVideoUrl;
String? _headerImageUrl;
List<String> _audioTraits = const [];
@override
void initState() {
@@ -1036,6 +1040,11 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
_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<ExtensionAlbumScreen> {
_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<ExtensionAlbumScreen> {
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<ExtensionAlbumScreen> {
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<Track>? _tracks;
bool _isLoading = true;
String? _error;
String? _headerVideoUrl;
@override
void initState() {
@@ -1222,8 +1245,14 @@ class _ExtensionPlaylistScreenState
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final playlistInfo = result['playlist_info'] as Map<String, dynamic>?;
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<ExtensionArtistScreen> {
List<ArtistAlbum>? _albums;
List<Track>? _topTracks;
String? _headerImageUrl;
String? _headerVideoUrl;
int? _monthlyListeners;
bool _isLoading = true;
String? _error;
@@ -1383,6 +1415,9 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
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<ExtensionArtistScreen> {
_albums = albums;
_topTracks = topTracks;
_headerImageUrl = headerImage;
_headerVideoUrl = headerVideo;
_monthlyListeners = listeners;
_isLoading = false;
});
@@ -1446,6 +1482,7 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
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<ExtensionArtistScreen> {
artistName: widget.artistName,
coverUrl: widget.coverUrl,
headerImageUrl: _headerImageUrl,
headerVideoUrl: _headerVideoUrl,
monthlyListeners: _monthlyListeners,
albums: _albums,
topTracks: _topTracks,
+118 -6
View File
@@ -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<Track> 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<PlaylistScreen> {
String? _error;
String? _resolvedPlaylistName;
String? _resolvedCoverUrl;
String? _resolvedHeaderVideoUrl;
List<Track> 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<PlaylistScreen> {
.map((t) => _parseTrack(t as Map<String, dynamic>))
.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<PlaylistScreen> {
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<PlaylistScreen> {
_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<PlaylistScreen> {
(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<PlaylistScreen> {
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<PlaylistScreen> {
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<PlaylistScreen> {
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<int>(
0,
(sum, t) => sum + (t.duration > 0 ? t.duration : 0),
);
final totalMinutes = (totalSeconds / 60).round();
final lines = <String>[];
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<PlaylistScreen> {
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,
+131
View File
@@ -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<MotionHeaderBanner> createState() => _MotionHeaderBannerState();
}
class _MotionHeaderBannerState extends State<MotionHeaderBanner>
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<void> _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(),
),
],
);
}
}
+112
View File
@@ -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:
+3
View File
@@ -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: