Skip to content

Commit 9dfd49c

Browse files
committed
feat: add endless playback support #285
1 parent 4defeef commit 9dfd49c

13 files changed

+957
-254
lines changed

analysis_options.yaml

+3-1
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,6 @@ linter:
3131
analyzer:
3232
enable-experiment:
3333
- records
34-
- patterns
34+
- patterns
35+
errors:
36+
invalid_annotation_target: ignore

lib/components/shared/track_tile/track_options.dart

+16-5
Original file line numberDiff line numberDiff line change
@@ -106,14 +106,22 @@ class TrackOptions extends HookConsumerWidget {
106106
) ??
107107
[];
108108

109-
final radios = pages.expand((e) => e.items ?? <PlaylistSimple>[]).toList();
109+
final radios = pages
110+
.expand((e) => e.items?.toList() ?? <PlaylistSimple>[])
111+
.toList()
112+
.cast<PlaylistSimple>();
110113

111114
final artists = track.artists!.map((e) => e.name);
112115

113116
final radio = radios.firstWhere(
114-
(e) =>
115-
e.name == "${track.name} Radio" &&
116-
artists.where((a) => e.name!.contains(a!)).length >= 2,
117+
(e) {
118+
final validPlaylists =
119+
artists.where((a) => e.description!.contains(a!));
120+
return e.name == "${track.name} Radio" &&
121+
(validPlaylists.length >= 2 ||
122+
validPlaylists.length == artists.length) &&
123+
e.owner?.displayName == "Spotify";
124+
},
117125
orElse: () => radios.first,
118126
);
119127

@@ -129,9 +137,12 @@ class TrackOptions extends HookConsumerWidget {
129137
);
130138
}
131139

132-
if (replaceQueue) {
140+
if (replaceQueue || playlist.tracks.isEmpty) {
133141
await playback.stop();
134142
await playback.load([track], autoPlay: true);
143+
144+
// we don't have to add those tracks as useEndlessPlayback will do it for us
145+
return;
135146
} else {
136147
await playback.addTrack(track);
137148
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import 'package:catcher_2/catcher_2.dart';
2+
import 'package:fl_query_hooks/fl_query_hooks.dart';
3+
import 'package:flutter_hooks/flutter_hooks.dart';
4+
import 'package:flutter_riverpod/flutter_riverpod.dart';
5+
import 'package:spotify/spotify.dart';
6+
import 'package:spotube/provider/authentication_provider.dart';
7+
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
8+
import 'package:spotube/provider/spotify_provider.dart';
9+
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
10+
import 'package:spotube/services/audio_player/audio_player.dart';
11+
import 'package:spotube/services/queries/search.dart';
12+
13+
void useEndlessPlayback(WidgetRef ref) {
14+
final auth = ref.watch(AuthenticationNotifier.provider);
15+
final playback = ref.watch(ProxyPlaylistNotifier.notifier);
16+
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
17+
final spotify = ref.watch(spotifyProvider);
18+
final endlessPlayback =
19+
ref.watch(userPreferencesProvider.select((s) => s.endlessPlayback));
20+
21+
final queryClient = useQueryClient();
22+
23+
useEffect(
24+
() {
25+
if (!endlessPlayback || auth == null) return null;
26+
27+
void listener(int index) async {
28+
try {
29+
final playlist = ref.read(ProxyPlaylistNotifier.provider);
30+
if (index != playlist.tracks.length - 1) return;
31+
32+
final track = playlist.tracks.last;
33+
34+
final pages = await queryClient.fetchInfiniteQueryJob<List<Page>,
35+
dynamic, int, SearchParams>(
36+
job: SearchQueries.queryJob(SearchType.playlist.name),
37+
args: (
38+
spotify: spotify,
39+
searchType: SearchType.playlist,
40+
query: "${track.name} Radio"
41+
),
42+
) ??
43+
[];
44+
45+
final radios = pages
46+
.expand((e) => e.items?.toList() ?? <PlaylistSimple>[])
47+
.toList()
48+
.cast<PlaylistSimple>();
49+
50+
final artists = track.artists!.map((e) => e.name);
51+
52+
final radio = radios.firstWhere(
53+
(e) {
54+
final validPlaylists =
55+
artists.where((a) => e.description!.contains(a!));
56+
return e.name == "${track.name} Radio" &&
57+
(validPlaylists.length >= 2 ||
58+
validPlaylists.length == artists.length) &&
59+
e.owner?.displayName != "Spotify";
60+
},
61+
orElse: () => radios.first,
62+
);
63+
64+
final tracks =
65+
await spotify.playlists.getTracksByPlaylistId(radio.id!).all();
66+
67+
await playback.addTracks(
68+
tracks.toList()
69+
..removeWhere((e) {
70+
final playlist = ref.read(ProxyPlaylistNotifier.provider);
71+
final isDuplicate = playlist.tracks.any((t) => t.id == e.id);
72+
return e.id == track.id || isDuplicate;
73+
}),
74+
);
75+
} catch (e, stack) {
76+
Catcher2.reportCheckedError(e, stack);
77+
}
78+
}
79+
80+
// Sometimes user can change settings for which the currentIndexChanged
81+
// might not be called. So we need to check if the current track is the
82+
// last track and if it is then we need to call the listener manually.
83+
if (playlist.active == playlist.tracks.length - 1 &&
84+
audioPlayer.isPlaying) {
85+
listener(playlist.active!);
86+
}
87+
88+
final subscription =
89+
audioPlayer.currentIndexChangedStream.listen(listener);
90+
91+
return subscription.cancel;
92+
},
93+
[
94+
spotify,
95+
playback,
96+
queryClient,
97+
playlist.tracks,
98+
endlessPlayback,
99+
auth,
100+
],
101+
);
102+
}

lib/l10n/app_en.arb

+2-1
Original file line numberDiff line numberDiff line change
@@ -289,5 +289,6 @@
289289
"no_lyrics_available": "Sorry, unable find lyrics for this track",
290290
"start_a_radio": "Start a Radio",
291291
"how_to_start_radio": "How do you want to start the radio?",
292-
"replace_queue_question": "Do you want to replace the current queue or append to it?"
292+
"replace_queue_question": "Do you want to replace the current queue or append to it?",
293+
"endless_playback": "Endless Playback"
293294
}

lib/pages/root/root_app.dart

+3
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import 'package:spotube/components/root/bottom_player.dart';
1515
import 'package:spotube/components/root/sidebar.dart';
1616
import 'package:spotube/components/root/spotube_navigation_bar.dart';
1717
import 'package:spotube/extensions/context.dart';
18+
import 'package:spotube/hooks/configurators/use_endless_playback.dart';
1819
import 'package:spotube/hooks/configurators/use_update_checker.dart';
1920
import 'package:spotube/provider/download_manager_provider.dart';
2021
import 'package:spotube/utils/persisted_state_notifier.dart';
@@ -134,6 +135,8 @@ class RootApp extends HookConsumerWidget {
134135
// checks for latest version of the application
135136
useUpdateChecker(ref);
136137

138+
useEndlessPlayback(ref);
139+
137140
final backgroundColor = Theme.of(context).scaffoldBackgroundColor;
138141

139142
useEffect(() {

lib/pages/settings/sections/playback.dart

+6
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,12 @@ class SettingsPlaybackSection extends HookConsumerWidget {
221221
preferencesNotifier.setDownloadMusicCodec(value);
222222
},
223223
),
224+
SwitchListTile(
225+
secondary: const Icon(SpotubeIcons.repeat),
226+
title: Text(context.l10n.endless_playback),
227+
value: preferences.endlessPlayback,
228+
onChanged: preferencesNotifier.setEndlessPlayback,
229+
),
224230
],
225231
);
226232
}

lib/provider/user_preferences/user_preferences_provider.dart

+4
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,10 @@ class UserPreferencesNotifier extends PersistedStateNotifier<UserPreferences> {
123123
audioPlayer.setAudioNormalization(normalize);
124124
}
125125

126+
void setEndlessPlayback(bool endless) {
127+
state = state.copyWith(endlessPlayback: endless);
128+
}
129+
126130
Future<String> _getDefaultDownloadDirectory() async {
127131
if (kIsAndroid) return "/storage/emulated/0/Download/Spotube";
128132

0 commit comments

Comments
 (0)