mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 05:12:00 +02:00
Add a selectable Mornye theme with shared glass controls, dialogs, sheets, notifications, and collection headers while retaining Material components. Update player artwork, lyrics transitions and highlighting, synchronized rolling time labels, volume controls, and compact navigation. Unify metadata, Library, search, and extension surfaces; preserve restored artwork and report extension load failures. Validation: full Flutter analyzer clean; 713 tests passed and 4 skipped; pinned Dart formatting and diff checks passed; signed ARM64 and ARM32 release APKs built and verified.
587 lines
19 KiB
Dart
587 lines
19 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/cupertino.dart' show CupertinoIcons;
|
|
import 'package:spotiflac_android/screens/track_history_snapshot.dart';
|
|
import 'package:spotiflac_android/widgets/album_detail_header.dart';
|
|
import 'package:spotiflac_android/screens/selection_mode_mixin.dart';
|
|
import 'package:spotiflac_android/widgets/collection_scaffold.dart';
|
|
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
|
|
import 'package:spotiflac_android/widgets/selection_action_button.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:spotiflac_android/services/platform_bridge.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/library_collections_provider.dart';
|
|
import 'package:spotiflac_android/utils/image_cache_utils.dart';
|
|
import 'package:spotiflac_android/utils/cover_art_utils.dart';
|
|
import 'package:spotiflac_android/utils/adaptive_layout.dart';
|
|
import 'package:spotiflac_android/utils/provider_resource_ids.dart';
|
|
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
|
|
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
|
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
|
import 'package:spotiflac_android/widgets/motion_header_banner.dart';
|
|
import 'package:spotiflac_android/widgets/track_list_tile.dart';
|
|
import 'package:spotiflac_android/widgets/track_detail_actions.dart';
|
|
import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart';
|
|
import 'package:spotiflac_android/widgets/error_card.dart';
|
|
import 'package:spotiflac_android/widgets/downloadable_cover.dart';
|
|
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
|
import 'package:spotiflac_android/widgets/mornye_artist_header.dart';
|
|
|
|
class PlaylistScreen extends ConsumerStatefulWidget {
|
|
final String playlistName;
|
|
final String? coverUrl;
|
|
final String? headerVideoUrl;
|
|
final List<Track> tracks;
|
|
final String? playlistId;
|
|
final String? metadataProviderId;
|
|
final String? recommendedService;
|
|
|
|
const PlaylistScreen({
|
|
super.key,
|
|
required this.playlistName,
|
|
this.coverUrl,
|
|
this.headerVideoUrl,
|
|
required this.tracks,
|
|
this.playlistId,
|
|
this.metadataProviderId,
|
|
this.recommendedService,
|
|
});
|
|
|
|
@override
|
|
ConsumerState<PlaylistScreen> createState() => _PlaylistScreenState();
|
|
}
|
|
|
|
class _PlaylistScreenState extends ConsumerState<PlaylistScreen>
|
|
with
|
|
SelectionModeMixin<PlaylistScreen>,
|
|
CollapsingHeaderScrollMixin<PlaylistScreen> {
|
|
final _historySnapshot = TrackHistorySnapshot();
|
|
List<Track>? _fetchedTracks;
|
|
bool _isLoading = false;
|
|
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;
|
|
|
|
/// Playlists can legitimately contain the same track twice, so selection is
|
|
/// keyed by position as well as id (matching the album screen).
|
|
String _trackSelectionId(Track track, int index) => '${track.id}#$index';
|
|
|
|
List<Track> get _selectedTracks => [
|
|
for (var index = 0; index < _tracks.length; index++)
|
|
if (selectedIds.contains(_trackSelectionId(_tracks[index], index)))
|
|
_tracks[index],
|
|
];
|
|
|
|
Widget _buildSelectionBottomBar(
|
|
BuildContext context,
|
|
ColorScheme colorScheme,
|
|
double bottomPadding,
|
|
) {
|
|
final selectedCount = selectedIds.length;
|
|
final allSelected = _tracks.isNotEmpty && selectedCount == _tracks.length;
|
|
|
|
return SelectionBottomBar(
|
|
selectedCount: selectedCount,
|
|
allSelected: allSelected,
|
|
onClose: exitSelectionMode,
|
|
onToggleSelectAll: () {
|
|
if (allSelected) {
|
|
exitSelectionMode();
|
|
} else {
|
|
selectAll([
|
|
for (var index = 0; index < _tracks.length; index++)
|
|
_trackSelectionId(_tracks[index], index),
|
|
]);
|
|
}
|
|
},
|
|
bottomPadding: bottomPadding,
|
|
children: [
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: SelectionActionButton(
|
|
icon: Icons.download_rounded,
|
|
label: '${context.l10n.dialogDownload} ($selectedCount)',
|
|
onPressed: selectedCount == 0 ? null : _downloadSelected,
|
|
colorScheme: colorScheme,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Future<void> _downloadSelected() async {
|
|
final selected = _selectedTracks;
|
|
if (selected.isEmpty) return;
|
|
await queueTracksSkippingDownloaded(
|
|
context,
|
|
ref,
|
|
selected,
|
|
artistNameForPicker: _playlistName,
|
|
recommendedService: widget.recommendedService,
|
|
forceQualityPicker: true,
|
|
onQueued: exitSelectionMode,
|
|
);
|
|
}
|
|
|
|
String? _metadataProviderId(String playlistId) {
|
|
final providerId = resolvePreferredMetadataProviderId(
|
|
widget.metadataProviderId,
|
|
playlistId,
|
|
);
|
|
if (providerId == null) return null;
|
|
final effective = resolveEffectiveMetadataProvider(
|
|
providerId,
|
|
ref.read(extensionProvider),
|
|
);
|
|
return effective.isEmpty ? null : effective;
|
|
}
|
|
|
|
String _metadataResourceId(String providerId, String playlistId) {
|
|
return stripPrefixedResourceId(playlistId);
|
|
}
|
|
|
|
String? _recommendedDownloadService() {
|
|
final explicit = widget.recommendedService;
|
|
if (explicit != null && explicit.isNotEmpty) {
|
|
return explicit;
|
|
}
|
|
|
|
final playlistId = widget.playlistId;
|
|
if (playlistId != null) {
|
|
final providerId = _metadataProviderId(playlistId);
|
|
if (providerId != null && providerId.isNotEmpty) {
|
|
return resolveEffectiveDownloadService(
|
|
providerId,
|
|
ref.read(extensionProvider),
|
|
);
|
|
}
|
|
}
|
|
|
|
final source = _tracks.firstOrNull?.source;
|
|
if (source != null && source.isNotEmpty) {
|
|
return source;
|
|
}
|
|
|
|
final trackId = _tracks.firstOrNull?.id ?? '';
|
|
final trackProviderId = legacyProviderIdFromResourceId(trackId);
|
|
if (trackProviderId != null) {
|
|
return resolveEffectiveDownloadService(
|
|
trackProviderId,
|
|
ref.read(extensionProvider),
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_fetchTracksIfNeeded();
|
|
}
|
|
|
|
Future<void> _fetchTracksIfNeeded() async {
|
|
if (widget.tracks.isNotEmpty || widget.playlistId == null) return;
|
|
|
|
setState(() {
|
|
_isLoading = true;
|
|
_error = null;
|
|
});
|
|
|
|
try {
|
|
String playlistId = widget.playlistId!;
|
|
late final Map<String, dynamic> result;
|
|
final providerId = _metadataProviderId(playlistId);
|
|
if (providerId != null) {
|
|
result = await PlatformBridge.getProviderMetadata(
|
|
providerId,
|
|
'playlist',
|
|
_metadataResourceId(providerId, playlistId),
|
|
);
|
|
} else {
|
|
result = await PlatformBridge.getProviderMetadata(
|
|
'deezer',
|
|
'playlist',
|
|
playlistId,
|
|
);
|
|
}
|
|
if (!mounted) return;
|
|
|
|
final playlistInfo = result['playlist_info'] as Map<String, dynamic>?;
|
|
final owner = playlistInfo?['owner'] as Map<String, dynamic>?;
|
|
final resolvedPlaylistName =
|
|
(playlistInfo?['name'] ?? owner?['name'])?.toString() ??
|
|
_playlistName;
|
|
|
|
final trackList = result['track_list'] as List<dynamic>? ?? [];
|
|
final tracks = trackList
|
|
.map(
|
|
(t) => Track.fromBackendMap(
|
|
t as Map<String, dynamic>,
|
|
playlistName: resolvedPlaylistName,
|
|
),
|
|
)
|
|
.toList();
|
|
|
|
final headerVideo = playlistInfo?['header_video']?.toString();
|
|
|
|
setState(() {
|
|
_fetchedTracks = tracks;
|
|
_resolvedPlaylistName = resolvedPlaylistName;
|
|
_resolvedCoverUrl = (playlistInfo?['images'] ?? owner?['images'])
|
|
?.toString();
|
|
_resolvedHeaderVideoUrl =
|
|
(headerVideo != null && headerVideo.isNotEmpty)
|
|
? headerVideo
|
|
: _resolvedHeaderVideoUrl;
|
|
_isLoading = false;
|
|
});
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_error = context.friendlyError(e);
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (context.isMornye) {
|
|
return MornyeArtistSurface(
|
|
imageSource: _coverUrl,
|
|
neutralActions: true,
|
|
child: Builder(builder: _buildPage),
|
|
);
|
|
}
|
|
return _buildPage(context);
|
|
}
|
|
|
|
Widget _buildPage(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final bottomPadding = isSelectionMode
|
|
? MediaQuery.paddingOf(context).bottom
|
|
: 0.0;
|
|
|
|
pruneSelection({
|
|
for (var index = 0; index < _tracks.length; index++)
|
|
_trackSelectionId(_tracks[index], index),
|
|
});
|
|
|
|
return CollectionScaffold(
|
|
scrollController: scrollController,
|
|
isSelectionMode: isSelectionMode,
|
|
onExitSelectionMode: exitSelectionMode,
|
|
selectionBar: _buildSelectionBottomBar(
|
|
context,
|
|
colorScheme,
|
|
bottomPadding,
|
|
),
|
|
appBar: _buildAppBar(context, colorScheme),
|
|
slivers: [
|
|
_buildTrackList(context, colorScheme),
|
|
_buildPlaylistFooter(context, colorScheme),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
|
final expandedHeight = calculateExpandedHeight(context);
|
|
final cacheWidth = coverCacheWidthForViewport(context);
|
|
final motionUrl = _headerVideoUrl;
|
|
final hasMotion =
|
|
motionUrl != null &&
|
|
motionUrl.trim().isNotEmpty &&
|
|
Uri.tryParse(motionUrl)?.hasAuthority == true;
|
|
Widget playlistPlaceholder({double? size}) {
|
|
return Container(
|
|
color: colorScheme.surfaceContainerHighest,
|
|
child: Icon(
|
|
Icons.playlist_play,
|
|
size: size ?? 80,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
);
|
|
}
|
|
|
|
final Widget coverImage = _coverUrl != null
|
|
? CachedCoverImage(
|
|
imageUrl: highResCoverUrl(_coverUrl) ?? _coverUrl!,
|
|
fit: BoxFit.cover,
|
|
memCacheWidth: cacheWidth,
|
|
placeholder: (_, _) => Container(color: colorScheme.surface),
|
|
errorWidget: (_, _, _) => Container(color: colorScheme.surface),
|
|
)
|
|
: playlistPlaceholder();
|
|
|
|
return AlbumDetailHeader(
|
|
title: _playlistName,
|
|
immersive: context.isMornye,
|
|
squareArtwork: !hasMotion,
|
|
expandedHeight: expandedHeight,
|
|
showTitleInAppBar: showTitleInAppBar,
|
|
background: hasMotion
|
|
? MotionHeaderBanner(videoUrl: motionUrl, fallback: coverImage)
|
|
: coverImage,
|
|
paletteSource: _coverUrl,
|
|
blurAndScrimBackground: !hasMotion && _coverUrl != null,
|
|
coverBuilder: hasMotion
|
|
? null
|
|
: (context, coverSize) => _coverUrl != null
|
|
? DownloadableCover(
|
|
coverUrl: highResCoverUrl(_coverUrl) ?? _coverUrl,
|
|
baseName: _playlistName,
|
|
child: CachedCoverImage(
|
|
imageUrl: highResCoverUrl(_coverUrl) ?? _coverUrl!,
|
|
fit: BoxFit.cover,
|
|
memCacheWidth: cacheWidth,
|
|
placeholder: (_, _) => playlistPlaceholder(),
|
|
errorWidget: (_, _, _) => playlistPlaceholder(size: 48),
|
|
),
|
|
)
|
|
: playlistPlaceholder(size: 48),
|
|
meta: HeaderMetaRow(
|
|
items: [
|
|
HeaderMetaItem(
|
|
context.l10n.tracksCount(_tracks.length),
|
|
icon: Icons.playlist_play,
|
|
),
|
|
],
|
|
),
|
|
actions: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
if (context.isMornye) ...[
|
|
_buildLoveAllButton(),
|
|
const SizedBox(width: 16),
|
|
Flexible(
|
|
child: SizedBox(
|
|
width: 172,
|
|
child: _buildDownloadAllCenterButton(context),
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
_buildAddToPlaylistButton(context),
|
|
] else ...[
|
|
_buildLoveAllButton(),
|
|
const SizedBox(width: 12),
|
|
Flexible(child: _buildDownloadAllCenterButton(context)),
|
|
const SizedBox(width: 12),
|
|
_buildAddToPlaylistButton(context),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPlaylistFooter(BuildContext context, ColorScheme colorScheme) =>
|
|
buildTrackListFooter(
|
|
context,
|
|
colorScheme,
|
|
_tracks,
|
|
excludeEpochReleaseDate: true,
|
|
);
|
|
|
|
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme) {
|
|
if (_isLoading) {
|
|
return SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: context.isMornye
|
|
? EdgeInsets.zero
|
|
: const EdgeInsets.all(16),
|
|
child: const TrackListSkeleton(itemCount: 8),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (_error != null) {
|
|
return SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: ErrorCard(
|
|
error: _error!,
|
|
colorScheme: colorScheme,
|
|
onRetry: _fetchTracksIfNeeded,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (_tracks.isEmpty) {
|
|
return SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Center(
|
|
child: Text(
|
|
context.l10n.errorNoTracksFound,
|
|
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
_historySnapshot.update(_tracks);
|
|
final historyLookups = _historySnapshot.lookups;
|
|
final existingHistoryKeys = ref.watch(
|
|
downloadHistoryVisibleBatchExistsProvider(_historySnapshot.request),
|
|
);
|
|
return SliverPadding(
|
|
padding: EdgeInsets.symmetric(horizontal: wideListInset(context)),
|
|
sliver: SliverList(
|
|
delegate: SliverChildBuilderDelegate((context, index) {
|
|
final track = _tracks[index];
|
|
final selectionId = _trackSelectionId(track, index);
|
|
final isInHistory = existingHistoryKeys.contains(
|
|
historyLookups[index].lookupKey,
|
|
);
|
|
return KeyedSubtree(
|
|
key: ValueKey(track.id),
|
|
child: StaggeredListItem(
|
|
index: index,
|
|
child: TrackListTile(
|
|
track: track,
|
|
isInHistory: isInHistory,
|
|
isSelectionMode: isSelectionMode,
|
|
isSelected: selectedIds.contains(selectionId),
|
|
onToggleSelection: () => toggleSelection(selectionId),
|
|
onEnterSelectionMode: () => enterSelectionMode(selectionId),
|
|
onDownload: ({bool forceQualityPicker = false}) =>
|
|
_downloadTrack(
|
|
context,
|
|
track,
|
|
playlistPosition: index + 1,
|
|
forceQualityPicker: forceQualityPicker,
|
|
),
|
|
leading: track.coverUrl != null
|
|
? DownloadableCover(
|
|
coverUrl: track.coverUrl,
|
|
baseName: '${track.artistName} - ${track.name}',
|
|
enabled: !isSelectionMode,
|
|
child: CachedCoverImage(
|
|
imageUrl: track.coverUrl!,
|
|
width: 48,
|
|
height: 48,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
)
|
|
: Container(
|
|
width: 48,
|
|
height: 48,
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(
|
|
context,
|
|
).colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Icon(
|
|
Icons.music_note,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}, childCount: _tracks.length),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _downloadTrack(
|
|
BuildContext context,
|
|
Track track, {
|
|
int? playlistPosition,
|
|
bool forceQualityPicker = false,
|
|
}) {
|
|
downloadSingleTrack(
|
|
context,
|
|
ref,
|
|
track,
|
|
recommendedService: _recommendedDownloadService(),
|
|
playlistName: _playlistName,
|
|
playlistPosition: playlistPosition,
|
|
forceQualityPicker: forceQualityPicker,
|
|
);
|
|
}
|
|
|
|
Widget _buildLoveAllButton() {
|
|
final collectionsState = ref.watch(libraryCollectionsProvider);
|
|
final allLoved =
|
|
_tracks.isNotEmpty && _tracks.every((t) => collectionsState.isLoved(t));
|
|
|
|
return HeaderCircleButton(
|
|
icon: allLoved ? Icons.favorite : Icons.favorite_border,
|
|
tonal: true,
|
|
iconColor: allLoved ? Theme.of(context).colorScheme.error : null,
|
|
tooltip: allLoved
|
|
? context.l10n.trackOptionRemoveFromLoved
|
|
: context.l10n.tooltipLoveAll,
|
|
onPressed: _tracks.isEmpty ? null : () => _loveAll(_tracks),
|
|
);
|
|
}
|
|
|
|
Widget _buildDownloadAllCenterButton(BuildContext context) {
|
|
return HeaderFilledButton(
|
|
icon: context.isMornye
|
|
? CupertinoIcons.arrow_down_circle_fill
|
|
: Icons.download_rounded,
|
|
tonal: true,
|
|
label: context.isMornye
|
|
? context.l10n.dialogDownload
|
|
: context.l10n.downloadAllCount(_tracks.length),
|
|
onPressed: _tracks.isEmpty ? null : () => _confirmDownloadAll(context),
|
|
);
|
|
}
|
|
|
|
Widget _buildAddToPlaylistButton(BuildContext context) {
|
|
return HeaderCircleButton(
|
|
icon: Icons.playlist_add,
|
|
tonal: true,
|
|
tooltip: context.l10n.tooltipAddToPlaylist,
|
|
onPressed: _tracks.isEmpty
|
|
? null
|
|
: () => showAddTracksToPlaylistSheet(
|
|
context,
|
|
ref,
|
|
_tracks,
|
|
playlistNamePrefill: widget.playlistName,
|
|
),
|
|
);
|
|
}
|
|
|
|
void _confirmDownloadAll(BuildContext context) {
|
|
confirmDownloadAllDialog(
|
|
context,
|
|
_tracks.length,
|
|
() => _downloadAll(context),
|
|
);
|
|
}
|
|
|
|
Future<void> _loveAll(List<Track> tracks) =>
|
|
loveAllTracks(context, ref, tracks);
|
|
|
|
void _downloadAll(BuildContext context) {
|
|
_downloadTracks(context, _tracks);
|
|
}
|
|
|
|
Future<void> _downloadTracks(BuildContext context, List<Track> tracks) async {
|
|
await queueTracksSkippingDownloaded(
|
|
context,
|
|
ref,
|
|
tracks,
|
|
artistNameForPicker: _playlistName,
|
|
recommendedService: _recommendedDownloadService(),
|
|
playlistName: _playlistName,
|
|
resolveDefaultService: false,
|
|
);
|
|
}
|
|
}
|