mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-02 19:05:57 +02:00
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:
+22
-5
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user