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:
zarzet
2026-01-31 12:12:14 +07:00
parent 74bac570c7
commit ff7135bf2c
5 changed files with 399 additions and 22 deletions
+60 -5
View File
@@ -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
View File
@@ -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 {
+48 -2
View File
@@ -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
View File
@@ -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;
+116 -11
View File
@@ -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))));
}
}
}