diff --git a/.vscode/settings.json b/.vscode/settings.json index cad7657df..44bf8e0ad 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,6 @@ { - "cmake.configureOnOpen": false + "cmake.configureOnOpen": false, + "cSpell.words": [ + "Mpris" + ] } \ No newline at end of file diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md index c218df9a9..2d1db3638 100644 --- a/CONTRIBUTION.md +++ b/CONTRIBUTION.md @@ -12,12 +12,16 @@ All types of contributions are encouraged and valued. See the [Table of Contents ## Table of Contents -- [Code of Conduct](#code-of-conduct) -- [I Have a Question](#i-have-a-question) -- [I Want To Contribute](#i-want-to-contribute) -- [Reporting Bugs](#reporting-bugs) -- [Suggesting Enhancements](#suggesting-enhancements) -- [Your First Code Contribution](#your-first-code-contribution) +- [Contributing to Spotube](#contributing-to-spotube) + - [Table of Contents](#table-of-contents) + - [Code of Conduct](#code-of-conduct) + - [I Have a Question](#i-have-a-question) + - [I Want To Contribute](#i-want-to-contribute) + - [Reporting Bugs](#reporting-bugs) + - [Before Submitting a Bug Report](#before-submitting-a-bug-report) + - [How Do I Submit a Good Bug Report?](#how-do-i-submit-a-good-bug-report) + - [Suggesting Enhancements](#suggesting-enhancements) + - [Your First Code Contribution](#your-first-code-contribution) ## Code of Conduct @@ -109,6 +113,9 @@ Enhancement suggestions are tracked as [GitHub issues](https://github.com/KRTirt ### Your First Code Contribution + +audioplayers requirement https://github.com/bluefireteam/audioplayers/blob/main/packages/audioplayers_linux/requirements.md + Do the following: - Download the latest Flutter SDK (>=2.15.1) & enable desktop support - Install Development dependencies in linux diff --git a/lib/components/Album/AlbumCard.dart b/lib/components/Album/AlbumCard.dart index 5e838e0cf..465d79e21 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 78c960936..deca5aacd 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 1c7cb3823..8501fa0b0 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 a42c68723..ce3687ef9 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 62ece941a..92f1be61e 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 0d7fc3ba5..1e939456e 100644 --- a/lib/components/Player/Player.dart +++ b/lib/components/Player/Player.dart @@ -1,19 +1,14 @@ -import 'dart:async'; - import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotube/components/Player/PlayerActions.dart'; import 'package:spotube/components/Player/PlayerOverlay.dart'; import 'package:spotube/components/Player/PlayerTrackDetails.dart'; import 'package:spotube/components/Player/PlayerControls.dart'; import 'package:spotube/helpers/image-to-url-string.dart'; import 'package:spotube/hooks/useBreakpoints.dart'; -import 'package:spotube/models/LocalStorageKeys.dart'; import 'package:spotube/models/Logger.dart'; import 'package:spotube/provider/Playback.dart'; import 'package:flutter/material.dart'; -import 'package:spotube/utils/AudioPlayerHandler.dart'; class Player extends HookConsumerWidget { Player({Key? key}) : super(key: key); @@ -23,41 +18,14 @@ 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 = - useFuture(future, initialData: null); - - useEffect(() { - /// warm up the audio player before playing actual audio - /// It's for resolving unresolved issue related to just_audio's - /// [disposeAllPlayers] method which is throwing - /// [UnimplementedException] in the [PlatformInterface] - /// implementation - player.core.setAsset("assets/warmer.mp3"); - return null; - }, []); - - useEffect(() { - if (localStorage.hasData) { - _volume.value = localStorage.data?.getDouble(LocalStorageKeys.volume) ?? - player.core.volume; - } - return null; - }, [localStorage.data]); - 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); @@ -82,7 +50,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), @@ -104,7 +72,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] @@ -135,22 +103,29 @@ class Player extends HookConsumerWidget { Container( height: 20, constraints: const BoxConstraints(maxWidth: 200), - child: Slider.adaptive( - value: _volume.value, - onChanged: (value) async { - try { - await player.core.setVolume(value).then((_) { - _volume.value = value; - localStorage.data?.setDouble( - LocalStorageKeys.volume, - value, - ); - }); - } catch (e, stack) { - logger.e("onChange", e, stack); - } - }, - ), + child: HookBuilder(builder: (context) { + final volume = useState( + useMemoized(() => playback.volume, []), + ); + return Slider.adaptive( + min: 0, + max: 1, + value: volume.value, + onChanged: (v) { + volume.value = v; + }, + onChangeEnd: (value) async { + try { + // You don't really need to know why but this + // way it works only + await playback.setVolume(value); + await playback.setVolume(value); + } catch (e, stack) { + logger.e("onChange", e, stack); + } + }, + ); + }), ), PlayerActions() ], diff --git a/lib/components/Player/PlayerActions.dart b/lib/components/Player/PlayerActions.dart index abe315f31..3a7bfa72d 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 d6f4d6ff3..e61f06b15 100644 --- a/lib/components/Player/PlayerControls.dart +++ b/lib/components/Player/PlayerControls.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/helpers/zero-pad-num-str.dart'; import 'package:spotube/hooks/playback.dart'; import 'package:spotube/models/Logger.dart'; import 'package:spotube/provider/Playback.dart'; -import 'package:spotube/utils/AudioPlayerHandler.dart'; class PlayerControls extends HookConsumerWidget { final Color? iconColor; @@ -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.positionStream, + stream: playback.player.onPositionChanged, builder: (context, snapshot) { final totalMinutes = zeroPadNumStr(duration.inMinutes.remainder(60)); @@ -48,59 +47,71 @@ class PlayerControls extends HookConsumerWidget { final sliderMax = duration.inSeconds; final sliderValue = snapshot.data?.inSeconds ?? 0; - return Column( - children: [ - Slider.adaptive( - // cannot divide by zero - // there's an edge case for value being bigger - // than total duration. Keeping it resolved - value: (sliderMax == 0 || sliderValue > sliderMax) + + return HookBuilder(builder: (context) { + final progressStatic = + (sliderMax == 0 || sliderValue > sliderMax) ? 0 - : sliderValue / sliderMax, - onChanged: (value) {}, - onChangeEnd: (value) { - player.seek( - Duration( - seconds: (value * sliderMax).toInt(), - ), - ); - }, - activeColor: iconColor, - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "$currentMinutes:$currentSeconds", - ), - Text("$totalMinutes:$totalSeconds"), - ], + : sliderValue / sliderMax; + + final progress = useState( + useMemoized(() => progressStatic, []), + ); + + useEffect(() { + progress.value = progressStatic; + return null; + }, [progressStatic]); + + return Column( + children: [ + Slider.adaptive( + // cannot divide by zero + // there's an edge case for value being bigger + // than total duration. Keeping it resolved + value: progress.value.toDouble(), + onChanged: (v) { + progress.value = v; + }, + onChangeEnd: (value) async { + await playback.seekPosition( + Duration( + seconds: (value * sliderMax).toInt(), + ), + ); + }, + activeColor: iconColor, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "$currentMinutes:$currentSeconds", + ), + Text("$totalMinutes:$totalSeconds"), + ], + ), ), - ), - ], - ); + ], + ); + }); }), Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, 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); } @@ -128,12 +139,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 eb0b25abe..833707190 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 6d580bc33..63c2b0745 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(); @@ -59,7 +59,6 @@ class PlayerView extends HookConsumerWidget { ), backgroundColor: paletteColor.color, body: Column( - // mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Padding( padding: const EdgeInsets.all(10), diff --git a/lib/components/Playlist/PlaylistCard.dart b/lib/components/Playlist/PlaylistCard.dart index a7ac51320..135af4f96 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 203f1948b..df1be1dfa 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 a83257eb0..10320eef1 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 332f20fc9..125ab68b8 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 789985d05..f9fdcc94d 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/extensions/yt-video-from-cache-track.dart b/lib/extensions/yt-video-from-cache-track.dart index 3aed8b5b4..1777d8cbf 100644 --- a/lib/extensions/yt-video-from-cache-track.dart +++ b/lib/extensions/yt-video-from-cache-track.dart @@ -30,3 +30,71 @@ extension VideoFromCacheTrackExtension on Video { ); } } + +extension ThumbnailSetJson on ThumbnailSet { + static ThumbnailSet fromJson(Map map) { + return ThumbnailSet(map["videoId"]); + } + + Map toJson() { + return { + "videoId": videoId, + }; + } +} + +extension EngagementJson on Engagement { + static Engagement fromJson(Map map) { + return Engagement( + map["viewCount"], + map["likeCount"], + map["dislikeCount"], + ); + } + + Map toJson() { + return { + "dislikeCount": dislikeCount, + "likeCount": likeCount, + "viewCount": viewCount, + }; + } +} + +extension VideoToJson on Video { + static Video fromJson(Map map) { + return Video( + VideoId(map["id"]), + map["title"], + map["author"], + ChannelId(map["channelId"]), + DateTime.tryParse(map["uploadDate"]), + DateTime.tryParse(map["publishDate"]), + map["description"], + parseDuration(map["duration"]), + ThumbnailSetJson.fromJson(map["thumbnails"]), + List.castFrom(map["keywords"]), + EngagementJson.fromJson(map["engagement"]), + map["isLive"], + ); + } + + Map toJson() { + return { + "hasWatchPage": hasWatchPage, + "url": url, + "author": author, + "channelId": channelId.value, + "description": description, + "duration": duration.toString(), + "engagement": engagement.toJson(), + "id": id.value, + "isLive": isLive, + "keywords": keywords.toList(), + "publishDate": publishDate.toString(), + "thumbnails": thumbnails.toJson(), + "title": title, + "uploadDate": uploadDate.toString(), + }; + } +} diff --git a/lib/helpers/search-youtube.dart b/lib/helpers/search-youtube.dart index 18f680c8e..a6ae01440 100644 --- a/lib/helpers/search-youtube.dart +++ b/lib/helpers/search-youtube.dart @@ -107,10 +107,16 @@ Future toSpotubeTrack({ "[YouTube Matched Track] ${ytVideo.title} | ${ytVideo.author} - ${ytVideo.url}", ); - final audioManifest = (Platform.isMacOS || Platform.isIOS) - ? trackManifest.audioOnly - .where((info) => info.codec.mimeType == "audio/mp4") - : trackManifest.audioOnly; + 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() diff --git a/lib/hooks/playback.dart b/lib/hooks/playback.dart index 2dc4f0350..f62fee26a 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,15 @@ 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; + } else if (playback.track != null && + playback.currentDuration == Duration.zero && + await playback.player.getCurrentPosition() == Duration.zero) { + await playback.play(playback.track!); + } else { + await playback.togglePlayPause(); + } } catch (e, stack) { logger.e("useTogglePlayPause", e, stack); } diff --git a/lib/hooks/useSyncedLyrics.dart b/lib/hooks/useSyncedLyrics.dart index f48686ab8..5f18014cc 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.positionStream; + final stream = player.onPositionChanged; final currentTime = useState(0); diff --git a/lib/interfaces/media_player2.dart b/lib/interfaces/media_player2.dart deleted file mode 100644 index cbacc307a..000000000 --- a/lib/interfaces/media_player2.dart +++ /dev/null @@ -1,207 +0,0 @@ -// This file was generated using the following command and may be overwritten. -// dart-dbus generate-object defs/org.mpris.MediaPlayer2.xml - -import 'package:bitsdojo_window/bitsdojo_window.dart'; -import 'package:dbus/dbus.dart'; - -class Media_Player extends DBusObject { - /// Creates a new object to expose on [path]. - Media_Player() : super(DBusObjectPath('/org/mpris/MediaPlayer2')); - - /// Gets value of property org.mpris.MediaPlayer2.CanQuit - Future getCanQuit() async { - return DBusMethodSuccessResponse([const DBusBoolean(true)]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Fullscreen - Future getFullscreen() async { - return DBusMethodSuccessResponse([const DBusBoolean(false)]); - } - - /// Sets property org.mpris.MediaPlayer2.Fullscreen - Future setFullscreen(bool value) async { - return DBusMethodSuccessResponse(); - } - - /// Gets value of property org.mpris.MediaPlayer2.CanSetFullscreen - Future getCanSetFullscreen() async { - return DBusMethodSuccessResponse([const DBusBoolean(false)]); - } - - /// Gets value of property org.mpris.MediaPlayer2.CanRaise - Future getCanRaise() async { - return DBusMethodSuccessResponse([const DBusBoolean(false)]); - } - - /// Gets value of property org.mpris.MediaPlayer2.HasTrackList - Future getHasTrackList() async { - return DBusMethodSuccessResponse([const DBusBoolean(false)]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Identity - Future getIdentity() async { - return DBusMethodSuccessResponse([const DBusString("Spotube")]); - } - - /// Gets value of property org.mpris.MediaPlayer2.DesktopEntry - Future getDesktopEntry() async { - return DBusMethodSuccessResponse([const DBusString("spotube")]); - } - - /// Gets value of property org.mpris.MediaPlayer2.SupportedUriSchemes - Future getSupportedUriSchemes() async { - return DBusMethodSuccessResponse([ - DBusArray.string(["http"]) - ]); - } - - /// Gets value of property org.mpris.MediaPlayer2.SupportedMimeTypes - Future getSupportedMimeTypes() async { - return DBusMethodSuccessResponse([ - DBusArray.string(["audio/mpeg"]) - ]); - } - - /// Implementation of org.mpris.MediaPlayer2.Raise() - Future doRaise() async { - return DBusMethodSuccessResponse(); - } - - /// Implementation of org.mpris.MediaPlayer2.Quit() - Future doQuit() async { - appWindow.close(); - return DBusMethodSuccessResponse(); - } - - @override - List introspect() { - return [ - DBusIntrospectInterface('org.mpris.MediaPlayer2', methods: [ - DBusIntrospectMethod('Raise'), - DBusIntrospectMethod('Quit') - ], properties: [ - DBusIntrospectProperty('CanQuit', DBusSignature('b'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('Fullscreen', DBusSignature('b'), - access: DBusPropertyAccess.readwrite), - DBusIntrospectProperty('CanSetFullscreen', DBusSignature('b'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('CanRaise', DBusSignature('b'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('HasTrackList', DBusSignature('b'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('Identity', DBusSignature('s'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('DesktopEntry', DBusSignature('s'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('SupportedUriSchemes', DBusSignature('as'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('SupportedMimeTypes', DBusSignature('as'), - access: DBusPropertyAccess.read) - ]) - ]; - } - - @override - Future handleMethodCall(DBusMethodCall methodCall) async { - if (methodCall.interface == 'org.mpris.MediaPlayer2') { - if (methodCall.name == 'Raise') { - if (methodCall.values.isNotEmpty) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doRaise(); - } else if (methodCall.name == 'Quit') { - if (methodCall.values.isNotEmpty) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doQuit(); - } else { - return DBusMethodErrorResponse.unknownMethod(); - } - } else { - return DBusMethodErrorResponse.unknownInterface(); - } - } - - @override - Future getProperty(String interface, String name) async { - if (interface == 'org.mpris.MediaPlayer2') { - if (name == 'CanQuit') { - return getCanQuit(); - } else if (name == 'Fullscreen') { - return getFullscreen(); - } else if (name == 'CanSetFullscreen') { - return getCanSetFullscreen(); - } else if (name == 'CanRaise') { - return getCanRaise(); - } else if (name == 'HasTrackList') { - return getHasTrackList(); - } else if (name == 'Identity') { - return getIdentity(); - } else if (name == 'DesktopEntry') { - return getDesktopEntry(); - } else if (name == 'SupportedUriSchemes') { - return getSupportedUriSchemes(); - } else if (name == 'SupportedMimeTypes') { - return getSupportedMimeTypes(); - } else { - return DBusMethodErrorResponse.unknownProperty(); - } - } else { - return DBusMethodErrorResponse.unknownProperty(); - } - } - - @override - Future setProperty( - String interface, String name, DBusValue value) async { - if (interface == 'org.mpris.MediaPlayer2') { - if (name == 'CanQuit') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'Fullscreen') { - if (value.signature != DBusSignature('b')) { - return DBusMethodErrorResponse.invalidArgs(); - } - return setFullscreen((value as DBusBoolean).value); - } else if (name == 'CanSetFullscreen') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'CanRaise') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'HasTrackList') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'Identity') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'DesktopEntry') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'SupportedUriSchemes') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'SupportedMimeTypes') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else { - return DBusMethodErrorResponse.unknownProperty(); - } - } else { - return DBusMethodErrorResponse.unknownProperty(); - } - } - - @override - Future getAllProperties(String interface) async { - var properties = {}; - if (interface == 'org.mpris.MediaPlayer2') { - properties['CanQuit'] = (await getCanQuit()).returnValues[0]; - properties['Fullscreen'] = (await getFullscreen()).returnValues[0]; - properties['CanSetFullscreen'] = - (await getCanSetFullscreen()).returnValues[0]; - properties['CanRaise'] = (await getCanRaise()).returnValues[0]; - properties['HasTrackList'] = (await getHasTrackList()).returnValues[0]; - properties['Identity'] = (await getIdentity()).returnValues[0]; - properties['DesktopEntry'] = (await getDesktopEntry()).returnValues[0]; - properties['SupportedUriSchemes'] = - (await getSupportedUriSchemes()).returnValues[0]; - properties['SupportedMimeTypes'] = - (await getSupportedMimeTypes()).returnValues[0]; - } - return DBusMethodSuccessResponse([DBusDict.stringVariant(properties)]); - } -} diff --git a/lib/main.dart b/lib/main.dart index 9f3d97ff7..e6b47da2d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,37 +1,26 @@ 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'; +import 'package:spotube/services/MobileAudioService.dart'; import 'package:spotube/themes/dark-theme.dart'; import 'package:spotube/themes/light-theme.dart'; -import 'package:spotube/utils/AudioPlayerHandler.dart'; import 'package:spotube/utils/platform.dart'; 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(); }); } + MobileAudioService? 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: () => MobileAudioService(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/models/SpotubeTrack.dart b/lib/models/SpotubeTrack.dart index a1edaaaaa..5b2657d40 100644 --- a/lib/models/SpotubeTrack.dart +++ b/lib/models/SpotubeTrack.dart @@ -1,4 +1,6 @@ import 'package:spotify/spotify.dart'; +import 'package:spotube/models/CurrentPlaylist.dart'; +import 'package:spotube/extensions/yt-video-from-cache-track.dart'; import 'package:youtube_explode_dart/youtube_explode_dart.dart'; enum SpotubeTrackMatchAlgorithm { @@ -14,11 +16,16 @@ class SpotubeTrack extends Track { Video ytTrack; String ytUri; + SpotubeTrack( + this.ytTrack, + this.ytUri, + ) : super(); + SpotubeTrack.fromTrack({ required Track track, required this.ytTrack, required this.ytUri, - }) { + }) : super() { album = track.album; artists = track.artists; availableMarkets = track.availableMarkets; @@ -38,4 +45,38 @@ class SpotubeTrack extends Track { type = track.type; uri = track.uri; } + + static SpotubeTrack fromJson(Map map) { + return SpotubeTrack.fromTrack( + track: Track.fromJson(map), + ytTrack: VideoToJson.fromJson(map["ytTrack"]), + ytUri: map["ytUri"], + ); + } + + Map toJson() { + return { + "album": album?.toJson(), + "artists": artists?.map((artist) => artist.toJson()).toList(), + "availableMarkets": availableMarkets, + "discNumber": discNumber, + "duration": duration.toString(), + "durationMs": durationMs, + "explicit": explicit, + // "externalIds": externalIds, + // "externalUrls": externalUrls, + "href": href, + "id": id, + "isPlayable": isPlayable, + // "linkedFrom": linkedFrom, + "name": name, + "popularity": popularity, + "previewUrl": previewUrl, + "trackNumber": trackNumber, + "type": type, + "uri": uri, + "ytTrack": ytTrack.toJson(), + "ytUri": ytUri, + }; + } } diff --git a/lib/provider/AudioPlayer.dart b/lib/provider/AudioPlayer.dart index 6aff379a9..3fd44b985 100644 --- a/lib/provider/AudioPlayer.dart +++ b/lib/provider/AudioPlayer.dart @@ -1,5 +1,5 @@ +import 'package:audioplayers/audioplayers.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:just_audio/just_audio.dart'; final audioPlayerProvider = Provider((ref) { return AudioPlayer(); diff --git a/lib/provider/DBus.dart b/lib/provider/DBus.dart index a0b0e942a..627046595 100644 --- a/lib/provider/DBus.dart +++ b/lib/provider/DBus.dart @@ -1,6 +1,12 @@ +import 'dart:io'; + import 'package:dbus/dbus.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -final dbusClientProvider = Provider((ref) { - return DBusClient.session(); +final Provider dbusClientProvider = Provider((ref) { + if (Platform.isLinux) { + return DBusClient.session(); + } }); + +final dbus = DBusClient.session(); diff --git a/lib/provider/Playback.dart b/lib/provider/Playback.dart index 30efe2270..a543b7a24 100644 --- a/lib/provider/Playback.dart +++ b/lib/provider/Playback.dart @@ -3,336 +3,384 @@ import 'dart:convert'; import 'dart:io'; import 'package:audio_service/audio_service.dart'; -import 'package:dbus/dbus.dart'; -import 'package:flutter/widgets.dart'; +import 'package:audioplayers/audioplayers.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive/hive.dart'; -import 'package:just_audio/just_audio.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/services/LinuxAudioService.dart'; +import 'package:spotube/services/MobileAudioService.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 { - AudioSource? _currentAudioSource; - final _logger = getLogger(Playback); - CurrentPlaylist? _currentPlaylist; - Track? _currentTrack; - - // states - bool _isPlaying = false; - Duration? duration; + // player properties + bool isShuffled; + bool isPlaying; + Duration currentDuration; + double volume; - Duration _prevPosition = Duration.zero; - bool _shuffled = false; + // class dependencies + LinuxAudioService? _linuxAudioService; + MobileAudioService? mobileAudioService; - 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; - final Media_Player _media_player; - late final Player_Interface _mpris; + // 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, - _media_player = Media_Player(), + this.mobileAudioService, + }) : volume = 0, + isShuffled = false, + isPlaying = false, + currentDuration = Duration.zero, + _subscriptions = [], super() { - _mpris = Player_Interface(player: player.core, playback: this); - player.onNextRequest = () { - movePlaylistPositionBy(1); - }; - player.onPreviousRequest = () { - movePlaylistPositionBy(-1); - }; + if (Platform.isLinux) { + _linuxAudioService = LinuxAudioService(this); + } - _init(); + (() 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; + currentDuration = Duration.zero; + notifyListeners(); + } + }), + player.onDurationChanged.listen((event) { + if (event != currentDuration) { + currentDuration = event; + notifyListeners(); + } + }), + player.onPositionChanged.listen((pos) async { + if (pos > Duration.zero && currentDuration == Duration.zero) { + currentDuration = await player.getDuration() ?? Duration.zero; + notifyListeners(); + } + }), + ]); + }()); } - StreamSubscription? _durationStream; - StreamSubscription? _positionStream; - StreamSubscription? _playingStream; - - void _init() async { - // dbus m.p.r.i.s stuff - try { - 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); + @override + void dispose() { + _linuxAudioService?.dispose(); + for (var subscription in _subscriptions) { + subscription.cancel(); } + super.dispose(); + } - cacheTrackBox = await Hive.openLazyBox("track-cache"); + 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!; + }); + } - _playingStream = player.core.playingStream.listen( - (playing) { - _isPlaying = playing; - notifyListeners(); - }, - ); + // 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.id == this.track?.id) return; + 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); - _durationStream = player.core.durationStream.listen((event) async { - if (event != null) { - // Actually things doesn't work all the time as they were - // described. So instead of listening to a `_ready` - // stream, it has to listen to duration stream since duration - // is always added to the Stream sink after all icyMetadata has - // been loaded thus indicating buffering started - if (event != Duration.zero && event != duration) { - // this line is for prev/next or already playing playlist - if (player.core.playing) await player.pause(); - await player.play(); - } - duration = event; - notifyListeners(); + // the track is not a SpotubeTrack so turning it to one + if (track is! SpotubeTrack) { + track = await toSpotubeTrack(track); } - }); + _logger.v("[Track Direct Source] - ${(track).ytUri}"); + this.track = track; + notifyListeners(); + updatePersistence(); + await player.play(UrlSource(track.ytUri)); + } catch (e, stack) { + _logger.e("play", e, stack); + } + } - _positionStream = - player.core.createPositionStream().listen((position) async { - // detecting multiple same call - if (_prevPosition.inSeconds == position.inSeconds) return; - _prevPosition = position; - - /// Because of ProcessingState.complete never gets set bug using a - /// custom solution to know when the audio stops playing - /// - /// Details: https://github.com/KRTirtho/spotube/issues/46 - if (duration != Duration.zero && - duration?.isNegative == false && - position.inSeconds == duration?.inSeconds) { - if (_currentTrack?.id != null) { - await player.pause(); - movePlaylistPositionBy(1); - } else { - _isPlaying = false; - duration = null; - notifyListeners(); - } - } - }); + Future resume() async { + if (isPlaying || (playlist == null && track == null)) return; + await player.resume(); + isPlaying = true; + notifyListeners(); } - @override - void dispose() { - _positionStream?.cancel(); - _playingStream?.cancel(); - _durationStream?.cancel(); - cacheTrackBox?.close(); - dbus.unregisterObject(_media_player); - dbus.unregisterObject(_mpris); - super.dispose(); + Future pause() async { + if (!isPlaying || (playlist == null && track == null)) return; + await player.pause(); + isPlaying = false; + notifyListeners(); } - bool get shuffled => _shuffled; - CurrentPlaylist? get currentPlaylist => _currentPlaylist; - Track? get currentTrack => _currentTrack; - bool get isPlaying => _isPlaying; + Future togglePlayPause() async { + isPlaying ? await pause() : await resume(); + } - set setCurrentTrack(Track track) { - _logger.v("[Setting Current Track] ${track.name} - ${track.id}"); - _currentTrack = track; - notifyListeners(); - updatePersistence(); + toggleShuffle() { + final result = isShuffled ? playlist?.unshuffle() : playlist?.shuffle(); + if (result == true) { + isShuffled = !isShuffled; + notifyListeners(); + } + } + + Future seekPosition(Duration position) { + return player.seek(position); } - set setCurrentPlaylist(CurrentPlaylist playlist) { - _logger.v("[Current Playlist Changed] ${playlist.name} - ${playlist.id}"); - _currentPlaylist = playlist; + Future setVolume(double newVolume) async { + await player.setVolume(volume); + volume = newVolume; notifyListeners(); updatePersistence(); } - void reset() { - _logger.v("Playback Reset"); - _isPlaying = false; - _shuffled = false; - duration = null; - _currentPlaylist = null; - _currentTrack = null; + Future stop() async { + await player.stop(); + await player.release(); + isPlaying = false; + isShuffled = false; + playlist = null; + track = null; + currentDuration = Duration.zero; notifyListeners(); updatePersistence(clearNullEntries: true); } - /// 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 destroy() { + stop(); + player.dispose(); } - 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(); + // 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; } } - } - 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 = AudioSource.uri(parsedUri); - await player.core - .setAudioSource( - _currentAudioSource!, - preload: true, - ) - .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 = AudioSource.uri(Uri.parse(spotubeTrack.ytUri)); - await player.core - .setAudioSource( - _currentAudioSource!, - preload: true, - ) - .then((value) { - _currentTrack = spotubeTrack; - notifyListeners(); - updatePersistence(); - }); - } + 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; } - } catch (e, stack) { - _logger.e("startPlaying", e, stack); + }); + + 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, + ); } - void shuffle() { - if (currentPlaylist?.shuffle() == true) { - _shuffled = true; - notifyListeners(); - } + Future setPlaylistPosition(int position) async { + if (playlist == null) return; + await playPlaylist(playlist!, position); } - void unshuffle() { - if (currentPlaylist?.unshuffle() == true) { - _shuffled = false; - notifyListeners(); - } + 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)); } @override - FutureOr loadFromLocal(Map map) { - if (map["currentPlaylist"] != null) { - _currentPlaylist = - CurrentPlaylist.fromJson(jsonDecode(map["currentPlaylist"])); + FutureOr loadFromLocal(Map map) async { + if (map["playlist"] != null) { + playlist = CurrentPlaylist.fromJson(jsonDecode(map["playlist"])); } - if (map["currentTrack"] != null) { - _currentTrack = Track.fromJson(jsonDecode(map["currentTrack"])); - startPlaying().then((_) { - Timer.periodic(const Duration(milliseconds: 100), (timer) { - if (player.core.playing) { - player.pause(); - timer.cancel(); - } - }); - }); + if (map["track"] != null) { + track = SpotubeTrack.fromJson(jsonDecode(map["track"])); } + volume = map["volume"] ?? volume; } @override FutureOr> toMap() { return { - "currentPlaylist": currentPlaylist != null - ? jsonEncode(currentPlaylist?.toJson()) - : null, - "currentTrack": - currentTrack != null ? jsonEncode(currentTrack?.toJson()) : null, + "playlist": playlist != null ? jsonEncode(playlist?.toJson()) : null, + "track": track != null ? jsonEncode(track?.toJson()) : null, + "volume": volume, }; } } 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 76fe6f5fb..aabe6d2e5 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/interfaces/media_player2_player.dart b/lib/services/LinuxAudioService.dart similarity index 61% rename from lib/interfaces/media_player2_player.dart rename to lib/services/LinuxAudioService.dart index 8e65c62ee..ea620fef7 100644 --- a/lib/interfaces/media_player2_player.dart +++ b/lib/services/LinuxAudioService.dart @@ -1,27 +1,247 @@ -// This file was generated using the following command and may be overwritten. -// dart-dbus generate-object defs/org.mpris.MediaPlayer2.Player.xml +import 'dart:io'; +import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:dbus/dbus.dart'; -import 'package:just_audio/just_audio.dart'; + +import 'package:spotube/provider/DBus.dart'; import 'package:spotube/helpers/image-to-url-string.dart'; import 'package:spotube/models/SpotubeTrack.dart'; import 'package:spotube/provider/Playback.dart'; -class Player_Interface extends DBusObject { - final AudioPlayer player; +class _MprisMediaPlayer2 extends DBusObject { + /// Creates a new object to expose on [path]. + _MprisMediaPlayer2() : super(DBusObjectPath('/org/mpris/MediaPlayer2')) { + dbus.registerObject(this); + } + + void dispose() { + dbus.unregisterObject(this); + } + + /// Gets value of property org.mpris.MediaPlayer2.CanQuit + Future getCanQuit() async { + return DBusMethodSuccessResponse([const DBusBoolean(true)]); + } + + /// Gets value of property org.mpris.MediaPlayer2.Fullscreen + Future getFullscreen() async { + return DBusMethodSuccessResponse([const DBusBoolean(false)]); + } + + /// Sets property org.mpris.MediaPlayer2.Fullscreen + Future setFullscreen(bool value) async { + return DBusMethodSuccessResponse(); + } + + /// Gets value of property org.mpris.MediaPlayer2.CanSetFullscreen + Future getCanSetFullscreen() async { + return DBusMethodSuccessResponse([const DBusBoolean(false)]); + } + + /// Gets value of property org.mpris.MediaPlayer2.CanRaise + Future getCanRaise() async { + return DBusMethodSuccessResponse([const DBusBoolean(false)]); + } + + /// Gets value of property org.mpris.MediaPlayer2.HasTrackList + Future getHasTrackList() async { + return DBusMethodSuccessResponse([const DBusBoolean(false)]); + } + + /// Gets value of property org.mpris.MediaPlayer2.Identity + Future getIdentity() async { + return DBusMethodSuccessResponse([const DBusString("Spotube")]); + } + + /// Gets value of property org.mpris.MediaPlayer2.DesktopEntry + Future getDesktopEntry() async { + return DBusMethodSuccessResponse([const DBusString("spotube")]); + } + + /// Gets value of property org.mpris.MediaPlayer2.SupportedUriSchemes + Future getSupportedUriSchemes() async { + return DBusMethodSuccessResponse([ + DBusArray.string(["http"]) + ]); + } + + /// Gets value of property org.mpris.MediaPlayer2.SupportedMimeTypes + Future getSupportedMimeTypes() async { + return DBusMethodSuccessResponse([ + DBusArray.string(["audio/mpeg"]) + ]); + } + + /// Implementation of org.mpris.MediaPlayer2.Raise() + Future doRaise() async { + return DBusMethodSuccessResponse(); + } + + /// Implementation of org.mpris.MediaPlayer2.Quit() + Future doQuit() async { + appWindow.close(); + return DBusMethodSuccessResponse(); + } + + @override + List introspect() { + return [ + DBusIntrospectInterface('org.mpris.MediaPlayer2', methods: [ + DBusIntrospectMethod('Raise'), + DBusIntrospectMethod('Quit') + ], properties: [ + DBusIntrospectProperty('CanQuit', DBusSignature('b'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('Fullscreen', DBusSignature('b'), + access: DBusPropertyAccess.readwrite), + DBusIntrospectProperty('CanSetFullscreen', DBusSignature('b'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('CanRaise', DBusSignature('b'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('HasTrackList', DBusSignature('b'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('Identity', DBusSignature('s'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('DesktopEntry', DBusSignature('s'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('SupportedUriSchemes', DBusSignature('as'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('SupportedMimeTypes', DBusSignature('as'), + access: DBusPropertyAccess.read) + ]) + ]; + } + + @override + Future handleMethodCall(DBusMethodCall methodCall) async { + if (methodCall.interface == 'org.mpris.MediaPlayer2') { + if (methodCall.name == 'Raise') { + if (methodCall.values.isNotEmpty) { + return DBusMethodErrorResponse.invalidArgs(); + } + return doRaise(); + } else if (methodCall.name == 'Quit') { + if (methodCall.values.isNotEmpty) { + return DBusMethodErrorResponse.invalidArgs(); + } + return doQuit(); + } else { + return DBusMethodErrorResponse.unknownMethod(); + } + } else { + return DBusMethodErrorResponse.unknownInterface(); + } + } + + @override + Future getProperty(String interface, String name) async { + if (interface == 'org.mpris.MediaPlayer2') { + if (name == 'CanQuit') { + return getCanQuit(); + } else if (name == 'Fullscreen') { + return getFullscreen(); + } else if (name == 'CanSetFullscreen') { + return getCanSetFullscreen(); + } else if (name == 'CanRaise') { + return getCanRaise(); + } else if (name == 'HasTrackList') { + return getHasTrackList(); + } else if (name == 'Identity') { + return getIdentity(); + } else if (name == 'DesktopEntry') { + return getDesktopEntry(); + } else if (name == 'SupportedUriSchemes') { + return getSupportedUriSchemes(); + } else if (name == 'SupportedMimeTypes') { + return getSupportedMimeTypes(); + } else { + return DBusMethodErrorResponse.unknownProperty(); + } + } else { + return DBusMethodErrorResponse.unknownProperty(); + } + } + + @override + Future setProperty( + String interface, String name, DBusValue value) async { + if (interface == 'org.mpris.MediaPlayer2') { + if (name == 'CanQuit') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'Fullscreen') { + if (value.signature != DBusSignature('b')) { + return DBusMethodErrorResponse.invalidArgs(); + } + return setFullscreen((value as DBusBoolean).value); + } else if (name == 'CanSetFullscreen') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'CanRaise') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'HasTrackList') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'Identity') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'DesktopEntry') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'SupportedUriSchemes') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'SupportedMimeTypes') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else { + return DBusMethodErrorResponse.unknownProperty(); + } + } else { + return DBusMethodErrorResponse.unknownProperty(); + } + } + + @override + Future getAllProperties(String interface) async { + var properties = {}; + if (interface == 'org.mpris.MediaPlayer2') { + properties['CanQuit'] = (await getCanQuit()).returnValues[0]; + properties['Fullscreen'] = (await getFullscreen()).returnValues[0]; + properties['CanSetFullscreen'] = + (await getCanSetFullscreen()).returnValues[0]; + properties['CanRaise'] = (await getCanRaise()).returnValues[0]; + properties['HasTrackList'] = (await getHasTrackList()).returnValues[0]; + properties['Identity'] = (await getIdentity()).returnValues[0]; + properties['DesktopEntry'] = (await getDesktopEntry()).returnValues[0]; + properties['SupportedUriSchemes'] = + (await getSupportedUriSchemes()).returnValues[0]; + properties['SupportedMimeTypes'] = + (await getSupportedMimeTypes()).returnValues[0]; + } + return DBusMethodSuccessResponse([DBusDict.stringVariant(properties)]); + } +} + +class _MprisMediaPlayer2Player extends DBusObject { final Playback playback; /// Creates a new object to expose on [path]. - Player_Interface({ - required this.player, + _MprisMediaPlayer2Player({ 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.playing + final status = playback.isPlaying ? "Playing" - : playback.currentPlaylist == null + : playback.playlist == null ? "Stopped" : "Paused"; return DBusMethodSuccessResponse([DBusString(status)]); @@ -40,39 +260,34 @@ class Player_Interface extends DBusObject { /// Gets value of property org.mpris.MediaPlayer2.Player.Rate Future getRate() async { - return DBusMethodSuccessResponse([DBusDouble(player.speed)]); + return DBusMethodSuccessResponse([DBusDouble(1)]); } /// Sets property org.mpris.MediaPlayer2.Player.Rate Future setRate(double value) async { - player.setSpeed(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 +295,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"), }), @@ -104,19 +319,19 @@ class Player_Interface extends DBusObject { /// Gets value of property org.mpris.MediaPlayer2.Player.Volume Future getVolume() async { - return DBusMethodSuccessResponse([DBusDouble(player.volume)]); + return DBusMethodSuccessResponse([DBusDouble(playback.volume)]); } /// Sets property org.mpris.MediaPlayer2.Player.Volume Future setVolume(double value) async { - player.setVolume(value); + playback.setVolume(value); return DBusMethodSuccessResponse(); } /// Gets value of property org.mpris.MediaPlayer2.Player.Position Future getPosition() async { return DBusMethodSuccessResponse([ - DBusInt64(player.position.inMicroseconds), + DBusInt64((await playback.player.getDuration())?.inMicroseconds ?? 0), ]); } @@ -134,7 +349,7 @@ class Player_Interface extends DBusObject { Future getCanGoNext() async { return DBusMethodSuccessResponse([ DBusBoolean( - playback.currentPlaylist?.tracks.isNotEmpty == true, + playback.playlist?.tracks.isNotEmpty == true, ) ]); } @@ -143,7 +358,7 @@ class Player_Interface extends DBusObject { Future getCanGoPrevious() async { return DBusMethodSuccessResponse([ DBusBoolean( - playback.currentPlaylist?.tracks.isNotEmpty == true, + playback.playlist?.tracks.isNotEmpty == true, ) ]); } @@ -170,45 +385,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.playing ? player.pause() : player.play(); + 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.play(); + 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(); } @@ -468,3 +681,17 @@ class Player_Interface extends DBusObject { return DBusMethodSuccessResponse([DBusDict.stringVariant(properties)]); } } + +class LinuxAudioService { + _MprisMediaPlayer2 mp2; + _MprisMediaPlayer2Player player; + + LinuxAudioService(Playback playback) + : mp2 = _MprisMediaPlayer2(), + player = _MprisMediaPlayer2Player(playback: playback); + + void dispose() { + mp2.dispose(); + player.dispose(); + } +} diff --git a/lib/services/MobileAudioService.dart b/lib/services/MobileAudioService.dart new file mode 100644 index 000000000..d6f97f105 --- /dev/null +++ b/lib/services/MobileAudioService.dart @@ -0,0 +1,84 @@ +import 'dart:async'; + +import 'package:audio_service/audio_service.dart'; +import 'package:audioplayers/audioplayers.dart'; +import 'package:spotube/provider/Playback.dart'; + +class MobileAudioService extends BaseAudioHandler { + final Playback playback; + + MobileAudioService(this.playback) { + final _player = playback.player; + _player.onPlayerStateChanged.listen((state) async { + if (state != PlayerState.completed) { + playbackState.add(await _transformEvent()); + } + }); + + _player.onPlayerComplete.listen((_) { + if (playback.playlist == null && playback.track == null) { + playbackState.add( + PlaybackState( + processingState: AudioProcessingState.completed, + ), + ); + } + }); + } + + void addItem(MediaItem item) { + mediaItem.add(item); + } + + @override + Future play() => playback.resume(); + + @override + Future pause() => playback.pause(); + + @override + Future seek(Duration position) => playback.seekPosition(position); + + @override + Future stop() => playback.stop(); + + @override + Future skipToNext() async { + playback.seekForward(); + await super.skipToNext(); + } + + @override + Future skipToPrevious() async { + playback.seekBackward(); + await super.skipToPrevious(); + } + + @override + Future onTaskRemoved() { + playback.destroy(); + return super.onTaskRemoved(); + } + + Future _transformEvent() async { + return PlaybackState( + controls: [ + MediaControl.skipToPrevious, + playback.player.state == PlayerState.playing + ? MediaControl.pause + : MediaControl.play, + MediaControl.skipToNext, + MediaControl.stop, + ], + androidCompactActionIndices: const [0, 1, 2], + playing: playback.player.state == PlayerState.playing, + updatePosition: + (await playback.player.getCurrentPosition()) ?? Duration.zero, + processingState: playback.player.state == PlayerState.paused + ? AudioProcessingState.buffering + : playback.player.state == PlayerState.playing + ? AudioProcessingState.ready + : AudioProcessingState.idle, + ); + } +} diff --git a/lib/utils/AudioPlayerHandler.dart b/lib/utils/AudioPlayerHandler.dart deleted file mode 100644 index eeb37b4a4..000000000 --- a/lib/utils/AudioPlayerHandler.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'dart:async'; - -import 'package:audio_service/audio_service.dart'; -import 'package:just_audio/just_audio.dart'; - -/// An [AudioHandler] for playing a single item. -class AudioPlayerHandler extends BaseAudioHandler { - final _player = AudioPlayer(); - - FutureOr Function()? onNextRequest; - FutureOr Function()? onPreviousRequest; - - /// Initialise our audio handler. - AudioPlayerHandler() { - // 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... - _player.playbackEventStream.map(_transformEvent).pipe(playbackState); - } - - AudioPlayer get core => _player; - - void addItem(MediaItem item) { - mediaItem.add(item); - } - - // In this simple example, we handle only 4 actions: play, pause, seek and - // stop. Any button press from the Flutter UI, notification, lock screen or - // headset will be routed through to these 4 methods so that you can handle - // your audio playback logic in one place. - - @override - Future play() => _player.play(); - - @override - Future pause() => _player.pause(); - - @override - Future seek(Duration position) => _player.seek(position); - - @override - Future stop() => _player.stop(); - - @override - Future skipToNext() async { - await onNextRequest?.call(); - await super.skipToNext(); - } - - @override - Future skipToPrevious() async { - await onPreviousRequest?.call(); - await super.skipToPrevious(); - } - - @override - Future onTaskRemoved() { - _player.stop(); - return super.onTaskRemoved(); - } - - /// Transform a just_audio event into an audio_service state. - /// - /// This method is used from the constructor. Every event received from the - /// just_audio player will be transformed into an audio_service state so that - /// it can be broadcast to audio_service clients. - PlaybackState _transformEvent(PlaybackEvent event) { - return PlaybackState( - controls: [ - MediaControl.skipToPrevious, - if (_player.playing) MediaControl.pause else MediaControl.play, - MediaControl.skipToNext, - MediaControl.stop, - ], - androidCompactActionIndices: const [0, 1, 2], - processingState: const { - ProcessingState.idle: AudioProcessingState.idle, - ProcessingState.loading: AudioProcessingState.loading, - ProcessingState.buffering: AudioProcessingState.buffering, - ProcessingState.ready: AudioProcessingState.ready, - ProcessingState.completed: AudioProcessingState.completed, - }[_player.processingState]!, - playing: _player.playing, - updatePosition: _player.position, - bufferedPosition: _player.bufferedPosition, - speed: _player.speed, - queueIndex: event.currentIndex, - ); - } -} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index a4c04a0f6..01b8e0f78 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,17 +6,17 @@ #include "generated_plugin_registrant.h" +#include #include -#include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin"); + audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar); g_autoptr(FlPluginRegistrar) bitsdojo_window_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "BitsdojoWindowPlugin"); bitsdojo_window_plugin_register_with_registrar(bitsdojo_window_linux_registrar); - g_autoptr(FlPluginRegistrar) libwinmedia_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "LibwinmediaPlugin"); - libwinmedia_plugin_register_with_registrar(libwinmedia_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 51b46f6e5..9aebc645e 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,8 +3,8 @@ # list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_linux bitsdojo_window_linux - libwinmedia url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 435c9977d..3e375fb36 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,8 +7,8 @@ import Foundation import audio_service import audio_session +import audioplayers_darwin import bitsdojo_window_macos -import just_audio import package_info_plus_macos import path_provider_macos import shared_preferences_macos @@ -18,8 +18,8 @@ import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin")) AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) + AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) BitsdojoWindowPlugin.register(with: registry.registrar(forPlugin: "BitsdojoWindowPlugin")) - JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) FLTPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 1c2129242..fd237e39f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -71,6 +71,55 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.1.6+1" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" bitsdojo_window: dependency: "direct main" description: @@ -499,41 +548,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "4.5.0" - just_audio: - dependency: "direct main" - description: - name: just_audio - url: "https://pub.dartlang.org" - source: hosted - version: "0.9.21" - just_audio_libwinmedia: - dependency: "direct main" - description: - name: just_audio_libwinmedia - url: "https://pub.dartlang.org" - source: hosted - version: "0.0.4" - just_audio_platform_interface: - dependency: transitive - description: - name: just_audio_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "4.1.0" - just_audio_web: - dependency: transitive - description: - name: just_audio_web - url: "https://pub.dartlang.org" - source: hosted - version: "0.4.7" - libwinmedia: - dependency: transitive - description: - name: libwinmedia - url: "https://pub.dartlang.org" - source: hosted - version: "0.0.7" lints: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 2f3a3c380..4c4b00aba 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,8 +42,6 @@ dependencies: url_launcher: ^6.0.17 youtube_explode_dart: ^1.10.8 bitsdojo_window: ^0.1.2 - just_audio: ^0.9.18 - just_audio_libwinmedia: ^0.0.4 path: ^1.8.0 path_provider: ^2.0.8 collection: ^1.15.0 @@ -64,6 +62,7 @@ dependencies: hive: ^2.2.2 hive_flutter: ^1.1.0 dbus: ^0.7.3 + audioplayers: ^1.0.1 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index a9dc0ec8f..3e689c389 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,16 +6,16 @@ #include "generated_plugin_registrant.h" +#include #include -#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + AudioplayersWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); BitsdojoWindowPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("BitsdojoWindowPlugin")); - LibwinmediaPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("LibwinmediaPlugin")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 44cee1639..c8e970a89 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,8 +3,8 @@ # list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_windows bitsdojo_window_windows - libwinmedia permission_handler_windows url_launcher_windows )