mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-17 08:30:33 +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:
+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(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user