From ff7135bf2c49d5236dc65ee274ac8ff91250cda3 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sat, 31 Jan 2026 12:12:14 +0700 Subject: [PATCH] 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 --- go_backend/deezer.go | 65 +++++++++++- go_backend/spotify.go | 15 ++- lib/providers/track_provider.dart | 50 ++++++++- lib/screens/home_tab.dart | 164 +++++++++++++++++++++++++++++- lib/screens/playlist_screen.dart | 127 +++++++++++++++++++++-- 5 files changed, 399 insertions(+), 22 deletions(-) diff --git a/go_backend/deezer.go b/go_backend/deezer.go index 049dc6e8..64004026 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -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{ diff --git a/go_backend/spotify.go b/go_backend/spotify.go index 2423e9d7..d776768a 100644 --- a/go_backend/spotify.go +++ b/go_backend/spotify.go @@ -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 { diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index ebdce48d..7b8289ae 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -23,6 +23,7 @@ class TrackState { final List? artistTopTracks; // Artist's popular tracks final List? searchArtists; // For search results final List? searchAlbums; // For search results (albums) + final List? 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? tracks, @@ -68,6 +70,7 @@ class TrackState { List? artistTopTracks, List? searchArtists, List? searchAlbums, + List? 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 { int _currentRequestId = 0; @@ -417,12 +437,28 @@ class TrackNotifier extends Notifier { } } - _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? ?? []; + final playlists = []; + for (int i = 0; i < playlistList.length; i++) { + final p = playlistList[i]; + try { + if (p is Map) { + 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 { ); } + SearchPlaylist _parseSearchPlaylist(Map 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 tracks) { final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList(); if (tracksWithIsrc.isEmpty) return; diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index db317227..e9fcd4bd 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -467,6 +467,7 @@ class _HomeTabState extends ConsumerState 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 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 with AutomaticKeepAliveClient tracks: tracks, searchArtists: searchArtists, searchAlbums: searchAlbums, + searchPlaylists: searchPlaylists, isLoading: isLoading, error: error, colorScheme: colorScheme, @@ -1562,6 +1564,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient required List tracks, required List? searchArtists, required List? searchAlbums, + required List? searchPlaylists, required bool isLoading, required String? error, required ColorScheme colorScheme, @@ -1744,6 +1747,42 @@ class _HomeTabState extends ConsumerState 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 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; diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 25fbfee5..bf87e86d 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -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 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 { Color? _dominantColor; bool _showTitleInAppBar = false; final ScrollController _scrollController = ScrollController(); + List? _fetchedTracks; + bool _isLoading = false; + String? _error; + + List get _tracks => _fetchedTracks ?? widget.tracks; @override void initState() { super.initState(); _scrollController.addListener(_onScroll); _extractDominantColor(); + _fetchTracksIfNeeded(); } @override @@ -46,6 +55,58 @@ class _PlaylistScreenState extends ConsumerState { super.dispose(); } + Future _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? ?? []; + final tracks = trackList.map((t) => _parseTrack(t as Map)).toList(); + + setState(() { + _fetchedTracks = tracks; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + + Track _parseTrack(Map 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 { 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)))); } } }