From f07a142274280030635bbec1a92896af48101881 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 3 Jul 2022 08:24:46 +0600 Subject: [PATCH] Refactored Playback works nicely in Desktop --- lib/components/Album/AlbumCard.dart | 14 +- lib/components/Album/AlbumView.dart | 28 +- lib/components/Artist/ArtistProfile.dart | 21 +- lib/components/Lyrics/Lyrics.dart | 8 +- lib/components/Lyrics/SyncedLyrics.dart | 14 +- lib/components/Player/Player.dart | 24 +- lib/components/Player/PlayerActions.dart | 12 +- lib/components/Player/PlayerControls.dart | 24 +- lib/components/Player/PlayerTrackDetails.dart | 6 +- lib/components/Player/PlayerView.dart | 2 +- lib/components/Playlist/PlaylistCard.dart | 18 +- lib/components/Playlist/PlaylistView.dart | 27 +- lib/components/Search/Search.dart | 28 +- .../Shared/DownloadTrackButton.dart | 11 +- lib/components/Shared/TrackTile.dart | 5 +- lib/hooks/playback.dart | 10 +- lib/hooks/useSyncedLyrics.dart | 2 +- lib/interfaces/media_player2.dart | 9 +- lib/interfaces/media_player2_player.dart | 82 +-- lib/main.dart | 38 +- lib/provider/DBus.dart | 2 + lib/provider/LegacyPlayback.dart | 325 ++++++++++ lib/provider/Playback.dart | 560 ++++++++++-------- lib/provider/SpotifyRequests.dart | 6 +- lib/utils/AudioPlayerHandler.dart | 36 +- 25 files changed, 830 insertions(+), 482 deletions(-) create mode 100644 lib/provider/LegacyPlayback.dart diff --git a/lib/components/Album/AlbumCard.dart b/lib/components/Album/AlbumCard.dart index 5e838e0c..465d79e2 100644 --- a/lib/components/Album/AlbumCard.dart +++ b/lib/components/Album/AlbumCard.dart @@ -18,15 +18,15 @@ class AlbumCard extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { Playback playback = ref.watch(playbackProvider); - bool isPlaylistPlaying = playback.currentPlaylist != null && - playback.currentPlaylist!.id == album.id; + bool isPlaylistPlaying = + playback.playlist != null && playback.playlist!.id == album.id; final int marginH = useBreakpointValue(sm: 10, md: 15, lg: 20, xl: 20, xxl: 20); return PlaybuttonCard( imageUrl: imageToUrlString(album.images), margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()), - isPlaying: playback.currentPlaylist?.id != null && - playback.currentPlaylist?.id == album.id, + isPlaying: + playback.playlist?.id != null && playback.playlist?.id == album.id, title: album.name!, description: "Album • ${artistsToString(album.artists ?? [])}", @@ -41,14 +41,12 @@ class AlbumCard extends HookConsumerWidget { .toList(); if (tracks.isEmpty) return; - playback.setCurrentPlaylist = CurrentPlaylist( + await playback.playPlaylist(CurrentPlaylist( tracks: tracks, id: album.id!, name: album.name!, thumbnail: album.images!.first.url!, - ); - playback.setCurrentTrack = tracks.first; - await playback.startPlaying(); + )); }, ); } diff --git a/lib/components/Album/AlbumView.dart b/lib/components/Album/AlbumView.dart index 78c96093..deca5aac 100644 --- a/lib/components/Album/AlbumView.dart +++ b/lib/components/Album/AlbumView.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/Shared/HeartButton.dart'; @@ -18,24 +17,25 @@ class AlbumView extends HookConsumerWidget { final AlbumSimple album; const AlbumView(this.album, {Key? key}) : super(key: key); - playPlaylist(Playback playback, List tracks, + Future playPlaylist(Playback playback, List tracks, {Track? currentTrack}) async { currentTrack ??= tracks.first; - var isPlaylistPlaying = playback.currentPlaylist?.id == album.id; + final isPlaylistPlaying = playback.playlist?.id == album.id; if (!isPlaylistPlaying) { - playback.setCurrentPlaylist = CurrentPlaylist( - tracks: tracks, - id: album.id!, - name: album.name!, - thumbnail: imageToUrlString(album.images), + await playback.playPlaylist( + CurrentPlaylist( + tracks: tracks, + id: album.id!, + name: album.name!, + thumbnail: imageToUrlString(album.images), + ), + tracks.indexWhere((s) => s.id == currentTrack?.id), ); - playback.setCurrentTrack = currentTrack; } else if (isPlaylistPlaying && currentTrack.id != null && - currentTrack.id != playback.currentTrack?.id) { - playback.setCurrentTrack = currentTrack; + currentTrack.id != playback.track?.id) { + await playback.play(currentTrack); } - await playback.startPlaying(); } @override @@ -54,8 +54,8 @@ class AlbumView extends HookConsumerWidget { return TrackCollectionView( id: album.id!, - isPlaying: playback.currentPlaylist?.id != null && - playback.currentPlaylist?.id == album.id, + isPlaying: + playback.playlist?.id != null && playback.playlist?.id == album.id, title: album.name!, titleImage: albumArt, tracksSnapshot: tracksSnapshot, diff --git a/lib/components/Artist/ArtistProfile.dart b/lib/components/Artist/ArtistProfile.dart index 1c7cb382..8501fa0b 100644 --- a/lib/components/Artist/ArtistProfile.dart +++ b/lib/components/Artist/ArtistProfile.dart @@ -183,24 +183,25 @@ class ArtistProfile extends HookConsumerWidget { topTracksSnapshot.when( data: (topTracks) { final isPlaylistPlaying = - playback.currentPlaylist?.id == data.id; + playback.playlist?.id == data.id; playPlaylist(List tracks, {Track? currentTrack}) async { currentTrack ??= tracks.first; if (!isPlaylistPlaying) { - playback.setCurrentPlaylist = CurrentPlaylist( - tracks: tracks, - id: data.id!, - name: "${data.name!} To Tracks", - thumbnail: imageToUrlString(data.images), + await playback.playPlaylist( + CurrentPlaylist( + tracks: tracks, + id: data.id!, + name: "${data.name!} To Tracks", + thumbnail: imageToUrlString(data.images), + ), + tracks.indexWhere((s) => s.id == currentTrack?.id), ); - playback.setCurrentTrack = currentTrack; } else if (isPlaylistPlaying && currentTrack.id != null && - currentTrack.id != playback.currentTrack?.id) { - playback.setCurrentTrack = currentTrack; + currentTrack.id != playback.track?.id) { + await playback.play(currentTrack); } - await playback.startPlaying(); } return Column(children: [ diff --git a/lib/components/Lyrics/Lyrics.dart b/lib/components/Lyrics/Lyrics.dart index a42c6872..ce3687ef 100644 --- a/lib/components/Lyrics/Lyrics.dart +++ b/lib/components/Lyrics/Lyrics.dart @@ -23,7 +23,7 @@ class Lyrics extends HookConsumerWidget { children: [ Center( child: Text( - playback.currentTrack?.name ?? "", + playback.track?.name ?? "", style: breakpoint >= Breakpoints.md ? textTheme.headline3 : textTheme.headline4?.copyWith(fontSize: 25), @@ -31,7 +31,7 @@ class Lyrics extends HookConsumerWidget { ), Center( child: Text( - artistsToString(playback.currentTrack?.artists ?? []), + artistsToString(playback.track?.artists ?? []), style: breakpoint >= Breakpoints.md ? textTheme.headline5 : textTheme.headline6, @@ -45,7 +45,7 @@ class Lyrics extends HookConsumerWidget { child: geniusLyricsSnapshot.when( data: (lyrics) { return Text( - lyrics == null && playback.currentTrack == null + lyrics == null && playback.track == null ? "No Track being played currently" : lyrics!, style: textTheme.headline6 @@ -53,7 +53,7 @@ class Lyrics extends HookConsumerWidget { ); }, error: (error, __) => Text( - "Sorry, no Lyrics were found for `${playback.currentTrack?.name}` :'("), + "Sorry, no Lyrics were found for `${playback.track?.name}` :'("), loading: () => const ShimmerLyrics(), ), ), diff --git a/lib/components/Lyrics/SyncedLyrics.dart b/lib/components/Lyrics/SyncedLyrics.dart index 62ece941..92f1be61 100644 --- a/lib/components/Lyrics/SyncedLyrics.dart +++ b/lib/components/Lyrics/SyncedLyrics.dart @@ -43,7 +43,7 @@ class SyncedLyrics extends HookConsumerWidget { controller.scrollToIndex(0); failed.value = false; return null; - }, [playback.currentTrack]); + }, [playback.track]); useEffect(() { if (lyricValue != null && lyricValue.rating <= 2) { @@ -99,20 +99,20 @@ class SyncedLyrics extends HookConsumerWidget { Center( child: SizedBox( height: breakpoint >= Breakpoints.md ? 50 : 30, - child: playback.currentTrack?.name != null && - playback.currentTrack!.name!.length > 29 + child: playback.track?.name != null && + playback.track!.name!.length > 29 ? SpotubeMarqueeText( - text: playback.currentTrack?.name ?? "Not Playing", + text: playback.track?.name ?? "Not Playing", style: headlineTextStyle, ) : Text( - playback.currentTrack?.name ?? "Not Playing", + playback.track?.name ?? "Not Playing", style: headlineTextStyle, ), )), Center( child: Text( - artistsToString(playback.currentTrack?.artists ?? []), + artistsToString(playback.track?.artists ?? []), style: breakpoint >= Breakpoints.md ? textTheme.headline5 : textTheme.headline6, @@ -157,7 +157,7 @@ class SyncedLyrics extends HookConsumerWidget { }, ), ), - if (playback.currentTrack != null && + if (playback.track != null && (lyricValue == null || lyricValue.lyrics.isEmpty == true)) const Expanded(child: ShimmerLyrics()), ], diff --git a/lib/components/Player/Player.dart b/lib/components/Player/Player.dart index 5757bd9b..329b3b34 100644 --- a/lib/components/Player/Player.dart +++ b/lib/components/Player/Player.dart @@ -24,12 +24,8 @@ class Player extends HookConsumerWidget { Widget build(BuildContext context, ref) { Playback playback = ref.watch(playbackProvider); - final _volume = useState(0.0); - final breakpoint = useBreakpoints(); - final AudioPlayerHandler player = playback.player; - final Future future = useMemoized(SharedPreferences.getInstance); final AsyncSnapshot localStorage = @@ -37,10 +33,10 @@ class Player extends HookConsumerWidget { String albumArt = useMemoized( () => imageToUrlString( - playback.currentTrack?.album?.images, - index: (playback.currentTrack?.album?.images?.length ?? 1) - 1, + playback.track?.album?.images, + index: (playback.track?.album?.images?.length ?? 1) - 1, ), - [playback.currentTrack?.album?.images], + [playback.track?.album?.images], ); final entryRef = useRef(null); @@ -65,7 +61,7 @@ class Player extends HookConsumerWidget { // entry will result in splashing while resizing the window if (breakpoint.isLessThanOrEqualTo(Breakpoints.md) && entryRef.value == null && - playback.currentTrack != null) { + playback.track != null) { entryRef.value = OverlayEntry( opaque: false, builder: (context) => PlayerOverlay(albumArt: albumArt), @@ -87,7 +83,7 @@ class Player extends HookConsumerWidget { return () { disposeOverlay(); }; - }, [breakpoint, playback.currentTrack]); + }, [breakpoint, playback.track]); // returning an empty non spacious Container as the overlay will take // place in the global overlay stack aka [_entries] @@ -119,16 +115,10 @@ class Player extends HookConsumerWidget { height: 20, constraints: const BoxConstraints(maxWidth: 200), child: Slider.adaptive( - value: _volume.value, + value: playback.volume, onChanged: (value) async { try { - await player.core.setVolume(value).then((_) { - _volume.value = value; - localStorage.data?.setDouble( - LocalStorageKeys.volume, - value, - ); - }); + await playback.setVolume(value); } catch (e, stack) { logger.e("onChange", e, stack); } diff --git a/lib/components/Player/PlayerActions.dart b/lib/components/Player/PlayerActions.dart index abe315f3..3a7bfa72 100644 --- a/lib/components/Player/PlayerActions.dart +++ b/lib/components/Player/PlayerActions.dart @@ -28,12 +28,12 @@ class PlayerActions extends HookConsumerWidget { mainAxisAlignment: mainAxisAlignment, children: [ DownloadTrackButton( - track: playback.currentTrack, + track: playback.track, ), if (auth.isLoggedIn) FutureBuilder( - future: playback.currentTrack?.id != null - ? spotifyApi.tracks.me.containsOne(playback.currentTrack!.id!) + future: playback.track?.id != null + ? spotifyApi.tracks.me.containsOne(playback.track!.id!) : Future.value(false), initialData: false, builder: (context, snapshot) { @@ -42,12 +42,12 @@ class PlayerActions extends HookConsumerWidget { isLiked: isLiked, onPressed: () async { try { - if (playback.currentTrack?.id == null) return; + if (playback.track?.id == null) return; isLiked ? await spotifyApi.tracks.me - .removeOne(playback.currentTrack!.id!) + .removeOne(playback.track!.id!) : await spotifyApi.tracks.me - .saveOne(playback.currentTrack!.id!); + .saveOne(playback.track!.id!); } catch (e, stack) { logger.e("FavoriteButton.onPressed", e, stack); } finally { diff --git a/lib/components/Player/PlayerControls.dart b/lib/components/Player/PlayerControls.dart index 3d30ce69..6a70a7fb 100644 --- a/lib/components/Player/PlayerControls.dart +++ b/lib/components/Player/PlayerControls.dart @@ -18,7 +18,6 @@ class PlayerControls extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final Playback playback = ref.watch(playbackProvider); - final AudioPlayerHandler player = playback.player; final onNext = useNextTrack(playback); @@ -26,14 +25,14 @@ class PlayerControls extends HookConsumerWidget { final _playOrPause = useTogglePlayPause(playback); - final duration = playback.duration ?? Duration.zero; + final duration = playback.currentDuration; return Container( constraints: const BoxConstraints(maxWidth: 600), child: Column( children: [ StreamBuilder( - stream: player.core.onPositionChanged, + stream: playback.player.onPositionChanged, builder: (context, snapshot) { final totalMinutes = zeroPadNumStr(duration.inMinutes.remainder(60)); @@ -61,7 +60,7 @@ class PlayerControls extends HookConsumerWidget { value: value.toDouble(), onChanged: (_) {}, onChangeEnd: (value) async { - await player.seek( + await playback.seekPosition( Duration( seconds: (value * sliderMax).toInt(), ), @@ -89,20 +88,15 @@ class PlayerControls extends HookConsumerWidget { children: [ IconButton( icon: const Icon(Icons.shuffle_rounded), - color: playback.shuffled + color: playback.isShuffled ? Theme.of(context).primaryColor : iconColor, onPressed: () { - if (playback.currentTrack == null || - playback.currentPlaylist == null) { + if (playback.track == null || playback.playlist == null) { return; } try { - if (!playback.shuffled) { - playback.shuffle(); - } else { - playback.unshuffle(); - } + playback.toggleShuffle(); } catch (e, stack) { logger.e("onShuffle", e, stack); } @@ -130,12 +124,10 @@ class PlayerControls extends HookConsumerWidget { IconButton( icon: const Icon(Icons.stop_rounded), color: iconColor, - onPressed: playback.currentTrack != null + onPressed: playback.track != null ? () async { try { - await player.pause(); - await player.seek(Duration.zero); - playback.reset(); + await playback.stop(); } catch (e, stack) { logger.e("onStop", e, stack); } diff --git a/lib/components/Player/PlayerTrackDetails.dart b/lib/components/Player/PlayerTrackDetails.dart index eb0b25ab..83370719 100644 --- a/lib/components/Player/PlayerTrackDetails.dart +++ b/lib/components/Player/PlayerTrackDetails.dart @@ -38,7 +38,7 @@ class PlayerTrackDetails extends HookConsumerWidget { if (breakpoint.isLessThanOrEqualTo(Breakpoints.md)) Flexible( child: Text( - playback.currentTrack?.name ?? "Not playing", + playback.track?.name ?? "Not playing", overflow: TextOverflow.ellipsis, style: Theme.of(context) .textTheme @@ -54,7 +54,7 @@ class PlayerTrackDetails extends HookConsumerWidget { child: Column( children: [ Text( - playback.currentTrack?.name ?? "Not playing", + playback.track?.name ?? "Not playing", overflow: TextOverflow.ellipsis, style: Theme.of(context) .textTheme @@ -62,7 +62,7 @@ class PlayerTrackDetails extends HookConsumerWidget { ?.copyWith(fontWeight: FontWeight.bold, color: color), ), artistsToClickableArtists( - playback.currentTrack?.artists ?? [], + playback.track?.artists ?? [], ) ], ), diff --git a/lib/components/Player/PlayerView.dart b/lib/components/Player/PlayerView.dart index 6d580bc3..3ea3f721 100644 --- a/lib/components/Player/PlayerView.dart +++ b/lib/components/Player/PlayerView.dart @@ -23,7 +23,7 @@ class PlayerView extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final currentTrack = ref.watch(playbackProvider.select( - (value) => value.currentTrack, + (value) => value.track, )); final breakpoint = useBreakpoints(); diff --git a/lib/components/Playlist/PlaylistCard.dart b/lib/components/Playlist/PlaylistCard.dart index a7ac5132..135af4f9 100644 --- a/lib/components/Playlist/PlaylistCard.dart +++ b/lib/components/Playlist/PlaylistCard.dart @@ -15,8 +15,8 @@ class PlaylistCard extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { Playback playback = ref.watch(playbackProvider); - bool isPlaylistPlaying = playback.currentPlaylist != null && - playback.currentPlaylist!.id == playlist.id; + bool isPlaylistPlaying = + playback.playlist != null && playback.playlist!.id == playlist.id; final int marginH = useBreakpointValue(sm: 10, md: 15, lg: 20, xl: 20, xxl: 20); @@ -46,14 +46,14 @@ class PlaylistCard extends HookConsumerWidget { if (tracks.isEmpty) return; - playback.setCurrentPlaylist = CurrentPlaylist( - tracks: tracks, - id: playlist.id!, - name: playlist.name!, - thumbnail: imageToUrlString(playlist.images), + await playback.playPlaylist( + CurrentPlaylist( + tracks: tracks, + id: playlist.id!, + name: playlist.name!, + thumbnail: imageToUrlString(playlist.images), + ), ); - playback.setCurrentTrack = tracks.first; - await playback.startPlaying(); }, ); } diff --git a/lib/components/Playlist/PlaylistView.dart b/lib/components/Playlist/PlaylistView.dart index 203f1948..df1be1df 100644 --- a/lib/components/Playlist/PlaylistView.dart +++ b/lib/components/Playlist/PlaylistView.dart @@ -24,22 +24,23 @@ class PlaylistView extends HookConsumerWidget { playPlaylist(Playback playback, List tracks, {Track? currentTrack}) async { currentTrack ??= tracks.first; - final isPlaylistPlaying = playback.currentPlaylist?.id != null && - playback.currentPlaylist?.id == playlist.id; + final isPlaylistPlaying = + playback.playlist?.id != null && playback.playlist?.id == playlist.id; if (!isPlaylistPlaying) { - playback.setCurrentPlaylist = CurrentPlaylist( - tracks: tracks, - id: playlist.id!, - name: playlist.name!, - thumbnail: imageToUrlString(playlist.images), + await playback.playPlaylist( + CurrentPlaylist( + tracks: tracks, + id: playlist.id!, + name: playlist.name!, + thumbnail: imageToUrlString(playlist.images), + ), + tracks.indexWhere((s) => s.id == currentTrack?.id), ); - playback.setCurrentTrack = currentTrack; } else if (isPlaylistPlaying && currentTrack.id != null && - currentTrack.id != playback.currentTrack?.id) { - playback.setCurrentTrack = currentTrack; + currentTrack.id != playback.track?.id) { + await playback.play(currentTrack); } - await playback.startPlaying(); } @override @@ -47,8 +48,8 @@ class PlaylistView extends HookConsumerWidget { Playback playback = ref.watch(playbackProvider); final Auth auth = ref.watch(authProvider); SpotifyApi spotify = ref.watch(spotifyProvider); - final isPlaylistPlaying = playback.currentPlaylist?.id != null && - playback.currentPlaylist?.id == playlist.id; + final isPlaylistPlaying = + playback.playlist?.id != null && playback.playlist?.id == playlist.id; final meSnapshot = ref.watch(currentUserQuery); final tracksSnapshot = ref.watch(playlistTracksQuery(playlist.id!)); diff --git a/lib/components/Search/Search.dart b/lib/components/Search/Search.dart index a83257eb..10320eef 100644 --- a/lib/components/Search/Search.dart +++ b/lib/components/Search/Search.dart @@ -115,26 +115,24 @@ class Search extends HookConsumerWidget { thumbnailUrl: imageToUrlString(track.value.album?.images), onTrackPlayButtonPressed: (currentTrack) async { - var isPlaylistPlaying = - playback.currentPlaylist?.id != null && - playback.currentPlaylist?.id == - currentTrack.id; + var isPlaylistPlaying = playback.playlist?.id != + null && + playback.playlist?.id == currentTrack.id; if (!isPlaylistPlaying) { - playback.setCurrentPlaylist = CurrentPlaylist( - tracks: [currentTrack], - id: currentTrack.id!, - name: currentTrack.name!, - thumbnail: imageToUrlString( - currentTrack.album?.images), + playback.playPlaylist( + CurrentPlaylist( + tracks: [currentTrack], + id: currentTrack.id!, + name: currentTrack.name!, + thumbnail: imageToUrlString( + currentTrack.album?.images), + ), ); - playback.setCurrentTrack = currentTrack; } else if (isPlaylistPlaying && currentTrack.id != null && - currentTrack.id != - playback.currentTrack?.id) { - playback.setCurrentTrack = currentTrack; + currentTrack.id != playback.track?.id) { + playback.play(currentTrack); } - await playback.startPlaying(); }, ); }), diff --git a/lib/components/Shared/DownloadTrackButton.dart b/lib/components/Shared/DownloadTrackButton.dart index 332f20fc..125ab68b 100644 --- a/lib/components/Shared/DownloadTrackButton.dart +++ b/lib/components/Shared/DownloadTrackButton.dart @@ -133,16 +133,13 @@ class DownloadTrackButton extends HookConsumerWidget { return statusCb.cancel(); }); - if (preferences.saveTrackLyrics && playback.currentTrack != null) { + if (preferences.saveTrackLyrics && playback.track != null) { if (!await outputLyricsFile.exists()) { await outputLyricsFile.create(recursive: true); } final lyrics = await getLyrics( - playback.currentTrack!.name!, - playback.currentTrack!.artists - ?.map((s) => s.name) - .whereNotNull() - .toList() ?? + playback.track!.name!, + playback.track!.artists?.map((s) => s.name).whereNotNull().toList() ?? [], apiKey: preferences.geniusAccessToken, optimizeQuery: true, @@ -159,7 +156,7 @@ class DownloadTrackButton extends HookConsumerWidget { status, yt, preferences.saveTrackLyrics, - playback.currentTrack, + playback.track, ]); useEffect(() { diff --git a/lib/components/Shared/TrackTile.dart b/lib/components/Shared/TrackTile.dart index 789985d0..f9fdcc94 100644 --- a/lib/components/Shared/TrackTile.dart +++ b/lib/components/Shared/TrackTile.dart @@ -84,7 +84,7 @@ class TrackTile extends HookConsumerWidget { }); } - actionAddToPlaylist() async { + Future actionAddToPlaylist() async { showDialog( context: context, builder: (context) { @@ -196,8 +196,7 @@ class TrackTile extends HookConsumerWidget { ), IconButton( icon: Icon( - playback.currentTrack?.id != null && - playback.currentTrack?.id == track.value.id + playback.track?.id != null && playback.track?.id == track.value.id ? Icons.pause_circle_rounded : Icons.play_circle_rounded, color: Theme.of(context).primaryColor, diff --git a/lib/hooks/playback.dart b/lib/hooks/playback.dart index 2dc4f035..07f1f3ae 100644 --- a/lib/hooks/playback.dart +++ b/lib/hooks/playback.dart @@ -8,7 +8,7 @@ Future Function() useNextTrack(Playback playback) { try { await playback.player.pause(); await playback.player.seek(Duration.zero); - playback.movePlaylistPositionBy(1); + playback.seekForward(); } catch (e, stack) { logger.e("useNextTrack", e, stack); } @@ -20,7 +20,7 @@ Future Function() usePreviousTrack(Playback playback) { try { await playback.player.pause(); await playback.player.seek(Duration.zero); - playback.movePlaylistPositionBy(-1); + playback.seekBackward(); } catch (e, stack) { logger.e("onPrevious", e, stack); } @@ -30,10 +30,8 @@ Future Function() usePreviousTrack(Playback playback) { Future Function([dynamic]) useTogglePlayPause(Playback playback) { return ([key]) async { try { - if (playback.currentTrack == null) return; - playback.isPlaying - ? await playback.player.pause() - : await playback.player.play(); + if (playback.track == null) return; + await playback.togglePlayPause(); } catch (e, stack) { logger.e("useTogglePlayPause", e, stack); } diff --git a/lib/hooks/useSyncedLyrics.dart b/lib/hooks/useSyncedLyrics.dart index c7f53d2b..5f18014c 100644 --- a/lib/hooks/useSyncedLyrics.dart +++ b/lib/hooks/useSyncedLyrics.dart @@ -6,7 +6,7 @@ useSyncedLyrics(WidgetRef ref, Map lyricsMap) { final player = ref.watch(playbackProvider.select( (value) => (value.player), )); - final stream = player.core.onPositionChanged; + final stream = player.onPositionChanged; final currentTime = useState(0); diff --git a/lib/interfaces/media_player2.dart b/lib/interfaces/media_player2.dart index cbacc307..44286db2 100644 --- a/lib/interfaces/media_player2.dart +++ b/lib/interfaces/media_player2.dart @@ -3,10 +3,17 @@ import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:dbus/dbus.dart'; +import 'package:spotube/provider/DBus.dart'; class Media_Player extends DBusObject { /// Creates a new object to expose on [path]. - Media_Player() : super(DBusObjectPath('/org/mpris/MediaPlayer2')); + Media_Player() : super(DBusObjectPath('/org/mpris/MediaPlayer2')) { + dbus.registerObject(this); + } + + void dispose() { + dbus.unregisterObject(this); + } /// Gets value of property org.mpris.MediaPlayer2.CanQuit Future getCanQuit() async { diff --git a/lib/interfaces/media_player2_player.dart b/lib/interfaces/media_player2_player.dart index f34a9bb7..a315636d 100644 --- a/lib/interfaces/media_player2_player.dart +++ b/lib/interfaces/media_player2_player.dart @@ -1,27 +1,40 @@ // This file was generated using the following command and may be overwritten. // dart-dbus generate-object defs/org.mpris.MediaPlayer2.Player.xml -import 'package:audioplayers/audioplayers.dart'; +import 'dart:io'; + import 'package:dbus/dbus.dart'; import 'package:spotube/helpers/image-to-url-string.dart'; import 'package:spotube/models/SpotubeTrack.dart'; import 'package:spotube/provider/Playback.dart'; +import 'package:spotube/provider/DBus.dart'; class Player_Interface extends DBusObject { - final AudioPlayer player; final Playback playback; /// Creates a new object to expose on [path]. Player_Interface({ - required this.player, required this.playback, - }) : super(DBusObjectPath("/org/mpris/MediaPlayer2")); + }) : super(DBusObjectPath("/org/mpris/MediaPlayer2")) { + (() async { + final nameStatus = + await dbus.requestName("org.mpris.MediaPlayer2.spotube"); + if (nameStatus == DBusRequestNameReply.exists) { + await dbus.requestName("org.mpris.MediaPlayer2.spotube.instance$pid"); + } + await dbus.registerObject(this); + }()); + } + + void dispose() { + dbus.unregisterObject(this); + } /// Gets value of property org.mpris.MediaPlayer2.Player.PlaybackStatus Future getPlaybackStatus() async { - final status = player.state == PlayerState.playing + final status = playback.isPlaying ? "Playing" - : playback.currentPlaylist == null + : playback.playlist == null ? "Stopped" : "Paused"; return DBusMethodSuccessResponse([DBusString(status)]); @@ -45,34 +58,29 @@ class Player_Interface extends DBusObject { /// Sets property org.mpris.MediaPlayer2.Player.Rate Future setRate(double value) async { - player.setPlaybackRate(value); return DBusMethodSuccessResponse(); } /// Gets value of property org.mpris.MediaPlayer2.Player.Shuffle Future getShuffle() async { - return DBusMethodSuccessResponse([DBusBoolean(playback.shuffled)]); + return DBusMethodSuccessResponse([DBusBoolean(playback.isShuffled)]); } /// Sets property org.mpris.MediaPlayer2.Player.Shuffle Future setShuffle(bool value) async { - if (value) { - playback.shuffle(); - } else { - playback.unshuffle(); - } + playback.toggleShuffle(); return DBusMethodSuccessResponse(); } /// Gets value of property org.mpris.MediaPlayer2.Player.Metadata Future getMetadata() async { try { - if (playback.currentTrack == null) { + if (playback.track == null) { return DBusMethodSuccessResponse([DBusDict.stringVariant({})]); } - final id = (playback.currentPlaylist != null - ? playback.currentPlaylist!.tracks.indexWhere( - (track) => playback.currentTrack!.id == track.id!, + final id = (playback.playlist != null + ? playback.playlist!.tracks.indexWhere( + (track) => playback.track!.id == track.id!, ) : 0) .abs(); @@ -80,18 +88,18 @@ class Player_Interface extends DBusObject { return DBusMethodSuccessResponse([ DBusDict.stringVariant({ "mpris:trackid": DBusString("${path.value}/Track/$id"), - "mpris:length": DBusInt32(playback.duration?.inMicroseconds ?? 0), - "mpris:artUrl": DBusString( - imageToUrlString(playback.currentTrack?.album?.images)), - "xesam:album": DBusString(playback.currentTrack!.album!.name!), + "mpris:length": DBusInt32(playback.currentDuration.inMicroseconds), + "mpris:artUrl": + DBusString(imageToUrlString(playback.track?.album?.images)), + "xesam:album": DBusString(playback.track!.album!.name!), "xesam:artist": DBusArray.string( - playback.currentTrack!.artists!.map((artist) => artist.name!), + playback.track!.artists!.map((artist) => artist.name!), ), - "xesam:title": DBusString(playback.currentTrack!.name!), + "xesam:title": DBusString(playback.track!.name!), "xesam:url": DBusString( - playback.currentTrack is SpotubeTrack - ? (playback.currentTrack as SpotubeTrack).ytUri - : playback.currentTrack!.previewUrl!, + playback.track is SpotubeTrack + ? (playback.track as SpotubeTrack).ytUri + : playback.track!.previewUrl!, ), "xesam:genre": const DBusString("Unknown"), }), @@ -116,7 +124,7 @@ class Player_Interface extends DBusObject { /// Gets value of property org.mpris.MediaPlayer2.Player.Position Future getPosition() async { return DBusMethodSuccessResponse([ - DBusInt64((await player.getDuration())?.inMicroseconds ?? 0), + DBusInt64((await playback.player.getDuration())?.inMicroseconds ?? 0), ]); } @@ -134,7 +142,7 @@ class Player_Interface extends DBusObject { Future getCanGoNext() async { return DBusMethodSuccessResponse([ DBusBoolean( - playback.currentPlaylist?.tracks.isNotEmpty == true, + playback.playlist?.tracks.isNotEmpty == true, ) ]); } @@ -143,7 +151,7 @@ class Player_Interface extends DBusObject { Future getCanGoPrevious() async { return DBusMethodSuccessResponse([ DBusBoolean( - playback.currentPlaylist?.tracks.isNotEmpty == true, + playback.playlist?.tracks.isNotEmpty == true, ) ]); } @@ -170,45 +178,43 @@ class Player_Interface extends DBusObject { /// Implementation of org.mpris.MediaPlayer2.Player.Next() Future doNext() async { - playback.movePlaylistPositionBy(1); + playback.seekForward(); return DBusMethodSuccessResponse(); } /// Implementation of org.mpris.MediaPlayer2.Player.Previous() Future doPrevious() async { - playback.movePlaylistPositionBy(-1); + playback.seekBackward(); return DBusMethodSuccessResponse(); } /// Implementation of org.mpris.MediaPlayer2.Player.Pause() Future doPause() async { - player.pause(); + playback.pause(); return DBusMethodSuccessResponse(); } /// Implementation of org.mpris.MediaPlayer2.Player.PlayPause() Future doPlayPause() async { - player.state == PlayerState.playing ? player.pause() : player.resume(); + playback.isPlaying ? playback.pause() : playback.resume(); return DBusMethodSuccessResponse(); } /// Implementation of org.mpris.MediaPlayer2.Player.Stop() Future doStop() async { - await player.pause(); - await player.seek(Duration.zero); - playback.reset(); + playback.stop(); return DBusMethodSuccessResponse(); } /// Implementation of org.mpris.MediaPlayer2.Player.Play() Future doPlay() async { - player.resume(); + playback.resume(); return DBusMethodSuccessResponse(); } /// Implementation of org.mpris.MediaPlayer2.Player.Seek() Future doSeek(int offset) async { - player.seek(Duration(microseconds: offset)); + playback.seekPosition(Duration(microseconds: offset)); return DBusMethodSuccessResponse(); } diff --git a/lib/main.dart b/lib/main.dart index 9f3d97ff..4a888ec8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,17 +1,14 @@ import 'package:audio_service/audio_service.dart'; import 'package:bitsdojo_window/bitsdojo_window.dart'; -import 'package:dbus/dbus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/entities/CacheTrack.dart'; -import 'package:spotube/interfaces/media_player2.dart'; import 'package:spotube/models/GoRouteDeclarations.dart'; import 'package:spotube/models/Logger.dart'; import 'package:spotube/provider/AudioPlayer.dart'; -import 'package:spotube/provider/DBus.dart'; import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/UserPreferences.dart'; import 'package:spotube/provider/YouTube.dart'; @@ -24,14 +21,6 @@ void main() async { await Hive.initFlutter(); Hive.registerAdapter(CacheTrackAdapter()); Hive.registerAdapter(CacheTrackEngagementAdapter()); - AudioPlayerHandler audioPlayerHandler = await AudioService.init( - builder: () => AudioPlayerHandler(), - config: const AudioServiceConfig( - androidNotificationChannelId: 'com.krtirtho.Spotube', - androidNotificationChannelName: 'Spotube', - androidNotificationOngoing: true, - ), - ); if (kIsDesktop) { WidgetsFlutterBinding.ensureInitialized(); // final client = DBusClient.session(); @@ -44,19 +33,38 @@ void main() async { appWindow.show(); }); } + AudioPlayerHandler? audioServiceHandler; runApp(ProviderScope( child: Spotube(), overrides: [ playbackProvider.overrideWithProvider(ChangeNotifierProvider( (ref) { final youtube = ref.watch(youtubeProvider); - final dbus = ref.watch(dbusClientProvider); - return Playback( - player: audioPlayerHandler, + final player = ref.watch(audioPlayerProvider); + + final playback = Playback( + player: player, youtube: youtube, ref: ref, - dbus: dbus, ); + + if (audioServiceHandler == null) { + AudioService.init( + builder: () => AudioPlayerHandler(playback), + config: const AudioServiceConfig( + androidNotificationChannelId: 'com.krtirtho.Spotube', + androidNotificationChannelName: 'Spotube', + androidNotificationOngoing: true, + ), + ).then( + (value) { + playback.mobileAudioService = value; + audioServiceHandler = value; + }, + ); + } + + return playback; }, )) ], diff --git a/lib/provider/DBus.dart b/lib/provider/DBus.dart index dd20edd2..62704659 100644 --- a/lib/provider/DBus.dart +++ b/lib/provider/DBus.dart @@ -8,3 +8,5 @@ final Provider dbusClientProvider = Provider((ref) { return DBusClient.session(); } }); + +final dbus = DBusClient.session(); diff --git a/lib/provider/LegacyPlayback.dart b/lib/provider/LegacyPlayback.dart new file mode 100644 index 00000000..1cf4efaf --- /dev/null +++ b/lib/provider/LegacyPlayback.dart @@ -0,0 +1,325 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:audio_service/audio_service.dart'; +import 'package:audioplayers/audioplayers.dart'; +import 'package:dbus/dbus.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive/hive.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/entities/CacheTrack.dart'; +import 'package:spotube/helpers/artist-to-string.dart'; +import 'package:spotube/helpers/image-to-url-string.dart'; +import 'package:spotube/helpers/search-youtube.dart'; +import 'package:spotube/interfaces/media_player2.dart'; +import 'package:spotube/interfaces/media_player2_player.dart'; +import 'package:spotube/models/CurrentPlaylist.dart'; +import 'package:spotube/models/Logger.dart'; +import 'package:spotube/provider/DBus.dart'; +import 'package:spotube/provider/UserPreferences.dart'; +import 'package:spotube/provider/YouTube.dart'; +import 'package:spotube/utils/AudioPlayerHandler.dart'; +import 'package:spotube/utils/PersistedChangeNotifier.dart'; +import 'package:youtube_explode_dart/youtube_explode_dart.dart'; + +class LegacyPlayback extends PersistedChangeNotifier { + UrlSource? _currentAudioSource; + final _logger = getLogger(LegacyPlayback); + CurrentPlaylist? _currentPlaylist; + Track? _currentTrack; + + // states + bool _isPlaying = false; + Duration? duration; + + bool _shuffled = false; + + AudioPlayerHandler player; + YoutubeExplode youtube; + Ref ref; + + LazyBox? cacheTrackBox; + + @protected + final DBusClient? dbus; + Media_Player? _media_player; + Player_Interface? _mpris; + + double volume = 1; + + LegacyPlayback({ + required this.player, + required this.youtube, + required this.ref, + required this.dbus, + CurrentPlaylist? currentPlaylist, + Track? currentTrack, + }) : _currentPlaylist = currentPlaylist, + _currentTrack = currentTrack, + super() { + player.onNextRequest = () { + movePlaylistPositionBy(1); + }; + player.onPreviousRequest = () { + movePlaylistPositionBy(-1); + }; + + _init(); + } + + StreamSubscription? _durationStream; + StreamSubscription? _playingStream; + StreamSubscription? _positionStream; + + void _init() async { + // dbus m.p.r.i.s stuff + if (Platform.isLinux) { + try { + _media_player = Media_Player(); + _mpris = Player_Interface(player: player.core, playback: this); + await dbus?.registerObject(_media_player!); + await dbus?.registerObject(_mpris!); + } catch (e) { + logger.e("[MPRIS initialization error]", e); + } + } + + cacheTrackBox = await Hive.openLazyBox("track-cache"); + + _playingStream = player.core.onPlayerStateChanged.listen( + (state) async { + _isPlaying = state == PlayerState.playing; + if (state == PlayerState.completed) { + if (_currentTrack?.id != null) { + movePlaylistPositionBy(1); + } else { + _isPlaying = false; + duration = null; + } + } + notifyListeners(); + }, + ); + + _durationStream = player.core.onDurationChanged.listen((event) { + duration = event; + notifyListeners(); + }); + + _positionStream = player.core.onPositionChanged.listen((pos) async { + if (pos > Duration.zero && + (duration == null || duration == Duration.zero)) { + duration = await player.core.getDuration(); + notifyListeners(); + } + }); + } + + @override + void dispose() { + _playingStream?.cancel(); + _durationStream?.cancel(); + _positionStream?.cancel(); + cacheTrackBox?.close(); + if (Platform.isLinux && _media_player != null && _mpris != null) { + dbus?.unregisterObject(_media_player!); + dbus?.unregisterObject(_mpris!); + } + super.dispose(); + } + + bool get shuffled => _shuffled; + CurrentPlaylist? get currentPlaylist => _currentPlaylist; + Track? get currentTrack => _currentTrack; + bool get isPlaying => _isPlaying; + + set setCurrentTrack(Track track) { + _logger.v("[Setting Current Track] ${track.name} - ${track.id}"); + _currentTrack = track; + notifyListeners(); + updatePersistence(); + } + + set setCurrentPlaylist(CurrentPlaylist playlist) { + _logger.v("[Current Playlist Changed] ${playlist.name} - ${playlist.id}"); + _currentPlaylist = playlist; + notifyListeners(); + updatePersistence(); + } + + void reset() { + _logger.v("Playback Reset"); + _isPlaying = false; + _shuffled = false; + duration = null; + _currentPlaylist = null; + _currentTrack = null; + notifyListeners(); + updatePersistence(clearNullEntries: true); + } + + void setVolume(double newVolume) { + volume = newVolume; + notifyListeners(); + updatePersistence(); + } + + /// sets the provided id matched track's uri\ + /// Doesn't notify listeners\ + /// @returns `bool` - `true` if succeed & `false` when failed + bool setTrackUriById(String id, String uri) { + if (_currentPlaylist == null) return false; + try { + int index = + _currentPlaylist!.tracks.indexWhere((element) => element.id == id); + if (index == -1) return false; + _currentPlaylist!.tracks[index].uri = uri; + updatePersistence(); + return _currentPlaylist!.tracks[index].uri == uri; + } catch (e) { + return false; + } + } + + void movePlaylistPositionBy(int pos) { + _logger.v("[Playlist Position Move] $pos"); + if (_currentTrack != null && _currentPlaylist != null) { + final int index = + _currentPlaylist!.trackIds.indexOf(_currentTrack!.id!) + pos; + + final safeIndex = index > _currentPlaylist!.trackIds.length - 1 + ? 0 + : index < 0 + ? _currentPlaylist!.trackIds.length + : index; + Track? track = _currentPlaylist!.tracks.asMap().containsKey(safeIndex) + ? _currentPlaylist!.tracks.elementAt(safeIndex) + : null; + if (track != null) { + duration = null; + _currentTrack = track; + notifyListeners(); + updatePersistence(); + // starts to play the newly entered next/prev track + startPlaying(); + } + } + } + + Future startPlaying([Track? track]) async { + _logger.v("[Track Playing] ${track?.name} - ${track?.id}"); + try { + // the track is already playing so no need to change that + if (track != null && track.id == _currentTrack?.id) return; + track ??= _currentTrack; + if (track != null) { + Uri? parsedUri = Uri.tryParse(track.uri ?? ""); + final tag = MediaItem( + id: track.id!, + title: track.name!, + album: track.album?.name, + artist: artistsToString(track.artists ?? []), + artUri: Uri.parse(imageToUrlString(track.album?.images)), + ); + player.addItem(tag); + if (parsedUri != null && parsedUri.hasAbsolutePath) { + _currentAudioSource = UrlSource(parsedUri.toString()); + await player.core + .play( + _currentAudioSource!, + ) + .then((value) async { + _currentTrack = track; + notifyListeners(); + updatePersistence(); + }); + return; + } + final preferences = ref.read(userPreferencesProvider); + final spotubeTrack = await toSpotubeTrack( + youtube: youtube, + track: track, + format: preferences.ytSearchFormat, + matchAlgorithm: preferences.trackMatchAlgorithm, + audioQuality: preferences.audioQuality, + box: cacheTrackBox, + ); + if (setTrackUriById(track.id!, spotubeTrack.ytUri)) { + logger.v("[Track Direct Source] - ${spotubeTrack.ytUri}"); + _currentAudioSource = UrlSource(spotubeTrack.ytUri); + await player.core + .play( + _currentAudioSource!, + ) + .then((value) { + _currentTrack = spotubeTrack; + notifyListeners(); + updatePersistence(); + }); + } + } + } catch (e, stack) { + _logger.e("startPlaying", e, stack); + } + } + + void shuffle() { + if (currentPlaylist?.shuffle() == true) { + _shuffled = true; + notifyListeners(); + } + } + + void unshuffle() { + if (currentPlaylist?.unshuffle() == true) { + _shuffled = false; + notifyListeners(); + } + } + + @override + FutureOr loadFromLocal(Map map) { + if (map["currentPlaylist"] != null) { + _currentPlaylist = + CurrentPlaylist.fromJson(jsonDecode(map["currentPlaylist"])); + } + if (map["currentTrack"] != null) { + _currentTrack = Track.fromJson(jsonDecode(map["currentTrack"])); + startPlaying().then((_) { + Timer.periodic(const Duration(milliseconds: 100), (timer) { + if (player.core.state == PlayerState.playing) { + player.pause(); + timer.cancel(); + } + }); + }); + } + volume = map["volume"] ?? volume; + } + + @override + FutureOr> toMap() { + return { + "currentPlaylist": currentPlaylist != null + ? jsonEncode(currentPlaylist?.toJson()) + : null, + "currentTrack": + currentTrack != null ? jsonEncode(currentTrack?.toJson()) : null, + "volume": volume, + }; + } +} + +final legacyPlaybackProvider = ChangeNotifierProvider((ref) { + final player = AudioPlayerHandler(); + final youtube = ref.watch(youtubeProvider); + final dbus = ref.watch(dbusClientProvider); + return LegacyPlayback( + player: player, + youtube: youtube, + ref: ref, + dbus: dbus, + ); +}); diff --git a/lib/provider/Playback.dart b/lib/provider/Playback.dart index 31930fa1..5459777f 100644 --- a/lib/provider/Playback.dart +++ b/lib/provider/Playback.dart @@ -1,330 +1,364 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:io'; import 'package:audio_service/audio_service.dart'; import 'package:audioplayers/audioplayers.dart'; -import 'package:dbus/dbus.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive/hive.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/entities/CacheTrack.dart'; +import 'package:spotube/extensions/yt-video-from-cache-track.dart'; import 'package:spotube/helpers/artist-to-string.dart'; +import 'package:spotube/helpers/contains-text-in-bracket.dart'; +import 'package:spotube/helpers/getLyrics.dart'; import 'package:spotube/helpers/image-to-url-string.dart'; import 'package:spotube/helpers/search-youtube.dart'; import 'package:spotube/interfaces/media_player2.dart'; import 'package:spotube/interfaces/media_player2_player.dart'; import 'package:spotube/models/CurrentPlaylist.dart'; import 'package:spotube/models/Logger.dart'; -import 'package:spotube/provider/DBus.dart'; +import 'package:spotube/models/SpotubeTrack.dart'; +import 'package:spotube/provider/AudioPlayer.dart'; import 'package:spotube/provider/UserPreferences.dart'; import 'package:spotube/provider/YouTube.dart'; import 'package:spotube/utils/AudioPlayerHandler.dart'; -import 'package:spotube/utils/PersistedChangeNotifier.dart'; -import 'package:youtube_explode_dart/youtube_explode_dart.dart'; +import 'package:youtube_explode_dart/youtube_explode_dart.dart' hide Playlist; +import 'package:collection/collection.dart'; +import 'package:spotube/extensions/list-sort-multiple.dart'; -class Playback extends PersistedChangeNotifier { - UrlSource? _currentAudioSource; - final _logger = getLogger(Playback); - CurrentPlaylist? _currentPlaylist; - Track? _currentTrack; +class Playback with ChangeNotifier { + // player properties + bool isShuffled; + bool isPlaying; + Duration currentDuration; + double volume; - // states - bool _isPlaying = false; - Duration? duration; + // class dependencies + Media_Player? linuxMPRIS; + Player_Interface? linuxMPRIS_Player; + AudioPlayerHandler? mobileAudioService; - bool _shuffled = false; - - AudioPlayerHandler player; + // foreign/passed properties + AudioPlayer player; YoutubeExplode youtube; Ref ref; + UserPreferences get preferences => ref.read(userPreferencesProvider); - LazyBox? cacheTrackBox; + // playlist & track list properties + late LazyBox cache; + CurrentPlaylist? playlist; + SpotubeTrack? track; - @protected - final DBusClient? dbus; - Media_Player? _media_player; - Player_Interface? _mpris; - - double volume = 1; + // internal stuff + final List _subscriptions; + final _logger = getLogger(Playback); Playback({ required this.player, required this.youtube, required this.ref, - required this.dbus, - CurrentPlaylist? currentPlaylist, - Track? currentTrack, - }) : _currentPlaylist = currentPlaylist, - _currentTrack = currentTrack, + this.mobileAudioService, + }) : volume = 0, + isShuffled = false, + isPlaying = false, + currentDuration = Duration.zero, + _subscriptions = [], super() { - player.onNextRequest = () { - movePlaylistPositionBy(1); - }; - player.onPreviousRequest = () { - movePlaylistPositionBy(-1); - }; - - _init(); - } - - StreamSubscription? _durationStream; - StreamSubscription? _playingStream; - StreamSubscription? _positionStream; - - void _init() async { - // dbus m.p.r.i.s stuff if (Platform.isLinux) { - try { - _media_player = Media_Player(); - _mpris = Player_Interface(player: player.core, playback: this); - final nameStatus = - await dbus?.requestName("org.mpris.MediaPlayer2.spotube"); - if (nameStatus == DBusRequestNameReply.exists) { - await dbus - ?.requestName("org.mpris.MediaPlayer2.spotube.instance$pid"); - } - await dbus?.registerObject(_media_player!); - await dbus?.registerObject(_mpris!); - } catch (e) { - logger.e("[MPRIS initialization error]", e); - } + linuxMPRIS = Media_Player(); + linuxMPRIS_Player = Player_Interface(playback: this); } - cacheTrackBox = await Hive.openLazyBox("track-cache"); - - _playingStream = player.core.onPlayerStateChanged.listen( - (state) async { - _isPlaying = state == PlayerState.playing; - if (state == PlayerState.completed) { - if (_currentTrack?.id != null) { - movePlaylistPositionBy(1); + (() async { + cache = await Hive.openLazyBox("track-cache"); + _subscriptions.addAll([ + player.onPlayerStateChanged.listen( + (state) async { + isPlaying = state == PlayerState.playing; + notifyListeners(); + }, + ), + player.onPlayerComplete.listen((_) { + if (track?.id != null) { + seekForward(); } else { - _isPlaying = false; - duration = null; + isPlaying = false; + currentDuration = Duration.zero; + notifyListeners(); } - } - notifyListeners(); - }, - ); - - _durationStream = player.core.onDurationChanged.listen((event) { - duration = event; - notifyListeners(); - }); - - _positionStream = player.core.onPositionChanged.listen((pos) async { - if (pos > Duration.zero && - (duration == null || duration == Duration.zero)) { - duration = await player.core.getDuration(); - notifyListeners(); - } - }); + }), + player.onDurationChanged.listen((event) { + currentDuration = event; + notifyListeners(); + }), + player.onPositionChanged.listen((pos) async { + if (pos > Duration.zero && currentDuration == Duration.zero) { + currentDuration = await player.getDuration() ?? Duration.zero; + notifyListeners(); + } + }), + ]); + }()); } @override void dispose() { - _playingStream?.cancel(); - _durationStream?.cancel(); - _positionStream?.cancel(); - cacheTrackBox?.close(); - if (Platform.isLinux && _media_player != null && _mpris != null) { - dbus?.unregisterObject(_media_player!); - dbus?.unregisterObject(_mpris!); + linuxMPRIS?.dispose(); + linuxMPRIS_Player?.dispose(); + for (var subscription in _subscriptions) { + subscription.cancel(); } super.dispose(); } - bool get shuffled => _shuffled; - CurrentPlaylist? get currentPlaylist => _currentPlaylist; - Track? get currentTrack => _currentTrack; - bool get isPlaying => _isPlaying; - - set setCurrentTrack(Track track) { - _logger.v("[Setting Current Track] ${track.name} - ${track.id}"); - _currentTrack = track; - notifyListeners(); - updatePersistence(); + Future playPlaylist(CurrentPlaylist playlist, [int index = 0]) async { + if (index < 0 || index > playlist.tracks.length - 1) return; + this.playlist = playlist; + final played = this.playlist!.tracks[index]; + await play(played).then((_) { + int i = this + .playlist! + .tracks + .indexWhere((element) => element.id == played.id); + if (index == -1) return; + this.playlist!.tracks[i] = track!; + }); } - set setCurrentPlaylist(CurrentPlaylist playlist) { - _logger.v("[Current Playlist Changed] ${playlist.name} - ${playlist.id}"); - _currentPlaylist = playlist; - notifyListeners(); - updatePersistence(); - } - - void reset() { - _logger.v("Playback Reset"); - _isPlaying = false; - _shuffled = false; - duration = null; - _currentPlaylist = null; - _currentTrack = null; - notifyListeners(); - updatePersistence(clearNullEntries: true); - } - - void setVolume(double newVolume) { - volume = newVolume; - notifyListeners(); - updatePersistence(); - } - - /// sets the provided id matched track's uri\ - /// Doesn't notify listeners\ - /// @returns `bool` - `true` if succeed & `false` when failed - bool setTrackUriById(String id, String uri) { - if (_currentPlaylist == null) return false; - try { - int index = - _currentPlaylist!.tracks.indexWhere((element) => element.id == id); - if (index == -1) return false; - _currentPlaylist!.tracks[index].uri = uri; - updatePersistence(); - return _currentPlaylist!.tracks[index].uri == uri; - } catch (e) { - return false; - } - } - - void movePlaylistPositionBy(int pos) { - _logger.v("[Playlist Position Move] $pos"); - if (_currentTrack != null && _currentPlaylist != null) { - int index = _currentPlaylist!.trackIds.indexOf(_currentTrack!.id!) + pos; - - var safeIndex = index > _currentPlaylist!.trackIds.length - 1 - ? 0 - : index < 0 - ? _currentPlaylist!.trackIds.length - : index; - Track? track = _currentPlaylist!.tracks.asMap().containsKey(safeIndex) - ? _currentPlaylist!.tracks.elementAt(safeIndex) - : null; - if (track != null) { - duration = null; - _currentTrack = track; - notifyListeners(); - updatePersistence(); - // starts to play the newly entered next/prev track - startPlaying(); - } - } - } - - Future startPlaying([Track? track]) async { + // player methods + Future play([Track? track]) async { _logger.v("[Track Playing] ${track?.name} - ${track?.id}"); try { // the track is already playing so no need to change that - if (track != null && track.id == _currentTrack?.id) return; - track ??= _currentTrack; - if (track != null) { - Uri? parsedUri = Uri.tryParse(track.uri ?? ""); - final tag = MediaItem( - id: track.id!, - title: track.name!, - album: track.album?.name, - artist: artistsToString(track.artists ?? []), - artUri: Uri.parse(imageToUrlString(track.album?.images)), - ); - player.addItem(tag); - if (parsedUri != null && parsedUri.hasAbsolutePath) { - _currentAudioSource = UrlSource(parsedUri.toString()); - await player.core - .play( - _currentAudioSource!, - ) - .then((value) async { - _currentTrack = track; - notifyListeners(); - updatePersistence(); - }); - return; - } - final preferences = ref.read(userPreferencesProvider); - final spotubeTrack = await toSpotubeTrack( - youtube: youtube, - track: track, - format: preferences.ytSearchFormat, - matchAlgorithm: preferences.trackMatchAlgorithm, - audioQuality: preferences.audioQuality, - box: cacheTrackBox, - ); - if (setTrackUriById(track.id!, spotubeTrack.ytUri)) { - logger.v("[Track Direct Source] - ${spotubeTrack.ytUri}"); - _currentAudioSource = UrlSource(spotubeTrack.ytUri); - await player.core - .play( - _currentAudioSource!, - ) - .then((value) { - _currentTrack = spotubeTrack; - notifyListeners(); - updatePersistence(); - }); - } + if ((track != null && track.id == this.track?.id) || + (this.track == null && track == null)) return; + track ??= this.track; + final tag = MediaItem( + id: track!.id!, + title: track.name!, + album: track.album?.name, + artist: artistsToString(track.artists ?? []), + artUri: Uri.parse(imageToUrlString(track.album?.images)), + ); + mobileAudioService?.addItem(tag); + + // the track is not a SpotubeTrack so turning it to one + if (track is! SpotubeTrack) { + track = await toSpotubeTrack(track); } - } catch (e, stack) { - _logger.e("startPlaying", e, stack); - } - } - - void shuffle() { - if (currentPlaylist?.shuffle() == true) { - _shuffled = true; - notifyListeners(); - } - } - - void unshuffle() { - if (currentPlaylist?.unshuffle() == true) { - _shuffled = false; - notifyListeners(); - } - } - - @override - FutureOr loadFromLocal(Map map) { - if (map["currentPlaylist"] != null) { - _currentPlaylist = - CurrentPlaylist.fromJson(jsonDecode(map["currentPlaylist"])); - } - if (map["currentTrack"] != null) { - _currentTrack = Track.fromJson(jsonDecode(map["currentTrack"])); - startPlaying().then((_) { - Timer.periodic(const Duration(milliseconds: 100), (timer) { - if (player.core.state == PlayerState.playing) { - player.pause(); - timer.cancel(); - } - }); + _logger.v("[Track Direct Source] - ${(track).ytUri}"); + await player.play(UrlSource(track.ytUri)).then((_) { + this.track = track as SpotubeTrack; + notifyListeners(); }); + } catch (e, stack) { + _logger.e("play", e, stack); } - volume = map["volume"] ?? volume; } - @override - FutureOr> toMap() { - return { - "currentPlaylist": currentPlaylist != null - ? jsonEncode(currentPlaylist?.toJson()) - : null, - "currentTrack": - currentTrack != null ? jsonEncode(currentTrack?.toJson()) : null, - "volume": volume, - }; + Future resume() async { + if (isPlaying || (playlist == null && track == null)) return; + await player.resume(); + isPlaying = true; + notifyListeners(); + } + + Future pause() async { + if (!isPlaying || (playlist == null && track == null)) return; + await player.pause(); + isPlaying = false; + notifyListeners(); + } + + Future togglePlayPause() async { + isPlaying ? await pause() : await resume(); + } + + toggleShuffle() { + final result = isShuffled ? playlist?.unshuffle() : playlist?.shuffle(); + if (result == true) { + isShuffled = !isShuffled; + notifyListeners(); + } + } + + Future seekPosition(Duration position) { + return player.seek(position); + } + + Future setVolume(double newVolume) async { + await player.setVolume(volume); + volume = newVolume; + notifyListeners(); + } + + Future stop() async { + await player.stop(); + await player.release(); + isPlaying = false; + isShuffled = false; + playlist = null; + track = null; + currentDuration = Duration.zero; + notifyListeners(); + } + + destroy() {} + + // playlist & track list methods + Future toSpotubeTrack(Track track) async { + final format = preferences.ytSearchFormat; + final matchAlgorithm = preferences.trackMatchAlgorithm; + final artistsName = + track.artists?.map((ar) => ar.name).toList().whereNotNull().toList() ?? + []; + final audioQuality = preferences.audioQuality; + _logger.v("[Track Search Artists] $artistsName"); + final mainArtist = artistsName.first; + final featuredArtists = artistsName.length > 1 + ? "feat. " + artistsName.sublist(1).join(" ") + : ""; + final title = getTitle( + track.name!, + artists: artistsName, + onlyCleanArtist: true, + ).trim(); + _logger.v("[Track Search Title] $title"); + final queryString = format + .replaceAll("\$MAIN_ARTIST", mainArtist) + .replaceAll("\$TITLE", title) + .replaceAll("\$FEATURED_ARTISTS", featuredArtists); + _logger.v("[Youtube Search Term] $queryString"); + + Video ytVideo; + final cachedTrack = await cache.get(track.id); + if (cachedTrack != null && cachedTrack.mode == matchAlgorithm.name) { + _logger.v( + "[Playing track from cache] youtubeId: ${cachedTrack.id} mode: ${cachedTrack.mode}", + ); + ytVideo = VideoFromCacheTrackExtension.fromCacheTrack(cachedTrack); + } else { + VideoSearchList videos = await youtube.search.search(queryString); + if (matchAlgorithm != SpotubeTrackMatchAlgorithm.youtube) { + List ratedRankedVideos = videos + .map((video) { + // the find should be lazy thus everything case insensitive + final ytTitle = video.title.toLowerCase(); + final bool hasTitle = ytTitle.contains(title); + final bool hasAllArtists = track.artists?.every( + (artist) => ytTitle.contains(artist.name!.toLowerCase()), + ) ?? + false; + final bool authorIsArtist = + track.artists?.first.name?.toLowerCase() == + video.author.toLowerCase(); + + final bool hasNoLiveInTitle = + !containsTextInBracket(ytTitle, "live"); + + int rate = 0; + for (final el in [ + hasTitle, + hasAllArtists, + if (matchAlgorithm == + SpotubeTrackMatchAlgorithm.authenticPopular) + authorIsArtist, + hasNoLiveInTitle, + !video.isLive, + ]) { + if (el) rate++; + } + // can't let pass any non title matching track + if (!hasTitle) rate = rate - 2; + return { + "video": video, + "points": rate, + "views": video.engagement.viewCount, + }; + }) + .toList() + .sortByProperties( + [false, false], + ["points", "views"], + ); + + ytVideo = ratedRankedVideos.first["video"] as Video; + } else { + ytVideo = videos.where((video) => !video.isLive).first; + } + } + + final trackManifest = await youtube.videos.streams.getManifest(ytVideo.id); + + _logger.v( + "[YouTube Matched Track] ${ytVideo.title} | ${ytVideo.author} - ${ytVideo.url}", + ); + + final audioManifest = trackManifest.audioOnly.where((info) { + final isMp4a = info.codec.mimeType == "audio/mp4"; + if (Platform.isLinux) { + return !isMp4a; + } else if (Platform.isMacOS || Platform.isIOS) { + return isMp4a; + } else { + return true; + } + }); + + final ytUri = (audioQuality == AudioQuality.high + ? audioManifest.withHighestBitrate() + : audioManifest.sortByBitrate().last) + .url + .toString(); + + // only save when the track isn't available in the cache with same + // matchAlgorithm + if (cachedTrack == null || cachedTrack.mode != matchAlgorithm.name) { + await cache.put( + track.id!, CacheTrack.fromVideo(ytVideo, matchAlgorithm.name)); + } + + return SpotubeTrack.fromTrack( + track: track, + ytTrack: ytVideo, + // Since Mac OS's & IOS's CodeAudio doesn't support WebMedia + // ('audio/webm', 'video/webm' & 'image/webp') thus using 'audio/mpeg' + // codec/mimetype for those Platforms + ytUri: ytUri, + ); + } + + Future setPlaylistPosition(int position) async { + if (playlist == null) return; + await playPlaylist(playlist!, position); + } + + Future seekForward() async { + if (playlist == null || track == null) return; + final int nextTrackIndex = + (playlist!.trackIds.indexOf(track!.id!) + 1).toInt(); + // checking if there's any track available forward + if (nextTrackIndex > (playlist?.tracks.length ?? 0) - 1) return; + await play(playlist!.tracks.elementAt(nextTrackIndex)); + } + + Future seekBackward() async { + if (playlist == null || track == null) return; + final int prevTrackIndex = + (playlist!.trackIds.indexOf(track!.id!) - 1).toInt(); + // checking if there's any track available behind + if (prevTrackIndex < 0) return; + await play(playlist!.tracks.elementAt(prevTrackIndex)); } } final playbackProvider = ChangeNotifierProvider((ref) { - final player = AudioPlayerHandler(); final youtube = ref.watch(youtubeProvider); - final dbus = ref.watch(dbusClientProvider); + final player = ref.watch(audioPlayerProvider); return Playback( player: player, youtube: youtube, ref: ref, - dbus: dbus, ); }); diff --git a/lib/provider/SpotifyRequests.dart b/lib/provider/SpotifyRequests.dart index 76fe6f5f..aabe6d2e 100644 --- a/lib/provider/SpotifyRequests.dart +++ b/lib/provider/SpotifyRequests.dart @@ -166,8 +166,7 @@ final searchQuery = FutureProvider.family, String>((ref, term) { final geniusLyricsQuery = FutureProvider( (ref) { - final currentTrack = - ref.watch(playbackProvider.select((s) => s.currentTrack)); + final currentTrack = ref.watch(playbackProvider.select((s) => s.track)); final geniusAccessToken = ref.watch(userPreferencesProvider.select((s) => s.geniusAccessToken)); if (currentTrack == null) { @@ -184,8 +183,7 @@ final geniusLyricsQuery = FutureProvider( final rentanadviserLyricsQuery = FutureProvider( (ref) { - final currentTrack = - ref.watch(playbackProvider.select((s) => s.currentTrack)); + final currentTrack = ref.watch(playbackProvider.select((s) => s.track)); if (currentTrack == null) return null; return getTimedLyrics(currentTrack as SpotubeTrack); }, diff --git a/lib/utils/AudioPlayerHandler.dart b/lib/utils/AudioPlayerHandler.dart index 266737a6..ed2a825b 100644 --- a/lib/utils/AudioPlayerHandler.dart +++ b/lib/utils/AudioPlayerHandler.dart @@ -1,17 +1,15 @@ import 'dart:async'; import 'package:audio_service/audio_service.dart'; -import 'package:audioplayers/audioplayers.dart'; +import 'package:spotube/provider/Playback.dart'; /// An [AudioHandler] for playing a single item. class AudioPlayerHandler extends BaseAudioHandler { - final _player = AudioPlayer(); - - FutureOr Function()? onNextRequest; - FutureOr Function()? onPreviousRequest; + final Playback playback; /// Initialise our audio handler. - AudioPlayerHandler() { + AudioPlayerHandler(this.playback) { + final _player = playback.player; // So that our clients (the Flutter UI and the system notification) know // what state to display, here we set up our audio handler to broadcast all // playback state changes as they happen via playbackState... @@ -27,8 +25,6 @@ class AudioPlayerHandler extends BaseAudioHandler { }); } - AudioPlayer get core => _player; - void addItem(MediaItem item) { mediaItem.add(item); } @@ -39,32 +35,32 @@ class AudioPlayerHandler extends BaseAudioHandler { // your audio playback logic in one place. @override - Future play() => _player.resume(); + Future play() => playback.resume(); @override - Future pause() => _player.pause(); + Future pause() => playback.pause(); @override - Future seek(Duration position) => _player.seek(position); + Future seek(Duration position) => playback.seekPosition(position); @override - Future stop() => _player.stop(); + Future stop() => playback.stop(); @override Future skipToNext() async { - await onNextRequest?.call(); + playback.seekForward(); await super.skipToNext(); } @override Future skipToPrevious() async { - await onPreviousRequest?.call(); + playback.seekBackward(); await super.skipToPrevious(); } @override Future onTaskRemoved() { - _player.stop(); + playback.destroy(); return super.onTaskRemoved(); } @@ -77,16 +73,14 @@ class AudioPlayerHandler extends BaseAudioHandler { return PlaybackState( controls: [ MediaControl.skipToPrevious, - if (_player.state == PlayerState.playing) - MediaControl.pause - else - MediaControl.play, + if (playback.isPlaying) MediaControl.pause else MediaControl.play, MediaControl.skipToNext, MediaControl.stop, ], androidCompactActionIndices: const [0, 1, 2], - playing: _player.state == PlayerState.playing, - updatePosition: (await _player.getCurrentPosition()) ?? Duration.zero, + playing: playback.isPlaying, + updatePosition: + (await playback.player.getCurrentPosition()) ?? Duration.zero, ); } }