Files
SpotiFLAC-Mobile/lib/screens/album_screen.dart
T
zarzet 2d9df7e1bc fix(ui): hero continuity for motion (Apple Music) headers
Albums/artists with a motion banner have no square cover, so the Hero
had no destination: the open flight ended nowhere and there was no
return flight. The Hero now rides the full-bleed banner, flying the
still cover image as the shuttle so the video doesn't restart mid-air.
2026-07-14 09:09:57 +07:00

648 lines
22 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/recent_access_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/image_cache_utils.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/utils/cover_art_utils.dart';
import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart';
import 'package:spotiflac_android/widgets/error_card.dart';
import 'package:spotiflac_android/widgets/album_detail_header.dart';
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
import 'package:spotiflac_android/utils/provider_resource_ids.dart';
import 'package:spotiflac_android/utils/ttl_cache.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/widgets/cross_extension_share_sheet.dart';
import 'package:spotiflac_android/widgets/track_list_tile.dart';
import 'package:spotiflac_android/widgets/motion_header_banner.dart';
import 'package:spotiflac_android/widgets/track_detail_actions.dart';
class _AlbumCache {
static final _cache = TtlCache<List<Track>>(const Duration(minutes: 10));
static List<Track>? get(String albumId) => _cache.get(albumId);
static void set(String albumId, List<Track> tracks) =>
_cache.set(albumId, tracks);
}
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;
final String? artistName;
/// Shared-element tag for the header cover (see [AlbumDetailHeader.heroTag]).
final Object? heroTag;
const AlbumScreen({
super.key,
required this.albumId,
required this.albumName,
this.coverUrl,
this.headerVideoUrl,
this.headerImageUrl,
this.audioTraits,
this.tracks,
this.extensionId,
this.artistId,
this.artistName,
this.heroTag,
});
@override
ConsumerState<AlbumScreen> createState() => _AlbumScreenState();
}
class _AlbumScreenState extends ConsumerState<AlbumScreen>
with CollapsingHeaderScrollMixin<AlbumScreen> {
List<Track>? _tracks;
bool _isLoading = false;
String? _error;
String? _artistId;
String? _albumType;
int? _albumTotalTracks;
String? _headerVideoUrl;
String? _headerImageUrl;
List<String> _audioTraits = const [];
String _effectiveMetadataProviderIdFromAlbumId() {
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
return widget.extensionId!;
}
return resolveEffectiveMetadataProvider(
legacyProviderIdFromResourceId(widget.albumId) ?? 'spotify',
ref.read(extensionProvider),
);
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final providerId = _effectiveMetadataProviderIdFromAlbumId();
ref
.read(recentAccessProvider.notifier)
.recordAlbumAccess(
id: widget.albumId,
name: widget.albumName,
artistName:
widget.artistName ??
widget.tracks?.firstOrNull?.albumArtist ??
widget.tracks?.firstOrNull?.artistName,
imageUrl: widget.coverUrl,
providerId: providerId,
);
});
if (widget.tracks != null && widget.tracks!.isNotEmpty) {
_tracks = widget.tracks;
} else {
_tracks = _AlbumCache.get(widget.albumId);
}
_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();
}
}
Future<void> _fetchTracks() async {
setState(() => _isLoading = true);
try {
final directProviderId = _directMetadataProviderId();
if (directProviderId != null) {
final metadata = await PlatformBridge.getProviderMetadata(
directProviderId,
'album',
_metadataResourceId(directProviderId),
);
_applyAlbumMetadata(
metadata['track_list'] as List<dynamic>,
metadata['album_info'] as Map<String, dynamic>?,
);
return;
} else {
final url = 'https://open.spotify.com/album/${widget.albumId}';
final result = await PlatformBridge.handleURLWithExtension(url);
if (result == null || result['tracks'] == null) {
throw StateError('Failed to load album metadata from extension');
}
_applyAlbumMetadata(
result['tracks'] as List<dynamic>,
result['album'] as Map<String, dynamic>?,
fallbackSource: result,
);
return;
}
} catch (e) {
if (mounted) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
}
/// Parses tracks + header metadata from either the direct-provider payload
/// (`albumInfo` only) or the extension payload (`albumInfo` falling back to
/// top-level [fallbackSource] keys), then updates state and the TTL cache.
void _applyAlbumMetadata(
List<dynamic> trackList,
Map<String, dynamic>? albumInfo, {
Map<String, dynamic>? fallbackSource,
}) {
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString();
final albumType = normalizeOptionalString(
albumInfo?['album_type']?.toString(),
);
final totalTracks = albumInfo?['total_tracks'] as int?;
final headerVideo =
(albumInfo?['header_video'] ?? fallbackSource?['header_video'])
?.toString();
final headerImage =
(albumInfo?['header_image'] ?? fallbackSource?['header_image'])
?.toString();
final audioTraits =
((albumInfo?['audio_traits'] ?? fallbackSource?['audio_traits'])
as List?)
?.map((e) => e.toString())
.toList();
final tracks = trackList
.map(
(t) => _parseTrack(
t as Map<String, dynamic>,
albumTypeFallback: albumType,
totalTracksFallback: totalTracks,
),
)
.toList();
_AlbumCache.set(widget.albumId, tracks);
if (mounted) {
setState(() {
_tracks = tracks;
_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;
});
}
}
String? _directMetadataProviderId() {
final providerId = _effectiveMetadataProviderIdFromAlbumId();
return providerId.isEmpty ? null : providerId;
}
String _metadataResourceId(String providerId) {
return stripPrefixedResourceId(widget.albumId);
}
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(HeaderMetaItem('Dolby Atmos', icon: Icons.surround_sound));
} else if (has(['spatial'])) {
items.add(HeaderMetaItem('Spatial Audio', icon: Icons.surround_sound));
}
if (has(['hi-res-lossless', 'hi_res_lossless', 'hires-lossless'])) {
items.add(HeaderMetaItem('Hi-Res Lossless', icon: Icons.graphic_eq));
} else if (has(['lossless'])) {
items.add(HeaderMetaItem('Lossless', icon: Icons.graphic_eq));
}
return items;
}
Widget _buildHeaderMeta(BuildContext context, String? releaseDate) {
final items = <Widget>[];
final year = _releaseYear(releaseDate);
if (year != null) items.add(HeaderMetaItem(year));
items.addAll(_audioTraitInline());
return ConstrainedBox(
constraints: const BoxConstraints(minHeight: 20),
child: HeaderMetaRow(items: 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,
int? totalTracksFallback,
}) {
return Track(
id: data['spotify_id'] as String? ?? '',
name: data['name'] as String? ?? '',
artistName: data['artists'] as String? ?? '',
albumName: data['album_name'] as String? ?? '',
albumArtist: data['album_artist'] as String?,
artistId:
(data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId,
albumId: data['album_id']?.toString() ?? widget.albumId,
coverUrl: normalizeCoverReference(data['images']?.toString()),
isrc: data['isrc'] as String?,
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
totalDiscs: data['total_discs'] as int?,
releaseDate: data['release_date'] as String?,
albumType:
normalizeOptionalString(data['album_type']?.toString()) ??
albumTypeFallback ??
_albumType,
totalTracks:
data['total_tracks'] as int? ??
totalTracksFallback ??
_albumTotalTracks,
composer: data['composer']?.toString(),
audioQuality: data['audio_quality']?.toString(),
audioModes: data['audio_modes']?.toString(),
previewUrl: data['preview_url']?.toString(),
explicit: parseExplicitFlag(data['explicit']),
);
}
String? _recommendedDownloadService() {
return _directMetadataProviderId();
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final tracks = _tracks ?? [];
final pageBackgroundColor = colorScheme.surface;
final bottomInset = context.navBarBottomInset;
return Scaffold(
backgroundColor: pageBackgroundColor,
body: CustomScrollView(
controller: scrollController,
slivers: [
_buildAppBar(context, colorScheme, pageBackgroundColor),
if (_isLoading)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: AlbumTrackListSkeleton(itemCount: 10),
),
),
if (_error != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: ErrorCard(error: _error!, colorScheme: colorScheme),
),
),
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
_buildTrackList(context, colorScheme, tracks),
_buildAlbumFooter(context, colorScheme, tracks),
],
SliverToBoxAdapter(child: SizedBox(height: 32 + bottomInset)),
],
),
);
}
Widget _buildAppBar(
BuildContext context,
ColorScheme colorScheme,
Color pageBackgroundColor,
) {
final tracks = _tracks ?? [];
final artistName =
widget.artistName ??
(tracks.isNotEmpty
? (tracks.first.albumArtist ?? tracks.first.artistName)
: 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;
final expandedHeight = calculateExpandedHeight(context);
final cacheWidth = coverCacheWidthForViewport(context);
final headerBgUrl =
_headerImageUrl ?? widget.headerImageUrl ?? widget.coverUrl;
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,
),
);
Widget background = hasMotion
? MotionHeaderBanner(videoUrl: motionUrl, fallback: headerBgImage())
: headerBgImage();
// With a motion banner there is no square cover, so the Hero rides the
// full-bleed background instead; the shuttle flies the still image (a
// shuttle-built banner would restart the video mid-flight).
if (hasMotion && widget.heroTag != null) {
background = Hero(
tag: widget.heroTag!,
flightShuttleBuilder: (_, _, _, _, _) => headerBgImage(),
child: background,
);
}
return AlbumDetailHeader(
title: widget.albumName,
expandedHeight: expandedHeight,
showTitleInAppBar: showTitleInAppBar,
backgroundColor: pageBackgroundColor,
heroTag: widget.heroTag,
background: background,
blurAndScrimBackground: showSquareCover,
coverBuilder: showSquareCover
? (context, coverSize) => coverThumbUrl != null
? 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,
),
),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
size: 48,
color: colorScheme.onSurfaceVariant,
),
)
: null,
subtitle: (artistName != null && artistName.isNotEmpty)
? ClickableArtistName(
artistName: artistName,
artistId: _artistId,
coverUrl: widget.coverUrl,
extensionId: widget.extensionId,
style: TextStyle(
color: colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: null,
meta: _buildHeaderMeta(context, releaseDate),
actions: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildLoveAllButton(),
const SizedBox(width: 12),
Flexible(
child: FilledButton.icon(
onPressed: tracks.isEmpty ? null : () => _downloadAll(context),
icon: const Icon(Icons.download, size: 18),
label: Text(
context.l10n.downloadAllCount(tracks.length),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
disabledBackgroundColor: Colors.white.withValues(alpha: 0.45),
disabledForegroundColor: Colors.black54,
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
),
),
const SizedBox(width: 12),
_buildAddToPlaylistButton(context),
],
),
appBarActions: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
tooltip: context.l10n.openInOtherServices,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: const Icon(Icons.open_in_new_rounded, color: Colors.white),
),
onPressed: () => _showShareSheet(context, tracks, artistName),
),
),
],
);
}
Widget _buildAlbumFooter(
BuildContext context,
ColorScheme colorScheme,
List<Track> tracks,
) => buildTrackListFooter(context, colorScheme, tracks);
Widget _buildTrackList(
BuildContext context,
ColorScheme colorScheme,
List<Track> tracks,
) {
final historyLookups = tracks
.map(historyLookupForTrack)
.toList(growable: false);
final existingHistoryKeys = ref
.watch(
downloadHistoryBatchExistsProvider(
HistoryBatchLookupRequest(historyLookups),
),
)
.maybeWhen(data: (keys) => keys, orElse: () => const <String>{});
return SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final track = tracks[index];
final isInHistory = existingHistoryKeys.contains(
historyLookups[index].lookupKey,
);
return KeyedSubtree(
key: ValueKey(track.id),
child: StaggeredListItem(
index: index,
child: TrackListTile(
track: track,
isInHistory: isInHistory,
onDownload: () => _downloadTrack(context, track),
clickableArtist: true,
leading: SizedBox(
width: 32,
child: Center(
child: Text(
'${track.trackNumber ?? 0}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
),
),
),
),
);
}, childCount: tracks.length),
);
}
void _downloadTrack(BuildContext context, Track track) {
downloadSingleTrack(
context,
ref,
track,
recommendedService: _recommendedDownloadService(),
);
}
Future<void> _downloadAll(BuildContext context) async {
final tracks = _tracks;
if (tracks == null || tracks.isEmpty) return;
await queueTracksSkippingDownloaded(
context,
ref,
tracks,
artistNameForPicker: widget.albumName,
recommendedService: _recommendedDownloadService(),
);
}
Widget _buildLoveAllButton() {
final collectionsState = ref.watch(libraryCollectionsProvider);
final tracks = _tracks;
final allLoved =
tracks != null &&
tracks.isNotEmpty &&
tracks.every((t) => collectionsState.isLoved(t));
return HeaderCircleButton(
icon: allLoved ? Icons.favorite : Icons.favorite_border,
iconColor: allLoved ? Colors.redAccent : Colors.white,
tooltip: allLoved
? context.l10n.trackOptionRemoveFromLoved
: context.l10n.tooltipLoveAll,
onPressed: tracks == null || tracks.isEmpty
? null
: () => _loveAll(tracks),
);
}
Widget _buildAddToPlaylistButton(BuildContext context) {
return HeaderCircleButton(
icon: Icons.add,
tooltip: context.l10n.tooltipAddToPlaylist,
onPressed: _tracks == null || _tracks!.isEmpty
? null
: () => showAddTracksToPlaylistSheet(context, ref, _tracks!),
);
}
void _showShareSheet(
BuildContext context,
List<Track> tracks,
String? artistName,
) {
final sourceExtensionId = _directMetadataProviderId() ?? '';
final resolvedArtists =
artistName ??
tracks.firstOrNull?.albumArtist ??
tracks.firstOrNull?.artistName ??
'';
CrossExtensionShareSheet.show(
context,
name: widget.albumName,
artists: resolvedArtists,
type: 'album',
sourceExtensionId: sourceExtensionId,
);
}
Future<void> _loveAll(List<Track> tracks) =>
loveAllTracks(context, ref, tracks);
}