mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-26 12:52:40 +02:00
Fixes #368. Reopening a Spotify playlist from "recent access" showed no tracks, while the first view (right after pasting the URL) worked fine. Root cause traced across two layers: 1. Go: ExtURLHandleResult (the parsed shape of an extension's handleUrl() return value) never captured a top-level `id` for the handled resource. Track/album/artist results carry their own id inside their nested metadata, but a plain playlist result has no such object, so its id was silently dropped everywhere from the goja parser through to the JSON the Dart side receives. 2. Dart: TrackState had nowhere to put that id even if it existed (only playlistName), so recording a "recent" playlist access stored the playlist's *name* as if it were its id. Reopening it later fed that name into PlaylistScreen's provider-guessing logic (legacyProviderIdFromResourceId, which only recognizes legacy "provider:id" prefixes), which naturally failed and fell through to a hardcoded Deezer metadata fetch using a Spotify playlist's name as the resource id - guaranteed to return nothing. Fix, matching the "generic API, not per-provider checks" architecture in CONTRIBUTING.md: - go_backend/extension_provider_wrapper.go: add ExtURLHandleResult.ID. - go_backend/extension_goja_convert.go: parse it from the handler's return value. - go_backend/exports_extensions.go: surface it in the JSON response. - lib/providers/track_provider.dart: add TrackState.playlistId, populated from the response's `id` field for playlist results. - lib/screens/home_tab.dart: record the real playlist id (falling back to the name only if a provider never supplies one) and pass both the id and the already-known provider id forward to PlaylistScreen. - lib/screens/playlist_screen.dart: PlaylistScreen gains a metadataProviderId param that takes priority over guessing from the id's shape. - lib/screens/home_tab_recent.dart: pass the recent-access entry's stored providerId through when reopening a playlist. - lib/utils/provider_resource_ids.dart: extract the known-id-vs-guessed-id preference into a small, directly testable resolvePreferredMetadataProviderId helper. Note: this fixes the common case (an extension already reported its own id as the source provider for the URL it handled). If a provider never supplies an id for its handleUrl() playlist result, playlistId stays null and behavior is unchanged from before this fix - no regression, just not a complete fix for that narrower case, since that would require changes in extension-side JS code outside this repo. Tests: - go_backend/extension_goja_convert_url_handle_test.go: the new ID field round-trips from a handler's return value, and stays empty when the handler doesn't supply one. - test/provider_resource_ids_test.dart: resolvePreferredMetadataProviderId prefers a known provider id, falls back to the legacy-prefix guess, treats a blank known id as unknown, and returns null for the unprefixed-id-with-no-known-provider case from #368 itself. Verification: go build/vet/test and flutter analyze/test all green.
542 lines
17 KiB
Dart
542 lines
17 KiB
Dart
import 'package:flutter/material.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/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? 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> {
|
|
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) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final bottomPadding = MediaQuery.paddingOf(context).bottom;
|
|
|
|
pruneSelection({
|
|
for (var index = 0; index < _tracks.length; index++)
|
|
_trackSelectionId(_tracks[index], index),
|
|
});
|
|
|
|
return CollectionScaffold(
|
|
scrollController: scrollController,
|
|
isSelectionMode: isSelectionMode,
|
|
onExitSelectionMode: exitSelectionMode,
|
|
bottomInset: context.navBarBottomInset,
|
|
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,
|
|
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
|
|
? 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,
|
|
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),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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 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
|
|
? 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,
|
|
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: Icons.download_rounded,
|
|
label: context.l10n.downloadAllCount(_tracks.length),
|
|
onPressed: _tracks.isEmpty ? null : () => _confirmDownloadAll(context),
|
|
);
|
|
}
|
|
|
|
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,
|
|
);
|
|
}
|
|
}
|