mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-12-07 15:59:42 +00:00
Compare commits
10 Commits
102a4f121e
...
2394a8ded4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2394a8ded4 | ||
|
|
ba27dc70e4 | ||
|
|
0ec9f3535b | ||
|
|
df72ba6960 | ||
|
|
d9057dae57 | ||
|
|
e61b79585e | ||
|
|
a9e5636e96 | ||
|
|
c5a72cd44c | ||
|
|
8b6cc11486 | ||
|
|
cee65b5f2f |
@ -1,6 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
||||
## [4.0.2](https://github.com/krtirtho/spotube/compare/v4.0.1...v4.0.2) (2025-03-16)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- invalid access token exception #2525
|
||||
|
||||
## [4.0.1](https://github.com/krtirtho/spotube/compare/v4.0.0...v4.0.1) (2025-03-15)
|
||||
|
||||
|
||||
@ -30,7 +30,6 @@ import 'package:spotube/provider/download_manager_provider.dart';
|
||||
import 'package:spotube/provider/local_tracks/local_tracks_provider.dart';
|
||||
import 'package:spotube/provider/audio_player/audio_player.dart';
|
||||
import 'package:spotube/provider/spotify/spotify.dart';
|
||||
import 'package:spotube/provider/spotify_provider.dart';
|
||||
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
@ -122,8 +121,9 @@ class TrackOptions extends HookConsumerWidget {
|
||||
final playlist = ref.read(audioPlayerProvider);
|
||||
final spotify = ref.read(spotifyProvider);
|
||||
final query = "${track.name} Radio";
|
||||
final pages =
|
||||
await spotify.search.get(query, types: [SearchType.playlist]).first();
|
||||
final pages = await spotify.invoke(
|
||||
(api) => api.search.get(query, types: [SearchType.playlist]).first(),
|
||||
);
|
||||
|
||||
final radios = pages
|
||||
.expand((e) => e.items?.cast<PlaylistSimple>().toList() ?? [])
|
||||
@ -165,8 +165,9 @@ class TrackOptions extends HookConsumerWidget {
|
||||
await playback.addTrack(track);
|
||||
}
|
||||
|
||||
final tracks =
|
||||
await spotify.playlists.getTracksByPlaylistId(radio.id!).all();
|
||||
final tracks = await spotify.invoke(
|
||||
(api) => api.playlists.getTracksByPlaylistId(radio.id!).all(),
|
||||
);
|
||||
|
||||
await playback.addTracks(
|
||||
tracks.toList()
|
||||
|
||||
@ -191,8 +191,7 @@ class TrackTile extends HookConsumerWidget {
|
||||
const SizedBox(
|
||||
width: 26,
|
||||
height: 26,
|
||||
child:
|
||||
CircularProgressIndicator(size: 1.5),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
(_, _, true, _, _) => Icon(
|
||||
SpotubeIcons.pause,
|
||||
|
||||
@ -5,7 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotube/collections/routes.dart';
|
||||
import 'package:spotube/collections/routes.gr.dart';
|
||||
import 'package:spotube/provider/spotify_provider.dart';
|
||||
import 'package:spotube/provider/spotify/spotify.dart';
|
||||
import 'package:flutter_sharing_intent/flutter_sharing_intent.dart';
|
||||
import 'package:flutter_sharing_intent/model/sharing_file.dart';
|
||||
import 'package:spotube/services/logger/logger.dart';
|
||||
@ -27,7 +27,9 @@ void useDeepLinking(WidgetRef ref, AppRouter router) {
|
||||
|
||||
switch (url.pathSegments.first) {
|
||||
case "album":
|
||||
final album = await spotify.albums.get(url.pathSegments.last);
|
||||
final album = await spotify.invoke((api) {
|
||||
return api.albums.get(url.pathSegments.last);
|
||||
});
|
||||
router.navigate(
|
||||
AlbumRoute(id: album.id!, album: album),
|
||||
);
|
||||
@ -36,7 +38,9 @@ void useDeepLinking(WidgetRef ref, AppRouter router) {
|
||||
router.navigate(ArtistRoute(artistId: url.pathSegments.last));
|
||||
break;
|
||||
case "playlist":
|
||||
final playlist = await spotify.playlists.get(url.pathSegments.last);
|
||||
final playlist = await spotify.invoke((api) {
|
||||
return api.playlists.get(url.pathSegments.last);
|
||||
});
|
||||
router
|
||||
.navigate(PlaylistRoute(id: playlist.id!, playlist: playlist));
|
||||
break;
|
||||
@ -65,7 +69,9 @@ void useDeepLinking(WidgetRef ref, AppRouter router) {
|
||||
|
||||
switch (startSegment) {
|
||||
case "spotify:album":
|
||||
final album = await spotify.albums.get(endSegment);
|
||||
final album = await spotify.invoke((api) {
|
||||
return api.albums.get(endSegment);
|
||||
});
|
||||
await router.navigate(
|
||||
AlbumRoute(id: album.id!, album: album),
|
||||
);
|
||||
@ -77,7 +83,9 @@ void useDeepLinking(WidgetRef ref, AppRouter router) {
|
||||
await router.navigate(TrackRoute(trackId: endSegment));
|
||||
break;
|
||||
case "spotify:playlist":
|
||||
final playlist = await spotify.playlists.get(endSegment);
|
||||
final playlist = await spotify.invoke((api) {
|
||||
return api.playlists.get(endSegment);
|
||||
});
|
||||
await router.navigate(
|
||||
PlaylistRoute(id: playlist.id!, playlist: playlist),
|
||||
);
|
||||
|
||||
@ -4,7 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/provider/authentication/authentication.dart';
|
||||
import 'package:spotube/provider/audio_player/audio_player.dart';
|
||||
import 'package:spotube/provider/spotify_provider.dart';
|
||||
import 'package:spotube/provider/spotify/spotify.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
|
||||
@ -28,8 +28,8 @@ void useEndlessPlayback(WidgetRef ref) {
|
||||
final track = playlist.tracks.last;
|
||||
|
||||
final query = "${track.name} Radio";
|
||||
final pages = await spotify.search
|
||||
.get(query, types: [SearchType.playlist]).first();
|
||||
final pages = await spotify.invoke((api) =>
|
||||
api.search.get(query, types: [SearchType.playlist]).first());
|
||||
|
||||
final radios = pages
|
||||
.expand((e) => e.items?.toList() ?? <PlaylistSimple>[])
|
||||
@ -50,8 +50,8 @@ void useEndlessPlayback(WidgetRef ref) {
|
||||
orElse: () => radios.first,
|
||||
);
|
||||
|
||||
final tracks =
|
||||
await spotify.playlists.getTracksByPlaylistId(radio.id!).all();
|
||||
final tracks = await spotify.invoke(
|
||||
(api) => api.playlists.getTracksByPlaylistId(radio.id!).all());
|
||||
|
||||
await playback.addTracks(
|
||||
tracks.toList()
|
||||
|
||||
@ -8,7 +8,7 @@ import 'package:spotube/collections/routes.gr.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/image/universal_image.dart';
|
||||
import 'package:spotube/models/spotify_friends.dart';
|
||||
import 'package:spotube/provider/spotify_provider.dart';
|
||||
import 'package:spotube/provider/spotify/spotify.dart';
|
||||
|
||||
class FriendItem extends HookConsumerWidget {
|
||||
final SpotifyFriendActivity friend;
|
||||
@ -95,8 +95,9 @@ class FriendItem extends HookConsumerWidget {
|
||||
text: " ${friend.track.album.name}",
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () async {
|
||||
final album =
|
||||
await spotify.albums.get(friend.track.album.id);
|
||||
final album = await spotify.invoke(
|
||||
(api) => api.albums.get(friend.track.album.id),
|
||||
);
|
||||
if (context.mounted) {
|
||||
context.navigateTo(
|
||||
AlbumRoute(id: album.id!, album: album),
|
||||
|
||||
@ -19,7 +19,6 @@ import 'package:spotube/components/image/universal_image.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/extensions/image.dart';
|
||||
import 'package:spotube/provider/spotify/spotify.dart';
|
||||
import 'package:spotube/provider/spotify_provider.dart';
|
||||
|
||||
class PlaylistCreateDialog extends HookConsumerWidget {
|
||||
/// Track ids to add to the playlist
|
||||
@ -260,7 +259,7 @@ class PlaylistCreateDialog extends HookConsumerWidget {
|
||||
class PlaylistCreateDialogButton extends HookConsumerWidget {
|
||||
const PlaylistCreateDialogButton({super.key});
|
||||
|
||||
showPlaylistDialog(BuildContext context, SpotifyApi spotify) {
|
||||
showPlaylistDialog(BuildContext context, SpotifyApiWrapper spotify) {
|
||||
showDialog(
|
||||
context: context,
|
||||
alignment: Alignment.center,
|
||||
|
||||
@ -22,7 +22,6 @@ import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/extensions/image.dart';
|
||||
import 'package:spotube/models/spotify/recommendation_seeds.dart';
|
||||
import 'package:spotube/provider/spotify/spotify.dart';
|
||||
import 'package:spotube/provider/spotify_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
|
||||
@ -70,22 +69,24 @@ class PlaylistGeneratorPage extends HookConsumerWidget {
|
||||
leftSeedCount,
|
||||
context.l10n.artists,
|
||||
)),
|
||||
fetchSeeds: (textEditingValue) => spotify.search
|
||||
.get(
|
||||
textEditingValue.text,
|
||||
types: [SearchType.artist],
|
||||
)
|
||||
.first(6)
|
||||
.then(
|
||||
(v) => List.castFrom<dynamic, Artist>(
|
||||
v.expand((e) => e.items ?? []).toList(),
|
||||
fetchSeeds: (textEditingValue) => spotify.invoke(
|
||||
(api) => api.search
|
||||
.get(
|
||||
textEditingValue.text,
|
||||
types: [SearchType.artist],
|
||||
)
|
||||
.where(
|
||||
(element) =>
|
||||
artists.value.none((artist) => element.id == artist.id),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
.first(6)
|
||||
.then(
|
||||
(v) => List.castFrom<dynamic, Artist>(
|
||||
v.expand((e) => e.items ?? []).toList(),
|
||||
)
|
||||
.where(
|
||||
(element) =>
|
||||
artists.value.none((artist) => element.id == artist.id),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
autocompleteOptionBuilder: (option, onSelected) => ButtonTile(
|
||||
leading: Avatar(
|
||||
initials: "O",
|
||||
@ -146,22 +147,24 @@ class PlaylistGeneratorPage extends HookConsumerWidget {
|
||||
leftSeedCount,
|
||||
context.l10n.tracks,
|
||||
)),
|
||||
fetchSeeds: (textEditingValue) => spotify.search
|
||||
.get(
|
||||
textEditingValue.text,
|
||||
types: [SearchType.track],
|
||||
)
|
||||
.first(6)
|
||||
.then(
|
||||
(v) => List.castFrom<dynamic, Track>(
|
||||
v.expand((e) => e.items ?? []).toList(),
|
||||
fetchSeeds: (textEditingValue) => spotify.invoke(
|
||||
(api) => api.search
|
||||
.get(
|
||||
textEditingValue.text,
|
||||
types: [SearchType.track],
|
||||
)
|
||||
.where(
|
||||
(element) =>
|
||||
tracks.value.none((track) => element.id == track.id),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
.first(6)
|
||||
.then(
|
||||
(v) => List.castFrom<dynamic, Track>(
|
||||
v.expand((e) => e.items ?? []).toList(),
|
||||
)
|
||||
.where(
|
||||
(element) =>
|
||||
tracks.value.none((track) => element.id == track.id),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
autocompleteOptionBuilder: (option, onSelected) => ButtonTile(
|
||||
leading: Avatar(
|
||||
initials: option.name!.substring(0, 1),
|
||||
|
||||
@ -15,6 +15,7 @@ import 'package:spotube/components/dialogs/prompt_dialog.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/models/database/database.dart';
|
||||
import 'package:spotube/provider/database/database.dart';
|
||||
import 'package:spotube/services/logger/logger.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
import 'package:otp_util/otp_util.dart';
|
||||
// ignore: implementation_imports
|
||||
@ -197,6 +198,34 @@ class AuthenticationNotifier extends AsyncNotifier<AuthenticationTableData?> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<Response> getToken({
|
||||
required String totp,
|
||||
required int timestamp,
|
||||
String mode = "transport",
|
||||
String? spDc,
|
||||
}) async {
|
||||
assert(mode == "transport" || mode == "init");
|
||||
|
||||
final accessTokenUrl = Uri.parse(
|
||||
"https://open.spotify.com/get_access_token?reason=$mode&productType=web-player"
|
||||
"&totp=$totp&totpVer=5&ts=$timestamp",
|
||||
);
|
||||
|
||||
final res = await dio.getUri(
|
||||
accessTokenUrl,
|
||||
options: Options(
|
||||
headers: {
|
||||
"Cookie": spDc ?? "",
|
||||
"User-Agent": ServiceUtils.randomUserAgent(
|
||||
kIsDesktop ? UserAgentDevice.desktop : UserAgentDevice.mobile,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
Future<AuthenticationTableCompanion> credentialsFromCookie(
|
||||
String cookie,
|
||||
) async {
|
||||
@ -207,24 +236,34 @@ class AuthenticationNotifier extends AsyncNotifier<AuthenticationTableData?> {
|
||||
?.trim();
|
||||
|
||||
final totp = await generateTotp();
|
||||
|
||||
final timestamp = (DateTime.now().millisecondsSinceEpoch / 1000).floor();
|
||||
|
||||
final accessTokenUrl = Uri.parse(
|
||||
"https://open.spotify.com/get_access_token?reason=transport&productType=web_player"
|
||||
"&totp=$totp&totpVer=5&ts=$timestamp",
|
||||
var res = await getToken(
|
||||
totp: totp,
|
||||
timestamp: timestamp,
|
||||
spDc: spDc,
|
||||
mode: "transport",
|
||||
);
|
||||
|
||||
final res = await dio.getUri(
|
||||
accessTokenUrl,
|
||||
options: Options(
|
||||
headers: {
|
||||
"Cookie": spDc ?? "",
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||
},
|
||||
),
|
||||
);
|
||||
final body = res.data;
|
||||
if ((res.data["accessToken"]?.length ?? 0) != 374) {
|
||||
res = await getToken(
|
||||
totp: totp,
|
||||
timestamp: timestamp,
|
||||
spDc: spDc,
|
||||
mode: "init",
|
||||
);
|
||||
}
|
||||
|
||||
final body = res.data as Map<String, dynamic>;
|
||||
|
||||
if (body["accessToken"] == null) {
|
||||
AppLogger.reportError(
|
||||
"The access token is only ${body["accessToken"]?.length} characters long instead of 374\n"
|
||||
"Your authentication probably doesn't work",
|
||||
StackTrace.current,
|
||||
);
|
||||
}
|
||||
|
||||
return AuthenticationTableCompanion.insert(
|
||||
id: const Value(0),
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotube/provider/authentication/authentication.dart';
|
||||
import 'package:spotube/provider/spotify_provider.dart';
|
||||
import 'package:spotube/provider/spotify/spotify.dart';
|
||||
import 'package:spotube/services/custom_spotify_endpoints/spotify_endpoints.dart';
|
||||
|
||||
final customSpotifyEndpointProvider = Provider<CustomSpotifyEndpoints>((ref) {
|
||||
|
||||
@ -76,8 +76,6 @@ final localTracksProvider =
|
||||
final mime = lookupMimeType(e.path) ??
|
||||
(extension(e.path) == ".opus" ? "audio/opus" : null);
|
||||
|
||||
print("${basename(e.path)}: $mime");
|
||||
|
||||
return e is File && supportedAudioTypes.contains(mime);
|
||||
},
|
||||
).cast<File>(),
|
||||
|
||||
@ -22,11 +22,14 @@ class FavoriteAlbumState extends PaginatedState<AlbumSimple> {
|
||||
class FavoriteAlbumNotifier
|
||||
extends PaginatedAsyncNotifier<AlbumSimple, FavoriteAlbumState> {
|
||||
@override
|
||||
Future<List<AlbumSimple>> fetch(int offset, int limit) {
|
||||
return spotify.me
|
||||
.savedAlbums()
|
||||
.getPage(limit, offset)
|
||||
.then((value) => value.items?.toList() ?? []);
|
||||
Future<List<AlbumSimple>> fetch(int offset, int limit) async {
|
||||
return await spotify
|
||||
.invoke(
|
||||
(api) => api.me.savedAlbums().getPage(limit, offset),
|
||||
)
|
||||
.then(
|
||||
(value) => value.items?.toList() ?? <AlbumSimple>[],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@ -45,8 +48,10 @@ class FavoriteAlbumNotifier
|
||||
if (state.value == null) return;
|
||||
|
||||
state = await AsyncValue.guard(() async {
|
||||
await spotify.me.saveAlbums(ids);
|
||||
final albums = await spotify.albums.list(ids);
|
||||
await spotify.invoke((api) => api.me.saveAlbums(ids));
|
||||
final albums = await spotify.invoke(
|
||||
(api) => api.albums.list(ids),
|
||||
);
|
||||
|
||||
return state.value!.copyWith(
|
||||
items: [
|
||||
@ -65,7 +70,7 @@ class FavoriteAlbumNotifier
|
||||
if (state.value == null) return;
|
||||
|
||||
state = await AsyncValue.guard(() async {
|
||||
await spotify.me.removeAlbums(ids);
|
||||
await spotify.invoke((api) => api.me.removeAlbums(ids));
|
||||
|
||||
return state.value!.copyWith(
|
||||
items: state.value!.items
|
||||
|
||||
@ -3,8 +3,10 @@ part of '../spotify.dart';
|
||||
final albumsIsSavedProvider = FutureProvider.autoDispose.family<bool, String>(
|
||||
(ref, albumId) async {
|
||||
final spotify = ref.watch(spotifyProvider);
|
||||
return spotify.me.containsSavedAlbums([albumId]).then(
|
||||
(value) => value[albumId] ?? false,
|
||||
return spotify.invoke(
|
||||
(api) => api.me.containsSavedAlbums([albumId]).then(
|
||||
(value) => value[albumId] ?? false,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@ -32,9 +32,9 @@ class AlbumReleasesNotifier
|
||||
fetch(int offset, int limit) async {
|
||||
final market = ref.read(userPreferencesProvider).market;
|
||||
|
||||
final albums = await spotify.browse
|
||||
.newReleases(country: market)
|
||||
.getPage(limit, offset);
|
||||
final albums = await spotify.invoke(
|
||||
(api) => api.browse.newReleases(country: market).getPage(limit, offset),
|
||||
);
|
||||
|
||||
return albums.items?.map((album) => album.toAlbum()).toList() ?? [];
|
||||
}
|
||||
|
||||
@ -30,7 +30,9 @@ class AlbumTracksNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier<Track,
|
||||
|
||||
@override
|
||||
fetch(arg, offset, limit) async {
|
||||
final tracks = await spotify.albums.tracks(arg.id!).getPage(limit, offset);
|
||||
final tracks = await spotify.invoke(
|
||||
(api) => api.albums.tracks(arg.id!).getPage(limit, offset),
|
||||
);
|
||||
final items = tracks.items?.map((e) => e.asTrack(arg)).toList() ?? [];
|
||||
|
||||
return (
|
||||
|
||||
@ -31,9 +31,9 @@ class ArtistAlbumsNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier<
|
||||
@override
|
||||
fetch(arg, offset, limit) async {
|
||||
final market = ref.read(userPreferencesProvider).market;
|
||||
final albums = await spotify.artists
|
||||
.albums(arg, country: market)
|
||||
.getPage(limit, offset);
|
||||
final albums = await spotify.invoke(
|
||||
(api) => api.artists.albums(arg, country: market).getPage(limit, offset),
|
||||
);
|
||||
|
||||
final items = albums.items?.toList() ?? [];
|
||||
|
||||
|
||||
@ -6,5 +6,5 @@ final artistProvider =
|
||||
|
||||
final spotify = ref.watch(spotifyProvider);
|
||||
|
||||
return spotify.artists.get(artistId);
|
||||
return spotify.invoke((api) => api.artists.get(artistId));
|
||||
});
|
||||
|
||||
@ -33,10 +33,12 @@ class FollowedArtistsNotifier
|
||||
|
||||
@override
|
||||
fetch(offset, limit) async {
|
||||
final artists = await spotify.me.following(FollowingType.artist).getPage(
|
||||
limit,
|
||||
offset ?? '',
|
||||
);
|
||||
final artists = await spotify.invoke(
|
||||
(api) => api.me.following(FollowingType.artist).getPage(
|
||||
limit,
|
||||
offset ?? '',
|
||||
),
|
||||
);
|
||||
|
||||
return (artists.items?.toList() ?? [], artists.after);
|
||||
}
|
||||
@ -55,7 +57,9 @@ class FollowedArtistsNotifier
|
||||
|
||||
Future<void> _followArtists(List<String> artistIds) async {
|
||||
try {
|
||||
final creds = await spotify.getCredentials();
|
||||
final creds = await spotify.invoke(
|
||||
(api) => api.getCredentials(),
|
||||
);
|
||||
|
||||
await dio.post(
|
||||
"https://api-partner.spotify.com/pathfinder/v1/query",
|
||||
@ -93,7 +97,9 @@ class FollowedArtistsNotifier
|
||||
await _followArtists(artistIds);
|
||||
|
||||
state = await AsyncValue.guard(() async {
|
||||
final artists = await spotify.artists.list(artistIds);
|
||||
final artists = await spotify.invoke(
|
||||
(api) => api.artists.list(artistIds),
|
||||
);
|
||||
|
||||
return state.value!.copyWith(
|
||||
items: [
|
||||
@ -110,7 +116,9 @@ class FollowedArtistsNotifier
|
||||
|
||||
Future<void> removeArtists(List<String> artistIds) async {
|
||||
if (state.value == null) return;
|
||||
await spotify.me.unfollow(FollowingType.artist, artistIds);
|
||||
await spotify.invoke(
|
||||
(api) => api.me.unfollow(FollowingType.artist, artistIds),
|
||||
);
|
||||
|
||||
state = await AsyncValue.guard(() async {
|
||||
final artists = state.value!.items.where((artist) {
|
||||
@ -136,7 +144,9 @@ final followedArtistsProvider =
|
||||
final allFollowedArtistsProvider = FutureProvider<List<Artist>>(
|
||||
(ref) async {
|
||||
final spotify = ref.watch(spotifyProvider);
|
||||
final artists = await spotify.me.following(FollowingType.artist).all();
|
||||
final artists = await spotify.invoke(
|
||||
(api) => api.me.following(FollowingType.artist).all(),
|
||||
);
|
||||
return artists.toList();
|
||||
},
|
||||
);
|
||||
|
||||
@ -3,8 +3,10 @@ part of '../spotify.dart';
|
||||
final artistIsFollowingProvider = FutureProvider.family(
|
||||
(ref, String artistId) async {
|
||||
final spotify = ref.watch(spotifyProvider);
|
||||
return spotify.me.checkFollowing(FollowingType.artist, [artistId]).then(
|
||||
(value) => value[artistId] ?? false,
|
||||
return spotify.invoke(
|
||||
(api) => api.me.checkFollowing(FollowingType.artist, [artistId]).then(
|
||||
(value) => value[artistId] ?? false,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@ -5,7 +5,9 @@ final relatedArtistsProvider = FutureProvider.autoDispose
|
||||
ref.cacheFor();
|
||||
|
||||
final spotify = ref.watch(spotifyProvider);
|
||||
final artists = await spotify.artists.relatedArtists(artistId);
|
||||
final artists = await spotify.invoke(
|
||||
(api) => api.artists.relatedArtists(artistId),
|
||||
);
|
||||
|
||||
return artists.toList();
|
||||
});
|
||||
|
||||
@ -7,7 +7,9 @@ final artistTopTracksProvider =
|
||||
|
||||
final spotify = ref.watch(spotifyProvider);
|
||||
final market = ref.watch(userPreferencesProvider.select((s) => s.market));
|
||||
final tracks = await spotify.artists.topTracks(artistId, market);
|
||||
final tracks = await spotify.invoke(
|
||||
(api) => api.artists.topTracks(artistId, market),
|
||||
);
|
||||
|
||||
return tracks.toList();
|
||||
},
|
||||
|
||||
@ -5,14 +5,16 @@ final categoriesProvider = FutureProvider(
|
||||
final spotify = ref.watch(spotifyProvider);
|
||||
final market = ref.watch(userPreferencesProvider.select((s) => s.market));
|
||||
final locale = ref.watch(userPreferencesProvider.select((s) => s.locale));
|
||||
final categories = await spotify.categories
|
||||
.list(
|
||||
country: market,
|
||||
locale: Intl.canonicalizedLocale(
|
||||
locale.toString(),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
final categories = await spotify.invoke(
|
||||
(api) => api.categories
|
||||
.list(
|
||||
country: market,
|
||||
locale: Intl.canonicalizedLocale(
|
||||
locale.toString(),
|
||||
),
|
||||
)
|
||||
.all(),
|
||||
);
|
||||
|
||||
return categories.toList()..shuffle();
|
||||
},
|
||||
|
||||
@ -32,7 +32,7 @@ class CategoryPlaylistsNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier<
|
||||
fetch(arg, offset, limit) async {
|
||||
final preferences = ref.read(userPreferencesProvider);
|
||||
final playlists = await Pages<PlaylistSimple?>(
|
||||
spotify,
|
||||
spotify.api,
|
||||
"v1/browse/categories/$arg/playlists?country=${preferences.market.name}&locale=${preferences.locale}",
|
||||
(json) => json == null ? null : PlaylistSimple.fromJson(json),
|
||||
'playlists',
|
||||
|
||||
@ -138,7 +138,7 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier<SubtitleSimple, Track?> {
|
||||
|
||||
SubtitleSimple? lyrics = cachedLyrics;
|
||||
|
||||
final token = await spotify.getCredentials();
|
||||
final token = await spotify.invoke((api) => api.getCredentials());
|
||||
|
||||
if ((lyrics == null || lyrics.lyrics.isEmpty) && auth != null) {
|
||||
lyrics = await getSpotifyLyrics(token.accessToken);
|
||||
|
||||
@ -30,9 +30,11 @@ class FavoritePlaylistsNotifier
|
||||
|
||||
@override
|
||||
fetch(int offset, int limit) async {
|
||||
final playlists = await spotify.playlists.me.getPage(
|
||||
limit,
|
||||
offset,
|
||||
final playlists = await spotify.invoke(
|
||||
(api) => api.playlists.me.getPage(
|
||||
limit,
|
||||
offset,
|
||||
),
|
||||
);
|
||||
|
||||
return playlists.items?.toList() ?? [];
|
||||
@ -67,7 +69,9 @@ class FavoritePlaylistsNotifier
|
||||
|
||||
Future<void> addFavorite(PlaylistSimple playlist) async {
|
||||
await update((state) async {
|
||||
await spotify.playlists.followPlaylist(playlist.id!);
|
||||
await spotify.invoke(
|
||||
(api) => api.playlists.followPlaylist(playlist.id!),
|
||||
);
|
||||
return state.copyWith(
|
||||
items: [...state.items, playlist],
|
||||
);
|
||||
@ -78,7 +82,9 @@ class FavoritePlaylistsNotifier
|
||||
|
||||
Future<void> removeFavorite(PlaylistSimple playlist) async {
|
||||
await update((state) async {
|
||||
await spotify.playlists.unfollowPlaylist(playlist.id!);
|
||||
await spotify.invoke(
|
||||
(api) => api.playlists.unfollowPlaylist(playlist.id!),
|
||||
);
|
||||
return state.copyWith(
|
||||
items: state.items.where((e) => e.id != playlist.id).toList(),
|
||||
);
|
||||
@ -92,9 +98,11 @@ class FavoritePlaylistsNotifier
|
||||
|
||||
final spotify = ref.read(spotifyProvider);
|
||||
|
||||
await spotify.playlists.addTracks(
|
||||
trackIds.map((id) => 'spotify:track:$id').toList(),
|
||||
playlistId,
|
||||
await spotify.invoke(
|
||||
(api) => api.playlists.addTracks(
|
||||
trackIds.map((id) => 'spotify:track:$id').toList(),
|
||||
playlistId,
|
||||
),
|
||||
);
|
||||
|
||||
ref.invalidate(playlistTracksProvider(playlistId));
|
||||
@ -105,9 +113,11 @@ class FavoritePlaylistsNotifier
|
||||
|
||||
final spotify = ref.read(spotifyProvider);
|
||||
|
||||
await spotify.playlists.removeTracks(
|
||||
trackIds.map((id) => 'spotify:track:$id').toList(),
|
||||
playlistId,
|
||||
await spotify.invoke(
|
||||
(api) => api.playlists.removeTracks(
|
||||
trackIds.map((id) => 'spotify:track:$id').toList(),
|
||||
playlistId,
|
||||
),
|
||||
);
|
||||
|
||||
ref.invalidate(playlistTracksProvider(playlistId));
|
||||
@ -128,8 +138,8 @@ final isFavoritePlaylistProvider = FutureProvider.family<bool, String>(
|
||||
return false;
|
||||
}
|
||||
|
||||
final follows =
|
||||
await spotify.playlists.followedByUsers(id, [me.value!.id!]);
|
||||
final follows = await spotify
|
||||
.invoke((api) => api.playlists.followedByUsers(id, [me.value!.id!]));
|
||||
|
||||
return follows[me.value!.id!] ?? false;
|
||||
},
|
||||
|
||||
@ -30,9 +30,8 @@ class FeaturedPlaylistsNotifier
|
||||
|
||||
@override
|
||||
fetch(int offset, int limit) async {
|
||||
final playlists = await spotify.playlists.featured.getPage(
|
||||
limit,
|
||||
offset,
|
||||
final playlists = await spotify.invoke(
|
||||
(api) => api.playlists.featured.getPage(limit, offset),
|
||||
);
|
||||
|
||||
return playlists.items?.toList() ?? [];
|
||||
|
||||
@ -8,32 +8,36 @@ final generatePlaylistProvider = FutureProvider.autoDispose
|
||||
userPreferencesProvider.select((s) => s.market),
|
||||
);
|
||||
|
||||
final recommendation = await spotify.recommendations
|
||||
.get(
|
||||
limit: input.limit,
|
||||
seedArtists: input.seedArtists?.toList(),
|
||||
seedGenres: input.seedGenres?.toList(),
|
||||
seedTracks: input.seedTracks?.toList(),
|
||||
market: market,
|
||||
max: (input.max?.toJson()?..removeWhere((key, value) => value == null))
|
||||
?.cast<String, num>(),
|
||||
min: (input.min?.toJson()?..removeWhere((key, value) => value == null))
|
||||
?.cast<String, num>(),
|
||||
target: (input.target?.toJson()
|
||||
?..removeWhere((key, value) => value == null))
|
||||
?.cast<String, num>(),
|
||||
)
|
||||
.catchError((e, stackTrace) {
|
||||
AppLogger.reportError(e, stackTrace);
|
||||
return Recommendations();
|
||||
});
|
||||
final recommendation = await spotify.invoke(
|
||||
(api) => api.recommendations
|
||||
.get(
|
||||
limit: input.limit,
|
||||
seedArtists: input.seedArtists?.toList(),
|
||||
seedGenres: input.seedGenres?.toList(),
|
||||
seedTracks: input.seedTracks?.toList(),
|
||||
market: market,
|
||||
max: (input.max?.toJson()?..removeWhere((key, value) => value == null))
|
||||
?.cast<String, num>(),
|
||||
min: (input.min?.toJson()?..removeWhere((key, value) => value == null))
|
||||
?.cast<String, num>(),
|
||||
target: (input.target?.toJson()
|
||||
?..removeWhere((key, value) => value == null))
|
||||
?.cast<String, num>(),
|
||||
)
|
||||
.catchError((e, stackTrace) {
|
||||
AppLogger.reportError(e, stackTrace);
|
||||
return Recommendations();
|
||||
}),
|
||||
);
|
||||
|
||||
if (recommendation.tracks?.isEmpty ?? true) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final tracks = await spotify.tracks
|
||||
.list(recommendation.tracks!.map((e) => e.id!).toList());
|
||||
final tracks = await spotify.invoke(
|
||||
(api) =>
|
||||
api.tracks.list(recommendation.tracks!.map((e) => e.id!).toList()),
|
||||
);
|
||||
|
||||
return tracks.toList();
|
||||
},
|
||||
|
||||
@ -4,7 +4,9 @@ class LikedTracksNotifier extends AsyncNotifier<List<Track>> {
|
||||
@override
|
||||
FutureOr<List<Track>> build() async {
|
||||
final spotify = ref.watch(spotifyProvider);
|
||||
final savedTracked = await spotify.tracks.me.saved.all();
|
||||
final savedTracked = await spotify.invoke(
|
||||
(api) => api.tracks.me.saved.all(),
|
||||
);
|
||||
|
||||
return savedTracked.map((e) => e.track!).toList();
|
||||
}
|
||||
@ -17,10 +19,14 @@ class LikedTracksNotifier extends AsyncNotifier<List<Track>> {
|
||||
final isLiked = tracks.map((e) => e.id).contains(track.id);
|
||||
|
||||
if (isLiked) {
|
||||
await spotify.tracks.me.removeOne(track.id!);
|
||||
await spotify.invoke(
|
||||
(api) => api.tracks.me.removeOne(track.id!),
|
||||
);
|
||||
return tracks.where((e) => e.id != track.id).toList();
|
||||
} else {
|
||||
await spotify.tracks.me.saveOne(track.id!);
|
||||
await spotify.invoke(
|
||||
(api) => api.tracks.me.saveOne(track.id!),
|
||||
);
|
||||
return [track, ...tracks];
|
||||
}
|
||||
});
|
||||
|
||||
@ -12,7 +12,9 @@ class PlaylistNotifier extends FamilyAsyncNotifier<Playlist, String> {
|
||||
@override
|
||||
FutureOr<Playlist> build(String arg) {
|
||||
final spotify = ref.watch(spotifyProvider);
|
||||
return spotify.playlists.get(arg);
|
||||
return spotify.invoke(
|
||||
(api) => api.playlists.get(arg),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> create(PlaylistInput input, [ValueChanged? onError]) async {
|
||||
@ -26,18 +28,22 @@ class PlaylistNotifier extends FamilyAsyncNotifier<Playlist, String> {
|
||||
|
||||
state = await AsyncValue.guard(() async {
|
||||
try {
|
||||
final playlist = await spotify.playlists.createPlaylist(
|
||||
me.value!.id!,
|
||||
input.playlistName,
|
||||
collaborative: input.collaborative,
|
||||
description: input.description,
|
||||
public: input.public,
|
||||
final playlist = await spotify.invoke(
|
||||
(api) => api.playlists.createPlaylist(
|
||||
me.value!.id!,
|
||||
input.playlistName,
|
||||
collaborative: input.collaborative,
|
||||
description: input.description,
|
||||
public: input.public,
|
||||
),
|
||||
);
|
||||
|
||||
if (input.base64Image != null) {
|
||||
await spotify.playlists.updatePlaylistImage(
|
||||
playlist.id!,
|
||||
input.base64Image!,
|
||||
await spotify.invoke(
|
||||
(api) => api.playlists.updatePlaylistImage(
|
||||
playlist.id!,
|
||||
input.base64Image!,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -58,21 +64,27 @@ class PlaylistNotifier extends FamilyAsyncNotifier<Playlist, String> {
|
||||
|
||||
await update((state) async {
|
||||
try {
|
||||
await spotify.playlists.updatePlaylist(
|
||||
state.id!,
|
||||
input.playlistName,
|
||||
collaborative: input.collaborative,
|
||||
description: input.description,
|
||||
public: input.public,
|
||||
await spotify.invoke(
|
||||
(api) => api.playlists.updatePlaylist(
|
||||
state.id!,
|
||||
input.playlistName,
|
||||
collaborative: input.collaborative,
|
||||
description: input.description,
|
||||
public: input.public,
|
||||
),
|
||||
);
|
||||
|
||||
if (input.base64Image != null) {
|
||||
await spotify.playlists.updatePlaylistImage(
|
||||
state.id!,
|
||||
input.base64Image!,
|
||||
await spotify.invoke(
|
||||
(api) => api.playlists.updatePlaylistImage(
|
||||
state.id!,
|
||||
input.base64Image!,
|
||||
),
|
||||
);
|
||||
|
||||
final playlist = await spotify.playlists.get(state.id!);
|
||||
final playlist = await spotify.invoke(
|
||||
(api) => api.playlists.get(state.id!),
|
||||
);
|
||||
|
||||
ref.read(favoritePlaylistsProvider.notifier).updatePlaylist(playlist);
|
||||
return playlist;
|
||||
@ -105,9 +117,11 @@ class PlaylistNotifier extends FamilyAsyncNotifier<Playlist, String> {
|
||||
|
||||
final spotify = ref.read(spotifyProvider);
|
||||
|
||||
await spotify.playlists.addTracks(
|
||||
trackIds.map((id) => "spotify:track:$id").toList(),
|
||||
state.value!.id!,
|
||||
await spotify.invoke(
|
||||
(api) => api.playlists.addTracks(
|
||||
trackIds.map((id) => "spotify:track:$id").toList(),
|
||||
state.value!.id!,
|
||||
),
|
||||
);
|
||||
} catch (e, stack) {
|
||||
onError?.call(e);
|
||||
|
||||
@ -30,9 +30,9 @@ class PlaylistTracksNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier<
|
||||
|
||||
@override
|
||||
fetch(arg, offset, limit) async {
|
||||
final tracks = await spotify.playlists
|
||||
.getTracksByPlaylistId(arg)
|
||||
.getPage(limit, offset);
|
||||
final tracks = await spotify.invoke(
|
||||
(api) => api.playlists.getTracksByPlaylistId(arg).getPage(limit, offset),
|
||||
);
|
||||
|
||||
/// Filter out tracks with null id because some personal playlists
|
||||
/// may contain local tracks that are not available in the Spotify catalog
|
||||
|
||||
@ -44,13 +44,15 @@ class SearchNotifier<Y> extends AutoDisposeFamilyPaginatedAsyncNotifier<Y,
|
||||
nextOffset: 0,
|
||||
);
|
||||
}
|
||||
final results = await spotify.search
|
||||
.get(
|
||||
ref.read(searchTermStateProvider),
|
||||
types: [arg],
|
||||
market: ref.read(userPreferencesProvider).market,
|
||||
)
|
||||
.getPage(limit, offset);
|
||||
final results = await spotify.invoke(
|
||||
(api) => api.search
|
||||
.get(
|
||||
ref.read(searchTermStateProvider),
|
||||
types: [arg],
|
||||
market: ref.read(userPreferencesProvider).market,
|
||||
)
|
||||
.getPage(limit, offset),
|
||||
);
|
||||
|
||||
final items = results.expand((e) => e.items ?? <Y>[]).toList().cast<Y>();
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import 'dart:math';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:spotube/collections/assets.gen.dart';
|
||||
import 'package:spotube/collections/env.dart';
|
||||
import 'package:spotube/models/database/database.dart';
|
||||
import 'package:spotube/provider/authentication/authentication.dart';
|
||||
import 'package:spotube/provider/database/database.dart';
|
||||
@ -25,10 +26,10 @@ import 'package:spotube/models/lyrics.dart';
|
||||
import 'package:spotube/models/spotify/recommendation_seeds.dart';
|
||||
import 'package:spotube/models/spotify_friends.dart';
|
||||
import 'package:spotube/provider/custom_spotify_endpoint_provider.dart';
|
||||
import 'package:spotube/provider/spotify_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||
import 'package:spotube/services/dio/dio.dart';
|
||||
import 'package:spotube/services/wikipedia/wikipedia.dart';
|
||||
import 'package:spotube/utils/primitive_utils.dart';
|
||||
|
||||
import 'package:wikipedia_api/wikipedia_api.dart';
|
||||
|
||||
@ -76,3 +77,57 @@ part 'utils/provider/paginated.dart';
|
||||
part 'utils/provider/cursor.dart';
|
||||
part 'utils/provider/paginated_family.dart';
|
||||
part 'utils/provider/cursor_family.dart';
|
||||
|
||||
class SpotifyApiWrapper {
|
||||
final SpotifyApi api;
|
||||
|
||||
final Ref ref;
|
||||
SpotifyApiWrapper(
|
||||
this.ref,
|
||||
this.api,
|
||||
);
|
||||
|
||||
bool _isRefreshing = false;
|
||||
|
||||
FutureOr<T> invoke<T>(
|
||||
FutureOr<T> Function(SpotifyApi api) fn,
|
||||
) async {
|
||||
try {
|
||||
return await fn(api);
|
||||
} catch (e) {
|
||||
if (((e is AuthorizationException && e.error == 'invalid_token') ||
|
||||
e is ExpirationException) &&
|
||||
!_isRefreshing) {
|
||||
_isRefreshing = true;
|
||||
await ref.read(authenticationProvider.notifier).refreshCredentials();
|
||||
|
||||
_isRefreshing = false;
|
||||
return await fn(api);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final spotifyProvider = Provider<SpotifyApiWrapper>(
|
||||
(ref) {
|
||||
final authState = ref.watch(authenticationProvider);
|
||||
final anonCred = PrimitiveUtils.getRandomElement(Env.spotifySecrets);
|
||||
|
||||
final wrapper = SpotifyApiWrapper(
|
||||
ref,
|
||||
authState.asData?.value == null
|
||||
? SpotifyApi(
|
||||
SpotifyApiCredentials(
|
||||
anonCred["clientId"],
|
||||
anonCred["clientSecret"],
|
||||
),
|
||||
)
|
||||
: SpotifyApi.withAccessToken(
|
||||
authState.asData!.value!.accessToken.value,
|
||||
),
|
||||
);
|
||||
|
||||
return wrapper;
|
||||
},
|
||||
);
|
||||
|
||||
@ -6,5 +6,5 @@ final trackProvider =
|
||||
|
||||
final spotify = ref.watch(spotifyProvider);
|
||||
|
||||
return spotify.tracks.get(id);
|
||||
return spotify.invoke((api) => api.tracks.get(id));
|
||||
});
|
||||
|
||||
@ -2,5 +2,5 @@ part of '../spotify.dart';
|
||||
|
||||
final meProvider = FutureProvider<User>((ref) async {
|
||||
final spotify = ref.watch(spotifyProvider);
|
||||
return spotify.me.get();
|
||||
return spotify.invoke((api) => api.me.get());
|
||||
});
|
||||
|
||||
@ -2,7 +2,7 @@ part of '../spotify.dart';
|
||||
|
||||
// ignore: invalid_use_of_internal_member
|
||||
mixin SpotifyMixin<T> on AsyncNotifierBase<T> {
|
||||
SpotifyApi get spotify => ref.read(spotifyProvider);
|
||||
SpotifyApiWrapper get spotify => ref.read(spotifyProvider);
|
||||
}
|
||||
|
||||
extension on AutoDisposeAsyncNotifierProviderRef {
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/collections/env.dart';
|
||||
|
||||
import 'package:spotube/provider/authentication/authentication.dart';
|
||||
import 'package:spotube/utils/primitive_utils.dart';
|
||||
|
||||
final spotifyProvider = Provider<SpotifyApi>((ref) {
|
||||
final authState = ref.watch(authenticationProvider);
|
||||
final anonCred = PrimitiveUtils.getRandomElement(Env.spotifySecrets);
|
||||
|
||||
if (authState.asData?.value == null) {
|
||||
return SpotifyApi(
|
||||
SpotifyApiCredentials(
|
||||
anonCred["clientId"],
|
||||
anonCred["clientSecret"],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SpotifyApi.withAccessToken(authState.asData!.value!.accessToken.value);
|
||||
});
|
||||
@ -1,5 +1,4 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:media_kit/media_kit.dart' hide Track;
|
||||
import 'package:spotube/services/logger/logger.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
@ -7,9 +6,7 @@ import 'package:spotify/spotify.dart' hide Playlist;
|
||||
import 'package:spotube/models/local_track.dart';
|
||||
import 'package:spotube/services/audio_player/custom_player.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:media_kit/media_kit.dart' as mk;
|
||||
|
||||
import 'package:spotube/services/audio_player/playback_state.dart';
|
||||
import 'package:spotube/services/sourced_track/sourced_track.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
@ -17,40 +14,40 @@ import 'package:spotube/utils/platform.dart';
|
||||
part 'audio_players_streams_mixin.dart';
|
||||
part 'audio_player_impl.dart';
|
||||
|
||||
// Constants class for shared constants like port and addresses
|
||||
class Constants {
|
||||
static const defaultServerPort = 8080;
|
||||
static const defaultLocalHost = "localhost";
|
||||
}
|
||||
|
||||
// Helper to get network address based on the platform
|
||||
String getNetworkAddress() {
|
||||
return kIsWindows ? Constants.defaultLocalHost : InternetAddress.anyIPv4.address;
|
||||
}
|
||||
|
||||
// Helper to get URI for a given track
|
||||
String getUriForTrack(Track track, int serverPort) {
|
||||
return track is LocalTrack
|
||||
? track.path
|
||||
: "http://${getNetworkAddress()}:$serverPort/stream/${track.id}";
|
||||
}
|
||||
|
||||
// SpotubeMedia class handling media creation logic
|
||||
class SpotubeMedia extends mk.Media {
|
||||
final Track track;
|
||||
static int serverPort = Constants.defaultServerPort;
|
||||
|
||||
static int serverPort = 0;
|
||||
|
||||
SpotubeMedia(
|
||||
this.track, {
|
||||
Map<String, dynamic>? extras,
|
||||
super.httpHeaders,
|
||||
}) : super(
|
||||
track is LocalTrack
|
||||
? track.path
|
||||
: "http://${kIsWindows ? "localhost" : InternetAddress.anyIPv4.address}:$serverPort/stream/${track.id}",
|
||||
SpotubeMedia(this.track, {Map<String, dynamic>? extras, super.httpHeaders})
|
||||
: super(
|
||||
getUriForTrack(track, serverPort),
|
||||
extras: {
|
||||
...?extras,
|
||||
"track": switch (track) {
|
||||
LocalTrack() => track.toJson(),
|
||||
SourcedTrack() => track.toJson(),
|
||||
_ => track.toJson(),
|
||||
},
|
||||
"track": track.toJson(),
|
||||
},
|
||||
);
|
||||
|
||||
@override
|
||||
String get uri {
|
||||
return switch (track) {
|
||||
/// [super.uri] must be used instead of [track.path] to prevent wrong
|
||||
/// path format exceptions in Windows causing [extras] to be null
|
||||
LocalTrack() => super.uri,
|
||||
_ =>
|
||||
"http://${kIsWindows ? "localhost" : InternetAddress.anyIPv4.address}:"
|
||||
"$serverPort/stream/${track.id}",
|
||||
};
|
||||
}
|
||||
String get uri => getUriForTrack(track, serverPort);
|
||||
|
||||
factory SpotubeMedia.fromMedia(mk.Media media) {
|
||||
final track = media.uri.startsWith("http")
|
||||
@ -62,102 +59,100 @@ class SpotubeMedia extends mk.Media {
|
||||
httpHeaders: media.httpHeaders,
|
||||
);
|
||||
}
|
||||
|
||||
// @override
|
||||
// operator ==(Object other) {
|
||||
// if (other is! SpotubeMedia) return false;
|
||||
|
||||
// final isLocal = track is LocalTrack && other.track is LocalTrack;
|
||||
// return isLocal
|
||||
// ? (other.track as LocalTrack).path == (track as LocalTrack).path
|
||||
// : other.track.id == track.id;
|
||||
// }
|
||||
|
||||
// @override
|
||||
// int get hashCode => track is LocalTrack
|
||||
// ? (track as LocalTrack).path.hashCode
|
||||
// : track.id.hashCode;
|
||||
}
|
||||
|
||||
abstract class AudioPlayerInterface {
|
||||
final CustomPlayer _mkPlayer;
|
||||
// Factory class to create SpotubeMedia instances
|
||||
class SpotubeMediaFactory {
|
||||
static SpotubeMedia create(Track track, {Map<String, dynamic>? extras, Map<String, String>? headers}) {
|
||||
return SpotubeMedia(track, extras: extras, httpHeaders: headers);
|
||||
}
|
||||
}
|
||||
|
||||
AudioPlayerInterface()
|
||||
: _mkPlayer = CustomPlayer(
|
||||
configuration: const mk.PlayerConfiguration(
|
||||
title: "Spotube",
|
||||
logLevel: kDebugMode ? mk.MPVLogLevel.info : mk.MPVLogLevel.error,
|
||||
),
|
||||
) {
|
||||
_mkPlayer.stream.error.listen((event) {
|
||||
// Playback state management class
|
||||
class PlaybackStateManager {
|
||||
final CustomPlayer player;
|
||||
|
||||
PlaybackStateManager(this.player);
|
||||
|
||||
bool get isPlaying => player.state.playing;
|
||||
bool get isPaused => !player.state.playing;
|
||||
bool get isStopped => player.state.playlist.medias.isEmpty;
|
||||
|
||||
Duration get duration => player.state.duration;
|
||||
Duration get position => player.state.position;
|
||||
Duration get bufferedPosition => player.state.buffer;
|
||||
bool get isShuffled => player.shuffled;
|
||||
double get volume => player.state.volume / 100;
|
||||
|
||||
Future<List<mk.AudioDevice>> get devices async => player.state.audioDevices;
|
||||
Future<mk.AudioDevice> get selectedDevice async => player.state.audioDevice;
|
||||
|
||||
PlaylistMode get loopMode => player.state.playlistMode;
|
||||
}
|
||||
|
||||
// Main AudioPlayerInterface class with DI and error handling
|
||||
abstract class AudioPlayerInterface {
|
||||
final CustomPlayer player;
|
||||
final PlaybackStateManager stateManager;
|
||||
|
||||
AudioPlayerInterface(this.player)
|
||||
: stateManager = PlaybackStateManager(player) {
|
||||
player.stream.error.listen((event) {
|
||||
AppLogger.reportError(event, StackTrace.current);
|
||||
// Retry or fallback mechanism can be added here
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether the current platform supports the audioplayers plugin
|
||||
static const bool _mkSupportedPlatform = true;
|
||||
|
||||
bool get mkSupportedPlatform => _mkSupportedPlatform;
|
||||
|
||||
Duration get duration {
|
||||
return _mkPlayer.state.duration;
|
||||
// High-level control methods for playback
|
||||
Future<void> play() async {
|
||||
try {
|
||||
await player.play();
|
||||
} catch (e) {
|
||||
AppLogger.reportError(e, StackTrace.current);
|
||||
}
|
||||
}
|
||||
|
||||
Playlist get playlist {
|
||||
return _mkPlayer.state.playlist;
|
||||
Future<void> pause() async {
|
||||
try {
|
||||
await player.pause();
|
||||
} catch (e) {
|
||||
AppLogger.reportError(e, StackTrace.current);
|
||||
}
|
||||
}
|
||||
|
||||
Duration get position {
|
||||
return _mkPlayer.state.position;
|
||||
Future<void> stop() async {
|
||||
try {
|
||||
await player.stop();
|
||||
} catch (e) {
|
||||
AppLogger.reportError(e, StackTrace.current);
|
||||
}
|
||||
}
|
||||
|
||||
Duration get bufferedPosition {
|
||||
return _mkPlayer.state.buffer;
|
||||
Future<void> seek(Duration position) async {
|
||||
try {
|
||||
await player.seek(position);
|
||||
} catch (e) {
|
||||
AppLogger.reportError(e, StackTrace.current);
|
||||
}
|
||||
}
|
||||
|
||||
Future<mk.AudioDevice> get selectedDevice async {
|
||||
return _mkPlayer.state.audioDevice;
|
||||
}
|
||||
|
||||
Future<List<mk.AudioDevice>> get devices async {
|
||||
return _mkPlayer.state.audioDevices;
|
||||
}
|
||||
|
||||
bool get hasSource {
|
||||
return _mkPlayer.state.playlist.medias.isNotEmpty;
|
||||
}
|
||||
|
||||
// states
|
||||
bool get isPlaying {
|
||||
return _mkPlayer.state.playing;
|
||||
}
|
||||
|
||||
bool get isPaused {
|
||||
return !_mkPlayer.state.playing;
|
||||
}
|
||||
|
||||
bool get isStopped {
|
||||
return !hasSource;
|
||||
}
|
||||
|
||||
Future<bool> get isCompleted async {
|
||||
return _mkPlayer.state.completed;
|
||||
}
|
||||
|
||||
bool get isShuffled {
|
||||
return _mkPlayer.shuffled;
|
||||
}
|
||||
|
||||
PlaylistMode get loopMode {
|
||||
return _mkPlayer.state.playlistMode;
|
||||
}
|
||||
|
||||
/// Returns the current volume of the player, between 0 and 1
|
||||
double get volume {
|
||||
return _mkPlayer.state.volume / 100;
|
||||
}
|
||||
|
||||
bool get isBuffering {
|
||||
return _mkPlayer.state.buffering;
|
||||
}
|
||||
// Access state information through the state manager
|
||||
bool get isPlaying => stateManager.isPlaying;
|
||||
bool get isPaused => stateManager.isPaused;
|
||||
bool get isStopped => stateManager.isStopped;
|
||||
Duration get duration => stateManager.duration;
|
||||
Duration get position => stateManager.position;
|
||||
Duration get bufferedPosition => stateManager.bufferedPosition;
|
||||
bool get isShuffled => stateManager.isShuffled;
|
||||
double get volume => stateManager.volume;
|
||||
Future<List<mk.AudioDevice>> get devices => stateManager.devices;
|
||||
Future<mk.AudioDevice> get selectedDevice => stateManager.selectedDevice;
|
||||
PlaylistMode get loopMode => stateManager.loopMode;
|
||||
}
|
||||
|
||||
// Example implementation for a specific platform/player
|
||||
class MyAudioPlayer extends AudioPlayerInterface {
|
||||
MyAudioPlayer(CustomPlayer player) : super(player);
|
||||
|
||||
// Additional functionality can be added here if necessary
|
||||
}
|
||||
|
||||
@ -1,45 +1,28 @@
|
||||
part of 'audio_player.dart';
|
||||
|
||||
final audioPlayer = SpotubeAudioPlayer();
|
||||
|
||||
class SpotubeAudioPlayer extends AudioPlayerInterface
|
||||
with SpotubeAudioPlayersStreams {
|
||||
Future<void> pause() async {
|
||||
await _mkPlayer.pause();
|
||||
}
|
||||
class SpotubeAudioPlayer extends AudioPlayerInterface with SpotubeAudioPlayersStreams {
|
||||
// Playback control methods
|
||||
Future<void> pause() async => await player.pause();
|
||||
|
||||
Future<void> resume() async {
|
||||
await _mkPlayer.play();
|
||||
}
|
||||
Future<void> resume() async => await player.play();
|
||||
|
||||
Future<void> stop() async {
|
||||
await _mkPlayer.stop();
|
||||
}
|
||||
Future<void> stop() async => await player.stop();
|
||||
|
||||
Future<void> seek(Duration position) async {
|
||||
await _mkPlayer.seek(position);
|
||||
}
|
||||
Future<void> seek(Duration position) async => await player.seek(position);
|
||||
|
||||
/// Volume is between 0 and 1
|
||||
/// Set volume between 0 and 1
|
||||
Future<void> setVolume(double volume) async {
|
||||
assert(volume >= 0 && volume <= 1);
|
||||
await _mkPlayer.setVolume(volume * 100);
|
||||
await player.setVolume(volume * 100);
|
||||
}
|
||||
|
||||
Future<void> setSpeed(double speed) async {
|
||||
await _mkPlayer.setRate(speed);
|
||||
}
|
||||
Future<void> setSpeed(double speed) async => await player.setRate(speed);
|
||||
|
||||
Future<void> setAudioDevice(mk.AudioDevice device) async {
|
||||
await _mkPlayer.setAudioDevice(device);
|
||||
}
|
||||
Future<void> setAudioDevice(mk.AudioDevice device) async => await player.setAudioDevice(device);
|
||||
|
||||
Future<void> dispose() async {
|
||||
await _mkPlayer.dispose();
|
||||
}
|
||||
|
||||
// Playlist related
|
||||
Future<void> dispose() async => await player.dispose();
|
||||
|
||||
// Playlist control methods
|
||||
Future<void> openPlaylist(
|
||||
List<mk.Media> tracks, {
|
||||
bool autoPlay = true,
|
||||
@ -47,88 +30,59 @@ class SpotubeAudioPlayer extends AudioPlayerInterface
|
||||
}) async {
|
||||
assert(tracks.isNotEmpty);
|
||||
assert(initialIndex <= tracks.length - 1);
|
||||
await _mkPlayer.open(
|
||||
|
||||
await player.open(
|
||||
mk.Playlist(tracks, index: initialIndex),
|
||||
play: autoPlay,
|
||||
);
|
||||
}
|
||||
|
||||
List<String> get sources {
|
||||
return _mkPlayer.state.playlist.medias.map((e) => e.uri).toList();
|
||||
}
|
||||
// Helper methods for playlist sources
|
||||
List<String> get sources => player.state.playlist.medias.map((e) => e.uri).toList();
|
||||
|
||||
String? get currentSource {
|
||||
if (_mkPlayer.state.playlist.index == -1) return null;
|
||||
return _mkPlayer.state.playlist.medias
|
||||
.elementAtOrNull(_mkPlayer.state.playlist.index)
|
||||
?.uri;
|
||||
final index = player.state.playlist.index;
|
||||
if (index == -1) return null;
|
||||
return player.state.playlist.medias.elementAtOrNull(index)?.uri;
|
||||
}
|
||||
|
||||
String? get nextSource {
|
||||
if (loopMode == PlaylistMode.loop &&
|
||||
_mkPlayer.state.playlist.index ==
|
||||
_mkPlayer.state.playlist.medias.length - 1) {
|
||||
return sources.first;
|
||||
}
|
||||
final isLastTrack = player.state.playlist.index == player.state.playlist.medias.length - 1;
|
||||
if (loopMode == PlaylistMode.loop && isLastTrack) return sources.first;
|
||||
|
||||
return _mkPlayer.state.playlist.medias
|
||||
.elementAtOrNull(_mkPlayer.state.playlist.index + 1)
|
||||
?.uri;
|
||||
return player.state.playlist.medias.elementAtOrNull(player.state.playlist.index + 1)?.uri;
|
||||
}
|
||||
|
||||
String? get previousSource {
|
||||
if (loopMode == PlaylistMode.loop && _mkPlayer.state.playlist.index == 0) {
|
||||
return sources.last;
|
||||
}
|
||||
if (loopMode == PlaylistMode.loop && player.state.playlist.index == 0) return sources.last;
|
||||
|
||||
return _mkPlayer.state.playlist.medias
|
||||
.elementAtOrNull(_mkPlayer.state.playlist.index - 1)
|
||||
?.uri;
|
||||
return player.state.playlist.medias.elementAtOrNull(player.state.playlist.index - 1)?.uri;
|
||||
}
|
||||
|
||||
int get currentIndex => _mkPlayer.state.playlist.index;
|
||||
int get currentIndex => player.state.playlist.index;
|
||||
|
||||
Future<void> skipToNext() async {
|
||||
await _mkPlayer.next();
|
||||
}
|
||||
// Playlist navigation methods
|
||||
Future<void> skipToNext() async => await player.next();
|
||||
|
||||
Future<void> skipToPrevious() async {
|
||||
await _mkPlayer.previous();
|
||||
}
|
||||
Future<void> skipToPrevious() async => await player.previous();
|
||||
|
||||
Future<void> jumpTo(int index) async {
|
||||
await _mkPlayer.jump(index);
|
||||
}
|
||||
Future<void> jumpTo(int index) async => await player.jump(index);
|
||||
|
||||
Future<void> addTrack(mk.Media media) async {
|
||||
await _mkPlayer.add(media);
|
||||
}
|
||||
// Playlist management methods
|
||||
Future<void> addTrack(mk.Media media) async => await player.add(media);
|
||||
|
||||
Future<void> addTrackAt(mk.Media media, int index) async {
|
||||
await _mkPlayer.insert(index, media);
|
||||
}
|
||||
Future<void> addTrackAt(mk.Media media, int index) async => await player.insert(index, media);
|
||||
|
||||
Future<void> removeTrack(int index) async {
|
||||
await _mkPlayer.remove(index);
|
||||
}
|
||||
Future<void> removeTrack(int index) async => await player.remove(index);
|
||||
|
||||
Future<void> moveTrack(int from, int to) async {
|
||||
await _mkPlayer.move(from, to);
|
||||
}
|
||||
Future<void> moveTrack(int from, int to) async => await player.move(from, to);
|
||||
|
||||
Future<void> clearPlaylist() async {
|
||||
_mkPlayer.stop();
|
||||
}
|
||||
Future<void> clearPlaylist() async => await player.stop();
|
||||
|
||||
Future<void> setShuffle(bool shuffle) async {
|
||||
await _mkPlayer.setShuffle(shuffle);
|
||||
}
|
||||
// Shuffle and loop mode control
|
||||
Future<void> setShuffle(bool shuffle) async => await player.setShuffle(shuffle);
|
||||
|
||||
Future<void> setLoopMode(PlaylistMode loop) async {
|
||||
await _mkPlayer.setPlaylistMode(loop);
|
||||
}
|
||||
Future<void> setLoopMode(PlaylistMode loop) async => await player.setPlaylistMode(loop);
|
||||
|
||||
Future<void> setAudioNormalization(bool normalize) async {
|
||||
await _mkPlayer.setAudioNormalization(normalize);
|
||||
}
|
||||
Future<void> setAudioNormalization(bool normalize) async => await player.setAudioNormalization(normalize);
|
||||
}
|
||||
|
||||
@ -4,43 +4,52 @@ import 'package:media_kit/media_kit.dart';
|
||||
import 'package:flutter_broadcasts/flutter_broadcasts.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:audio_session/audio_session.dart';
|
||||
// ignore: implementation_imports
|
||||
import 'package:spotube/services/audio_player/playback_state.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
|
||||
/// MediaKit [Player] by default doesn't have a state stream.
|
||||
/// This class adds a state stream to the [Player] class.
|
||||
class CustomPlayer extends Player {
|
||||
final StreamController<AudioPlaybackState> _playerStateStream;
|
||||
final StreamController<bool> _shuffleStream;
|
||||
final StreamController<AudioPlaybackState> _playerStateStream = StreamController.broadcast();
|
||||
final StreamController<bool> _shuffleStream = StreamController.broadcast();
|
||||
|
||||
late final List<StreamSubscription> _subscriptions;
|
||||
|
||||
bool _shuffled;
|
||||
bool _shuffled = false;
|
||||
int _androidAudioSessionId = 0;
|
||||
String _packageName = "";
|
||||
AndroidAudioManager? _androidAudioManager;
|
||||
|
||||
CustomPlayer({super.configuration})
|
||||
: _playerStateStream = StreamController.broadcast(),
|
||||
_shuffleStream = StreamController.broadcast(),
|
||||
_shuffled = false {
|
||||
CustomPlayer({super.configuration}) {
|
||||
nativePlayer.setProperty("network-timeout", "120");
|
||||
_initPlatformSpecificSetup();
|
||||
_listenToPlayerEvents();
|
||||
}
|
||||
|
||||
Future<void> _initPlatformSpecificSetup() async {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
_packageName = packageInfo.packageName;
|
||||
|
||||
if (kIsAndroid) {
|
||||
_androidAudioManager = AndroidAudioManager();
|
||||
_androidAudioSessionId = await _androidAudioManager!.generateAudioSessionId();
|
||||
notifyAudioSessionUpdate(true);
|
||||
await _setAndroidAudioSession();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _setAndroidAudioSession() async {
|
||||
await nativePlayer.setProperty("audiotrack-session-id", _androidAudioSessionId.toString());
|
||||
await nativePlayer.setProperty("ao", "audiotrack,opensles,");
|
||||
}
|
||||
|
||||
void _listenToPlayerEvents() {
|
||||
_subscriptions = [
|
||||
stream.buffering.listen((event) {
|
||||
_playerStateStream.add(AudioPlaybackState.buffering);
|
||||
}),
|
||||
stream.buffering.listen((_) => _playerStateStream.add(AudioPlaybackState.buffering)),
|
||||
stream.playing.listen((playing) {
|
||||
if (playing) {
|
||||
_playerStateStream.add(AudioPlaybackState.playing);
|
||||
} else {
|
||||
_playerStateStream.add(AudioPlaybackState.paused);
|
||||
}
|
||||
_playerStateStream.add(playing ? AudioPlaybackState.playing : AudioPlaybackState.paused);
|
||||
}),
|
||||
stream.completed.listen((isCompleted) async {
|
||||
if (!isCompleted) return;
|
||||
_playerStateStream.add(AudioPlaybackState.completed);
|
||||
stream.completed.listen((isCompleted) {
|
||||
if (isCompleted) {
|
||||
_playerStateStream.add(AudioPlaybackState.completed);
|
||||
}
|
||||
}),
|
||||
stream.playlist.listen((event) {
|
||||
if (event.medias.isEmpty) {
|
||||
@ -51,23 +60,6 @@ class CustomPlayer extends Player {
|
||||
AppLogger.reportError('[MediaKitError] \n$event', StackTrace.current);
|
||||
}),
|
||||
];
|
||||
PackageInfo.fromPlatform().then((packageInfo) {
|
||||
_packageName = packageInfo.packageName;
|
||||
});
|
||||
if (kIsAndroid) {
|
||||
_androidAudioManager = AndroidAudioManager();
|
||||
AudioSession.instance.then((s) async {
|
||||
_androidAudioSessionId =
|
||||
await _androidAudioManager!.generateAudioSessionId();
|
||||
notifyAudioSessionUpdate(true);
|
||||
|
||||
await nativePlayer.setProperty(
|
||||
"audiotrack-session-id",
|
||||
_androidAudioSessionId.toString(),
|
||||
);
|
||||
await nativePlayer.setProperty("ao", "audiotrack,opensles,");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> notifyAudioSessionUpdate(bool active) async {
|
||||
@ -79,7 +71,7 @@ class CustomPlayer extends Player {
|
||||
: "android.media.action.CLOSE_AUDIO_EFFECT_CONTROL_SESSION",
|
||||
data: {
|
||||
"android.media.extra.AUDIO_SESSION": _androidAudioSessionId,
|
||||
"android.media.extra.PACKAGE_NAME": _packageName
|
||||
"android.media.extra.PACKAGE_NAME": _packageName,
|
||||
},
|
||||
),
|
||||
);
|
||||
@ -90,6 +82,7 @@ class CustomPlayer extends Player {
|
||||
|
||||
Stream<AudioPlaybackState> get playerStateStream => _playerStateStream.stream;
|
||||
Stream<bool> get shuffleStream => _shuffleStream.stream;
|
||||
|
||||
Stream<int> get indexChangeStream {
|
||||
int oldIndex = state.playlist.index;
|
||||
return stream.playlist.map((event) => event.index).where((newIndex) {
|
||||
@ -106,6 +99,8 @@ class CustomPlayer extends Player {
|
||||
_shuffled = shuffle;
|
||||
await super.setShuffle(shuffle);
|
||||
_shuffleStream.add(shuffle);
|
||||
|
||||
// Ensure delay before rearranging playlist
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
if (shuffle) {
|
||||
await move(state.playlist.index, 0);
|
||||
@ -115,7 +110,6 @@ class CustomPlayer extends Player {
|
||||
@override
|
||||
Future<void> stop() async {
|
||||
await super.stop();
|
||||
|
||||
_shuffled = false;
|
||||
_playerStateStream.add(AudioPlaybackState.stopped);
|
||||
_shuffleStream.add(false);
|
||||
@ -123,10 +117,10 @@ class CustomPlayer extends Player {
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
for (var element in _subscriptions) {
|
||||
element.cancel();
|
||||
}
|
||||
await Future.wait(_subscriptions.map((sub) => sub.cancel()));
|
||||
await notifyAudioSessionUpdate(false);
|
||||
await _playerStateStream.close();
|
||||
await _shuffleStream.close();
|
||||
return super.dispose();
|
||||
}
|
||||
|
||||
@ -138,10 +132,9 @@ class CustomPlayer extends Player {
|
||||
}
|
||||
|
||||
Future<void> setAudioNormalization(bool normalize) async {
|
||||
if (normalize) {
|
||||
await nativePlayer.setProperty('af', 'dynaudnorm=g=5:f=250:r=0.9:p=0.5');
|
||||
} else {
|
||||
await nativePlayer.setProperty('af', '');
|
||||
}
|
||||
await nativePlayer.setProperty(
|
||||
'af',
|
||||
normalize ? 'dynaudnorm=g=5:f=250:r=0.9:p=0.5' : ''
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,10 +42,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: app_links
|
||||
sha256: ad1a6d598e7e39b46a34f746f9a8b011ee147e4c275d407fa457e7a62f84dd99
|
||||
sha256: "85ed8fc1d25a76475914fff28cc994653bd900bc2c26e4b57a49e097febb54ba"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.2"
|
||||
version: "6.4.0"
|
||||
app_links_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@ -3,7 +3,7 @@ description: Open source Spotify client that doesn't require Premium nor uses El
|
||||
|
||||
publish_to: "none"
|
||||
|
||||
version: 4.0.1+40
|
||||
version: 4.0.2+41
|
||||
|
||||
homepage: https://spotube.krtirtho.dev
|
||||
repository: https://github.com/KRTirtho/spotube
|
||||
@ -13,7 +13,7 @@ environment:
|
||||
flutter: ">=3.29.0"
|
||||
|
||||
dependencies:
|
||||
app_links: ^6.3.2
|
||||
app_links: ^6.4.0
|
||||
args: ^2.5.0
|
||||
async: ^2.11.0
|
||||
audio_service: ^0.18.13
|
||||
|
||||
Loading…
Reference in New Issue
Block a user