mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-21 18:37:36 +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:
+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