mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-17 22:04:47 +02:00
feat: add playlist search to Deezer default search
- Add SearchPlaylist class and parsing in track_provider.dart - Add playlist search to Deezer SearchAll API (5 results) - Add SearchPlaylistResult struct in Go backend - Add _SearchPlaylistItemWidget for displaying playlists - Add _navigateToSearchPlaylist method - Update PlaylistScreen to support fetching tracks by playlistId - Display playlists in search results alongside artists and albums
This commit is contained in:
+60
-5
@@ -187,7 +187,8 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
||||
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d\n", query, trackLimit, artistLimit)
|
||||
|
||||
albumLimit := 5 // Same as artistLimit for consistency
|
||||
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d:%d", query, trackLimit, artistLimit, albumLimit)
|
||||
playlistLimit := 5
|
||||
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d:%d:%d", query, trackLimit, artistLimit, albumLimit, playlistLimit)
|
||||
|
||||
c.cacheMu.RLock()
|
||||
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
||||
@@ -198,9 +199,10 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
||||
c.cacheMu.RUnlock()
|
||||
|
||||
result := &SearchAllResult{
|
||||
Tracks: make([]TrackMetadata, 0, trackLimit),
|
||||
Artists: make([]SearchArtistResult, 0, artistLimit),
|
||||
Albums: make([]SearchAlbumResult, 0, albumLimit),
|
||||
Tracks: make([]TrackMetadata, 0, trackLimit),
|
||||
Artists: make([]SearchArtistResult, 0, artistLimit),
|
||||
Albums: make([]SearchAlbumResult, 0, albumLimit),
|
||||
Playlists: make([]SearchPlaylistResult, 0, playlistLimit),
|
||||
}
|
||||
|
||||
// Search tracks - NO ISRC fetch for performance
|
||||
@@ -322,7 +324,60 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
||||
GoLog("[Deezer] Album search failed: %v\n", err)
|
||||
}
|
||||
|
||||
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists, %d albums\n", len(result.Tracks), len(result.Artists), len(result.Albums))
|
||||
// Search playlists
|
||||
playlistURL := fmt.Sprintf("%s/playlist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), playlistLimit)
|
||||
GoLog("[Deezer] Fetching playlists from: %s\n", playlistURL)
|
||||
|
||||
var playlistResp struct {
|
||||
Data []struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Picture string `json:"picture"`
|
||||
PictureMedium string `json:"picture_medium"`
|
||||
PictureBig string `json:"picture_big"`
|
||||
PictureXL string `json:"picture_xl"`
|
||||
NbTracks int `json:"nb_tracks"`
|
||||
User struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"user"`
|
||||
} `json:"data"`
|
||||
Error *struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := c.getJSON(ctx, playlistURL, &playlistResp); err == nil {
|
||||
if playlistResp.Error != nil {
|
||||
GoLog("[Deezer] Playlist API error: type=%s, code=%d, message=%s\n", playlistResp.Error.Type, playlistResp.Error.Code, playlistResp.Error.Message)
|
||||
} else {
|
||||
GoLog("[Deezer] Got %d playlists from API\n", len(playlistResp.Data))
|
||||
for _, playlist := range playlistResp.Data {
|
||||
pictureURL := playlist.PictureXL
|
||||
if pictureURL == "" {
|
||||
pictureURL = playlist.PictureBig
|
||||
}
|
||||
if pictureURL == "" {
|
||||
pictureURL = playlist.PictureMedium
|
||||
}
|
||||
if pictureURL == "" {
|
||||
pictureURL = playlist.Picture
|
||||
}
|
||||
|
||||
result.Playlists = append(result.Playlists, SearchPlaylistResult{
|
||||
ID: fmt.Sprintf("deezer:%d", playlist.ID),
|
||||
Name: playlist.Title,
|
||||
Owner: playlist.User.Name,
|
||||
Images: pictureURL,
|
||||
TotalTracks: playlist.NbTracks,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
GoLog("[Deezer] Playlist search failed: %v\n", err)
|
||||
}
|
||||
|
||||
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists, %d albums, %d playlists\n", len(result.Tracks), len(result.Artists), len(result.Albums), len(result.Playlists))
|
||||
|
||||
c.cacheMu.Lock()
|
||||
c.searchCache[cacheKey] = &cacheEntry{
|
||||
|
||||
+12
-3
@@ -248,10 +248,19 @@ type SearchAlbumResult struct {
|
||||
AlbumType string `json:"album_type"`
|
||||
}
|
||||
|
||||
type SearchPlaylistResult struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Owner string `json:"owner"`
|
||||
Images string `json:"images"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
}
|
||||
|
||||
type SearchAllResult struct {
|
||||
Tracks []TrackMetadata `json:"tracks"`
|
||||
Artists []SearchArtistResult `json:"artists"`
|
||||
Albums []SearchAlbumResult `json:"albums"`
|
||||
Tracks []TrackMetadata `json:"tracks"`
|
||||
Artists []SearchArtistResult `json:"artists"`
|
||||
Albums []SearchAlbumResult `json:"albums"`
|
||||
Playlists []SearchPlaylistResult `json:"playlists"`
|
||||
}
|
||||
|
||||
type spotifyURI struct {
|
||||
|
||||
@@ -23,6 +23,7 @@ class TrackState {
|
||||
final List<Track>? artistTopTracks; // Artist's popular tracks
|
||||
final List<SearchArtist>? searchArtists; // For search results
|
||||
final List<SearchAlbum>? searchAlbums; // For search results (albums)
|
||||
final List<SearchPlaylist>? searchPlaylists; // For search results (playlists)
|
||||
final bool hasSearchText; // For back button handling
|
||||
final bool isShowingRecentAccess; // For recent access mode
|
||||
final String? searchExtensionId; // Extension ID used for current search results
|
||||
@@ -44,13 +45,14 @@ class TrackState {
|
||||
this.artistTopTracks,
|
||||
this.searchArtists,
|
||||
this.searchAlbums,
|
||||
this.searchPlaylists,
|
||||
this.hasSearchText = false,
|
||||
this.isShowingRecentAccess = false,
|
||||
this.searchExtensionId,
|
||||
this.selectedSearchFilter,
|
||||
});
|
||||
|
||||
bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty) || (searchAlbums != null && searchAlbums!.isNotEmpty);
|
||||
bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty) || (searchAlbums != null && searchAlbums!.isNotEmpty) || (searchPlaylists != null && searchPlaylists!.isNotEmpty);
|
||||
|
||||
TrackState copyWith({
|
||||
List<Track>? tracks,
|
||||
@@ -68,6 +70,7 @@ class TrackState {
|
||||
List<Track>? artistTopTracks,
|
||||
List<SearchArtist>? searchArtists,
|
||||
List<SearchAlbum>? searchAlbums,
|
||||
List<SearchPlaylist>? searchPlaylists,
|
||||
bool? hasSearchText,
|
||||
bool? isShowingRecentAccess,
|
||||
String? searchExtensionId,
|
||||
@@ -90,6 +93,7 @@ class TrackState {
|
||||
artistTopTracks: artistTopTracks ?? this.artistTopTracks,
|
||||
searchArtists: searchArtists ?? this.searchArtists,
|
||||
searchAlbums: searchAlbums ?? this.searchAlbums,
|
||||
searchPlaylists: searchPlaylists ?? this.searchPlaylists,
|
||||
hasSearchText: hasSearchText ?? this.hasSearchText,
|
||||
isShowingRecentAccess: isShowingRecentAccess ?? this.isShowingRecentAccess,
|
||||
searchExtensionId: searchExtensionId,
|
||||
@@ -156,6 +160,22 @@ class SearchAlbum {
|
||||
});
|
||||
}
|
||||
|
||||
class SearchPlaylist {
|
||||
final String id;
|
||||
final String name;
|
||||
final String owner;
|
||||
final String? imageUrl;
|
||||
final int totalTracks;
|
||||
|
||||
const SearchPlaylist({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.owner,
|
||||
this.imageUrl,
|
||||
required this.totalTracks,
|
||||
});
|
||||
}
|
||||
|
||||
class TrackNotifier extends Notifier<TrackState> {
|
||||
int _currentRequestId = 0;
|
||||
|
||||
@@ -417,12 +437,28 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
}
|
||||
|
||||
_log.i('Search complete: ${tracks.length} tracks (${extensionTracks.length} from extensions), ${artists.length} artists, ${albums.length} albums parsed successfully');
|
||||
final playlistList = results['playlists'] as List<dynamic>? ?? [];
|
||||
final playlists = <SearchPlaylist>[];
|
||||
for (int i = 0; i < playlistList.length; i++) {
|
||||
final p = playlistList[i];
|
||||
try {
|
||||
if (p is Map<String, dynamic>) {
|
||||
playlists.add(_parseSearchPlaylist(p));
|
||||
} else {
|
||||
_log.w('Playlist[$i] is not a Map: ${p.runtimeType}');
|
||||
}
|
||||
} catch (e) {
|
||||
_log.e('Failed to parse playlist[$i]: $e', e);
|
||||
}
|
||||
}
|
||||
|
||||
_log.i('Search complete: ${tracks.length} tracks (${extensionTracks.length} from extensions), ${artists.length} artists, ${albums.length} albums, ${playlists.length} playlists parsed successfully');
|
||||
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
searchArtists: artists,
|
||||
searchAlbums: albums,
|
||||
searchPlaylists: playlists,
|
||||
isLoading: false,
|
||||
hasSearchText: state.hasSearchText,
|
||||
);
|
||||
@@ -642,6 +678,16 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
);
|
||||
}
|
||||
|
||||
SearchPlaylist _parseSearchPlaylist(Map<String, dynamic> data) {
|
||||
return SearchPlaylist(
|
||||
id: data['id'] as String? ?? '',
|
||||
name: data['name'] as String? ?? '',
|
||||
owner: data['owner'] as String? ?? '',
|
||||
imageUrl: data['images'] as String?,
|
||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
void _preWarmCacheForTracks(List<Track> tracks) {
|
||||
final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList();
|
||||
if (tracksWithIsrc.isEmpty) return;
|
||||
|
||||
+163
-1
@@ -467,6 +467,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
final tracks = ref.watch(trackProvider.select((s) => s.tracks));
|
||||
final searchArtists = ref.watch(trackProvider.select((s) => s.searchArtists));
|
||||
final searchAlbums = ref.watch(trackProvider.select((s) => s.searchAlbums));
|
||||
final searchPlaylists = ref.watch(trackProvider.select((s) => s.searchPlaylists));
|
||||
final isLoading = ref.watch(trackProvider.select((s) => s.isLoading));
|
||||
final error = ref.watch(trackProvider.select((s) => s.error));
|
||||
final hasSearchedBefore = ref.watch(settingsProvider.select((s) => s.hasSearchedBefore));
|
||||
@@ -482,7 +483,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
));
|
||||
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final hasActualResults = tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty) || (searchAlbums != null && searchAlbums.isNotEmpty);
|
||||
final hasActualResults = tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty) || (searchAlbums != null && searchAlbums.isNotEmpty) || (searchPlaylists != null && searchPlaylists.isNotEmpty);
|
||||
final isShowingRecentAccess = ref.watch(trackProvider.select((s) => s.isShowingRecentAccess));
|
||||
final hasResults = isShowingRecentAccess || hasActualResults || isLoading;
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
@@ -683,6 +684,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
tracks: tracks,
|
||||
searchArtists: searchArtists,
|
||||
searchAlbums: searchAlbums,
|
||||
searchPlaylists: searchPlaylists,
|
||||
isLoading: isLoading,
|
||||
error: error,
|
||||
colorScheme: colorScheme,
|
||||
@@ -1562,6 +1564,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
required List<Track> tracks,
|
||||
required List<SearchArtist>? searchArtists,
|
||||
required List<SearchAlbum>? searchAlbums,
|
||||
required List<SearchPlaylist>? searchPlaylists,
|
||||
required bool isLoading,
|
||||
required String? error,
|
||||
required ColorScheme colorScheme,
|
||||
@@ -1744,6 +1747,42 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
),
|
||||
),
|
||||
|
||||
// Playlists from default search (Deezer/Spotify)
|
||||
if (searchPlaylists != null && searchPlaylists.isNotEmpty)
|
||||
SliverToBoxAdapter(child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text(context.l10n.searchPlaylists, style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
)),
|
||||
if (searchPlaylists != null && searchPlaylists.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).brightness == Brightness.dark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (int i = 0; i < searchPlaylists.length; i++)
|
||||
_SearchPlaylistItemWidget(
|
||||
key: ValueKey('search-playlist-${searchPlaylists[i].id}'),
|
||||
playlist: searchPlaylists[i],
|
||||
showDivider: i < searchPlaylists.length - 1,
|
||||
onTap: () => _navigateToSearchPlaylist(searchPlaylists[i]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Playlists from extension search
|
||||
if (playlistItems.isNotEmpty)
|
||||
SliverToBoxAdapter(child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
@@ -1857,6 +1896,33 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
));
|
||||
}
|
||||
|
||||
void _navigateToSearchPlaylist(SearchPlaylist playlist) {
|
||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||
|
||||
// Extract the numeric ID from "deezer:123" format
|
||||
String playlistId = playlist.id;
|
||||
if (playlistId.startsWith('deezer:')) {
|
||||
playlistId = playlistId.substring(7);
|
||||
}
|
||||
|
||||
ref.read(recentAccessProvider.notifier).recordPlaylistAccess(
|
||||
id: playlist.id,
|
||||
name: playlist.name,
|
||||
ownerName: playlist.owner,
|
||||
imageUrl: playlist.imageUrl,
|
||||
providerId: 'deezer',
|
||||
);
|
||||
|
||||
Navigator.push(context, MaterialPageRoute(
|
||||
builder: (context) => PlaylistScreen(
|
||||
playlistName: playlist.name,
|
||||
coverUrl: playlist.imageUrl,
|
||||
tracks: const [], // Will be fetched
|
||||
playlistId: playlistId,
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
void _navigateToExtensionAlbum(Track albumItem) async {
|
||||
final extensionId = albumItem.source;
|
||||
if (extensionId == null || extensionId.isEmpty) {
|
||||
@@ -2823,6 +2889,102 @@ class _SearchAlbumItemWidget extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget for displaying playlist items from default search (Deezer/Spotify)
|
||||
class _SearchPlaylistItemWidget extends StatelessWidget {
|
||||
final SearchPlaylist playlist;
|
||||
final bool showDivider;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _SearchPlaylistItemWidget({
|
||||
super.key,
|
||||
required this.playlist,
|
||||
required this.showDivider,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final hasValidImage = playlist.imageUrl != null &&
|
||||
playlist.imageUrl!.isNotEmpty &&
|
||||
Uri.tryParse(playlist.imageUrl!)?.hasAuthority == true;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: onTap,
|
||||
splashColor: colorScheme.primary.withValues(alpha: 0.12),
|
||||
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: hasValidImage
|
||||
? CachedNetworkImage(
|
||||
imageUrl: playlist.imageUrl!,
|
||||
width: 56,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 112,
|
||||
memCacheHeight: 112,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
)
|
||||
: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.playlist_play,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
playlist.name,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
playlist.owner.isNotEmpty ? playlist.owner : 'Playlist',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 24,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showDivider)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: 80,
|
||||
endIndent: 12,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ExtensionAlbumScreen extends ConsumerStatefulWidget {
|
||||
final String extensionId;
|
||||
final String albumId;
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:spotiflac_android/services/palette_service.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.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/models/download_item.dart';
|
||||
@@ -15,12 +16,14 @@ class PlaylistScreen extends ConsumerStatefulWidget {
|
||||
final String playlistName;
|
||||
final String? coverUrl;
|
||||
final List<Track> tracks;
|
||||
final String? playlistId; // Deezer playlist ID for fetching tracks
|
||||
|
||||
const PlaylistScreen({
|
||||
super.key,
|
||||
required this.playlistName,
|
||||
this.coverUrl,
|
||||
required this.tracks,
|
||||
this.playlistId,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -31,12 +34,18 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
Color? _dominantColor;
|
||||
bool _showTitleInAppBar = false;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
List<Track>? _fetchedTracks;
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
|
||||
List<Track> get _tracks => _fetchedTracks ?? widget.tracks;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_extractDominantColor();
|
||||
_fetchTracksIfNeeded();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -46,6 +55,58 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _fetchTracksIfNeeded() async {
|
||||
if (widget.tracks.isNotEmpty || widget.playlistId == null) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final result = await PlatformBridge.getDeezerMetadata('playlist', widget.playlistId!);
|
||||
if (!mounted) return;
|
||||
|
||||
final trackList = result['tracks'] as List<dynamic>? ?? [];
|
||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
|
||||
setState(() {
|
||||
_fetchedTracks = tracks;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Track _parseTrack(Map<String, dynamic> data) {
|
||||
int durationMs = 0;
|
||||
final durationValue = data['duration_ms'];
|
||||
if (durationValue is int) {
|
||||
durationMs = durationValue;
|
||||
} else if (durationValue is double) {
|
||||
durationMs = durationValue.toInt();
|
||||
}
|
||||
|
||||
return Track(
|
||||
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
||||
name: (data['name'] ?? '').toString(),
|
||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
|
||||
albumArtist: data['album_artist']?.toString(),
|
||||
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
final shouldShow = _scrollController.offset > 280;
|
||||
if (shouldShow != _showTitleInAppBar) {
|
||||
@@ -211,15 +272,15 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
children: [
|
||||
Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer),
|
||||
const SizedBox(width: 4),
|
||||
Text(context.l10n.tracksCount(widget.tracks.length), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||
Text(context.l10n.tracksCount(_tracks.length), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: () => _downloadAll(context),
|
||||
onPressed: _tracks.isEmpty ? null : () => _downloadAll(context),
|
||||
icon: const Icon(Icons.download, size: 18),
|
||||
label: Text(context.l10n.downloadAllCount(widget.tracks.length)),
|
||||
label: Text(context.l10n.downloadAllCount(_tracks.length)),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
@@ -249,10 +310,54 @@ const SizedBox(height: 16),
|
||||
}
|
||||
|
||||
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme) {
|
||||
if (_isLoading) {
|
||||
return const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Card(
|
||||
color: colorScheme.errorContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: colorScheme.error),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(_error!, style: TextStyle(color: colorScheme.error))),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_tracks.isEmpty) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Center(
|
||||
child: Text(
|
||||
context.l10n.errorNoTracksFound,
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final track = widget.tracks[index];
|
||||
final track = _tracks[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(track.id),
|
||||
child: _PlaylistTrackItem(
|
||||
@@ -261,7 +366,7 @@ const SizedBox(height: 16),
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: widget.tracks.length,
|
||||
childCount: _tracks.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -286,21 +391,21 @@ const SizedBox(height: 16),
|
||||
}
|
||||
|
||||
void _downloadAll(BuildContext context) {
|
||||
if (widget.tracks.isEmpty) return;
|
||||
if (_tracks.isEmpty) return;
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: '${widget.tracks.length} tracks',
|
||||
trackName: '${_tracks.length} tracks',
|
||||
artistName: widget.playlistName,
|
||||
onSelect: (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(widget.tracks, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(widget.tracks.length))));
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(_tracks, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(_tracks.length))));
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(widget.tracks, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(widget.tracks.length))));
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(_tracks, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(_tracks.length))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user