diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index 65e6c1a0d..c00643ce3 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -111,4 +111,5 @@ abstract class SpotubeIcons { static const wikipedia = SimpleIcons.wikipedia; static const discord = SimpleIcons.discord; static const youtube = SimpleIcons.youtube; + static const radio = FeatherIcons.radio; } diff --git a/lib/components/shared/track_tile/track_options.dart b/lib/components/shared/track_tile/track_options.dart index 724bc029d..515d42b5f 100644 --- a/lib/components/shared/track_tile/track_options.dart +++ b/lib/components/shared/track_tile/track_options.dart @@ -1,6 +1,7 @@ import 'dart:io'; -import 'package:flutter/material.dart'; +import 'package:fl_query/fl_query.dart'; +import 'package:flutter/material.dart' hide Page; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; @@ -10,6 +11,7 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/library/user_local_tracks.dart'; import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart'; import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart'; +import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; import 'package:spotube/components/shared/dialogs/track_details_dialog.dart'; import 'package:spotube/components/shared/heart_button.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; @@ -20,7 +22,9 @@ import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/services/mutations/mutations.dart'; +import 'package:spotube/services/queries/search.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; enum TrackOptionValue { @@ -36,6 +40,7 @@ enum TrackOptionValue { favorite, details, download, + startRadio, } class TrackOptions extends HookConsumerWidget { @@ -82,6 +87,67 @@ class TrackOptions extends HookConsumerWidget { ); } + void actionStartRadio( + BuildContext context, + WidgetRef ref, + Track track, + ) async { + final playback = ref.read(ProxyPlaylistNotifier.notifier); + final playlist = ref.read(ProxyPlaylistNotifier.provider); + final spotify = ref.read(spotifyProvider); + final pages = await QueryClient.of(context) + .fetchInfiniteQueryJob, dynamic, int, SearchParams>( + job: SearchQueries.queryJob(SearchType.playlist.name), + args: ( + spotify: spotify, + searchType: SearchType.playlist, + query: "${track.name} Radio" + ), + ) ?? + []; + + final radios = pages.expand((e) => e.items ?? []).toList(); + + final artists = track.artists!.map((e) => e.name); + + final radio = radios.firstWhere( + (e) => + e.name == "${track.name} Radio" && + artists.where((a) => e.name!.contains(a!)).length >= 2, + orElse: () => radios.first, + ); + + bool replaceQueue = false; + + if (context.mounted && playlist.tracks.isNotEmpty) { + replaceQueue = await showPromptDialog( + context: context, + title: context.l10n.how_to_start_radio, + message: context.l10n.replace_queue_question, + okText: context.l10n.replace, + cancelText: context.l10n.add_to_queue, + ); + } + + if (replaceQueue) { + await playback.stop(); + await playback.load([track], autoPlay: true); + } else { + await playback.addTrack(track); + } + + final tracks = + await spotify.playlists.getTracksByPlaylistId(radio.id!).all(); + + await playback.addTracks( + tracks.toList() + ..removeWhere((e) { + final isDuplicate = playlist.tracks.any((t) => t.id == e.id); + return e.id == track.id || isDuplicate; + }), + ); + } + @override Widget build(BuildContext context, ref) { final scaffoldMessenger = ScaffoldMessenger.of(context); @@ -207,6 +273,9 @@ class TrackOptions extends HookConsumerWidget { case TrackOptionValue.download: await downloadManager.addToQueue(track); break; + case TrackOptionValue.startRadio: + actionStartRadio(context, ref, track); + break; } }, icon: icon ?? const Icon(SpotubeIcons.moreHorizontal), @@ -287,12 +356,18 @@ class TrackOptions extends HookConsumerWidget { : context.l10n.save_as_favorite, ), ), - if (auth != null) + if (auth != null) ...[ + PopSheetEntry( + value: TrackOptionValue.startRadio, + leading: const Icon(SpotubeIcons.radio), + title: Text(context.l10n.start_a_radio), + ), PopSheetEntry( value: TrackOptionValue.addToPlaylist, leading: const Icon(SpotubeIcons.playlistAdd), title: Text(context.l10n.add_to_playlist), ), + ], if (userPlaylist && auth != null) PopSheetEntry( value: TrackOptionValue.removeFromPlaylist, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 07df5f06e..f79090aed 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -286,5 +286,8 @@ "genres": "Genres", "explore_genres": "Explore Genres", "friends": "Friends", - "no_lyrics_available": "Sorry, unable find lyrics for this track" + "no_lyrics_available": "Sorry, unable find lyrics for this track", + "start_a_radio": "Start a Radio", + "how_to_start_radio": "How do you want to start the radio?", + "replace_queue_question": "Do you want to replace the current queue or append to it?" } \ No newline at end of file diff --git a/lib/services/queries/search.dart b/lib/services/queries/search.dart index f11f43998..3c6ee0641 100644 --- a/lib/services/queries/search.dart +++ b/lib/services/queries/search.dart @@ -1,36 +1,60 @@ import 'package:fl_query/fl_query.dart'; +import 'package:fl_query_hooks/fl_query_hooks.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/hooks/spotify/use_spotify_infinite_query.dart'; +import 'package:spotube/provider/spotify_provider.dart'; + +typedef SearchParams = ({ + SpotifyApi spotify, + SearchType searchType, + String query +}); class SearchQueries { const SearchQueries(); + + static final queryJob = + InfiniteQueryJob.withVariableKey, dynamic, int, SearchParams>( + baseQueryKey: "search-query", + task: (variableKey, page, args) => args!.spotify.search.get( + args.query, + types: [args.searchType], + ).getPage(10, page), + initialPage: 0, + nextPage: (lastPage, lastPageData) { + if (lastPageData.isEmpty) return null; + if ((lastPageData.first.isLast || + (lastPageData.first.items ?? []).length < 10)) { + return null; + } + return lastPageData.first.nextOffset; + }, + enabled: false, + ); + InfiniteQuery, dynamic, int> query( WidgetRef ref, - String query, + String queryStr, SearchType searchType, ) { - return useSpotifyInfiniteQuery, dynamic, int>( - "search-query/${searchType.name}", - (page, spotify) { - if (query.trim().isEmpty) return []; - final queryString = query; - return spotify.search.get( - queryString, - types: [searchType], - ).getPage(10, page); - }, - enabled: false, - ref: ref, - initialPage: 0, - nextPage: (lastPage, lastPageData) { - if (lastPageData.isEmpty) return null; - if ((lastPageData.first.isLast || - (lastPageData.first.items ?? []).length < 10)) { - return null; - } - return lastPageData.first.nextOffset; - }, + final spotify = ref.watch(spotifyProvider); + final query = useInfiniteQueryJob, dynamic, int, SearchParams>( + job: queryJob(searchType.name), + args: (spotify: spotify, searchType: searchType, query: queryStr), ); + + useEffect(() { + return ref.listenManual( + spotifyProvider, + (previous, next) { + if (previous != next) { + query.refreshAll(); + } + }, + ).close; + }, [query]); + + return query; } } diff --git a/untranslated_messages.json b/untranslated_messages.json index 9e26dfeeb..748a99067 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -1 +1,109 @@ -{} \ No newline at end of file +{ + "ar": [ + "start_a_radio", + "how_to_start_radio", + "replace_queue_question" + ], + + "bn": [ + "start_a_radio", + "how_to_start_radio", + "replace_queue_question" + ], + + "ca": [ + "start_a_radio", + "how_to_start_radio", + "replace_queue_question" + ], + + "de": [ + "start_a_radio", + "how_to_start_radio", + "replace_queue_question" + ], + + "es": [ + "start_a_radio", + "how_to_start_radio", + "replace_queue_question" + ], + + "fa": [ + "start_a_radio", + "how_to_start_radio", + "replace_queue_question" + ], + + "fr": [ + "start_a_radio", + "how_to_start_radio", + "replace_queue_question" + ], + + "hi": [ + "start_a_radio", + "how_to_start_radio", + "replace_queue_question" + ], + + "it": [ + "start_a_radio", + "how_to_start_radio", + "replace_queue_question" + ], + + "ja": [ + "start_a_radio", + "how_to_start_radio", + "replace_queue_question" + ], + + "ne": [ + "start_a_radio", + "how_to_start_radio", + "replace_queue_question" + ], + + "nl": [ + "start_a_radio", + "how_to_start_radio", + "replace_queue_question" + ], + + "pl": [ + "start_a_radio", + "how_to_start_radio", + "replace_queue_question" + ], + + "pt": [ + "start_a_radio", + "how_to_start_radio", + "replace_queue_question" + ], + + "ru": [ + "start_a_radio", + "how_to_start_radio", + "replace_queue_question" + ], + + "tr": [ + "start_a_radio", + "how_to_start_radio", + "replace_queue_question" + ], + + "uk": [ + "start_a_radio", + "how_to_start_radio", + "replace_queue_question" + ], + + "zh": [ + "start_a_radio", + "how_to_start_radio", + "replace_queue_question" + ] +}