mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-29 15:28:48 +02:00
- shell switches to a NavigationRail at >=600dp (same destinations, badges included); the bottom bar and its blur stay phone-only while the mini player remains anchored bottom in both modes - now-playing gains a two-pane landscape layout (artwork left, metadata/controls right) built from the same widget pieces - track lists on album/playlist/library-folder screens and search result sections center at 720dp via wideListInset instead of stretching across the full tablet width; explore carousels stay full-bleed intentionally
447 lines
14 KiB
Dart
447 lines
14 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:spotiflac_android/widgets/album_detail_header.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/nav_bar_inset.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';
|
|
|
|
class PlaylistScreen extends ConsumerStatefulWidget {
|
|
final String playlistName;
|
|
final String? coverUrl;
|
|
final String? headerVideoUrl;
|
|
final List<Track> tracks;
|
|
final String? playlistId;
|
|
final String? recommendedService;
|
|
|
|
const PlaylistScreen({
|
|
super.key,
|
|
required this.playlistName,
|
|
this.coverUrl,
|
|
this.headerVideoUrl,
|
|
required this.tracks,
|
|
this.playlistId,
|
|
this.recommendedService,
|
|
});
|
|
|
|
@override
|
|
ConsumerState<PlaylistScreen> createState() => _PlaylistScreenState();
|
|
}
|
|
|
|
class _PlaylistScreenState extends ConsumerState<PlaylistScreen>
|
|
with CollapsingHeaderScrollMixin<PlaylistScreen> {
|
|
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;
|
|
|
|
String? _metadataProviderId(String playlistId) {
|
|
final providerId = legacyProviderIdFromResourceId(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 trackList = result['track_list'] as List<dynamic>? ?? [];
|
|
final tracks = trackList
|
|
.map((t) => Track.fromBackendMap(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) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_error = e.toString();
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return Scaffold(
|
|
body: CustomScrollView(
|
|
controller: scrollController,
|
|
slivers: [
|
|
_buildAppBar(context, colorScheme),
|
|
_buildTrackList(context, colorScheme),
|
|
_buildPlaylistFooter(context, colorScheme),
|
|
SliverToBoxAdapter(
|
|
child: SizedBox(height: 32 + context.navBarBottomInset),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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,
|
|
expandedHeight: expandedHeight,
|
|
showTitleInAppBar: showTitleInAppBar,
|
|
background: hasMotion
|
|
? MotionHeaderBanner(videoUrl: motionUrl, fallback: coverImage)
|
|
: coverImage,
|
|
blurAndScrimBackground: !hasMotion && _coverUrl != null,
|
|
coverBuilder: hasMotion
|
|
? null
|
|
: (context, coverSize) => _coverUrl != null
|
|
? 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: [
|
|
_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 const SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(16),
|
|
child: TrackListSkeleton(itemCount: 8),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (_error != null) {
|
|
return SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: ErrorCard(error: _error!, colorScheme: colorScheme),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (_tracks.isEmpty) {
|
|
return SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Center(
|
|
child: Text(
|
|
context.l10n.errorNoTracksFound,
|
|
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
final historyLookups = _tracks
|
|
.map(historyLookupForTrack)
|
|
.toList(growable: false);
|
|
final existingHistoryKeys = ref
|
|
.watch(
|
|
downloadHistoryBatchExistsProvider(
|
|
HistoryBatchLookupRequest(historyLookups),
|
|
),
|
|
)
|
|
.maybeWhen(data: (keys) => keys, orElse: () => const <String>{});
|
|
return SliverPadding(
|
|
padding: EdgeInsets.symmetric(horizontal: wideListInset(context)),
|
|
sliver: 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, playlistPosition: index + 1),
|
|
leading: track.coverUrl != null
|
|
? 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,
|
|
}) {
|
|
downloadSingleTrack(
|
|
context,
|
|
ref,
|
|
track,
|
|
recommendedService: _recommendedDownloadService(),
|
|
playlistName: _playlistName,
|
|
playlistPosition: playlistPosition,
|
|
);
|
|
}
|
|
|
|
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,
|
|
iconColor: allLoved ? Colors.redAccent : Colors.white,
|
|
tooltip: allLoved
|
|
? context.l10n.trackOptionRemoveFromLoved
|
|
: context.l10n.tooltipLoveAll,
|
|
onPressed: _tracks.isEmpty ? null : () => _loveAll(_tracks),
|
|
);
|
|
}
|
|
|
|
Widget _buildDownloadAllCenterButton(BuildContext context) {
|
|
return FilledButton.icon(
|
|
onPressed: _tracks.isEmpty ? null : () => _confirmDownloadAll(context),
|
|
icon: const Icon(Icons.download_rounded, size: 18),
|
|
label: Text(
|
|
context.l10n.downloadAllCount(_tracks.length),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: Colors.white,
|
|
foregroundColor: Colors.black87,
|
|
minimumSize: const Size(0, 48),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAddToPlaylistButton(BuildContext context) {
|
|
return HeaderCircleButton(
|
|
icon: Icons.playlist_add,
|
|
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,
|
|
);
|
|
}
|
|
}
|