diff --git a/lib/components/Home/Home.dart b/lib/components/Home/Home.dart index 6030ed08c..dedef0ddf 100644 --- a/lib/components/Home/Home.dart +++ b/lib/components/Home/Home.dart @@ -12,7 +12,7 @@ import 'package:spotube/components/Home/SpotubeNavigationBar.dart'; import 'package:spotube/components/LoaderShimmers/ShimmerCategories.dart'; import 'package:spotube/components/Lyrics/SyncedLyrics.dart'; import 'package:spotube/components/Search/Search.dart'; -import 'package:spotube/components/Shared/DownloadTrackButton.dart'; +import 'package:spotube/components/Shared/ReplaceDownloadedFileDialog.dart'; import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; import 'package:spotube/components/Player/Player.dart'; import 'package:spotube/components/Library/UserLibrary.dart'; diff --git a/lib/components/Player/PlayerActions.dart b/lib/components/Player/PlayerActions.dart index ac92963d1..08d03e15e 100644 --- a/lib/components/Player/PlayerActions.dart +++ b/lib/components/Player/PlayerActions.dart @@ -1,16 +1,19 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/components/Library/UserLocalTracks.dart'; import 'package:spotube/components/Player/PlayerQueue.dart'; -import 'package:spotube/components/Shared/DownloadTrackButton.dart'; import 'package:spotube/components/Shared/HeartButton.dart'; import 'package:spotube/hooks/useForceUpdate.dart'; import 'package:spotube/models/Logger.dart'; import 'package:spotube/provider/Auth.dart'; +import 'package:spotube/provider/Downloader.dart'; import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/SpotifyDI.dart'; import 'package:spotube/provider/SpotifyRequests.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; class PlayerActions extends HookConsumerWidget { final MainAxisAlignment mainAxisAlignment; @@ -27,7 +30,25 @@ class PlayerActions extends HookConsumerWidget { final SpotifyApi spotifyApi = ref.watch(spotifyProvider); final Playback playback = ref.watch(playbackProvider); final Auth auth = ref.watch(authProvider); + final downloader = ref.watch(downloaderProvider); final update = useForceUpdate(); + final isInQueue = + downloader.inQueue.any((element) => element.id == playback.track?.id); + final localTracks = ref.watch(localTracksProvider).value; + + final isDownloaded = useMemoized(() { + return localTracks?.any( + (element) => + element.name == playback.track?.name && + element.album?.name == playback.track?.album?.name && + TypeConversionUtils.artists_X_String( + element.artists ?? []) == + TypeConversionUtils.artists_X_String( + playback.track?.artists ?? []), + ) == + true; + }, [localTracks, playback.track]); + return Row( mainAxisAlignment: mainAxisAlignment, children: [ @@ -56,9 +77,25 @@ class PlayerActions extends HookConsumerWidget { : null, ), if (!kIsWeb) - DownloadTrackButton( - track: playback.track, - ), + if (isInQueue) + const SizedBox( + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + ), + height: 20, + width: 20, + ) + else + IconButton( + icon: Icon( + isDownloaded + ? Icons.download_done_rounded + : Icons.download_rounded, + ), + onPressed: playback.track != null + ? () => downloader.addToQueue(playback.track!) + : null, + ), if (auth.isLoggedIn) FutureBuilder( future: playback.track?.id != null diff --git a/lib/components/Shared/DownloadTrackButton.dart b/lib/components/Shared/DownloadTrackButton.dart deleted file mode 100644 index 576ffe284..000000000 --- a/lib/components/Shared/DownloadTrackButton.dart +++ /dev/null @@ -1,228 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/components/Library/UserLocalTracks.dart'; -import 'package:spotube/models/SpotubeTrack.dart'; -import 'package:spotube/provider/Playback.dart'; -import 'package:spotube/provider/UserPreferences.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:spotube/utils/service_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; -import 'package:youtube_explode_dart/youtube_explode_dart.dart'; -import 'package:path/path.dart' as path; -import 'package:permission_handler/permission_handler.dart'; -import 'package:collection/collection.dart'; - -enum TrackStatus { downloading, idle, done } - -class DownloadTrackButton extends HookConsumerWidget { - final Track? track; - const DownloadTrackButton({Key? key, this.track}) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final UserPreferences preferences = ref.watch(userPreferencesProvider); - final Playback playback = ref.watch(playbackProvider); - final status = useState(TrackStatus.idle); - YoutubeExplode yt = useMemoized(() => YoutubeExplode()); - - final outputFile = useState(null); - String fileName = - "${track?.name} - ${TypeConversionUtils.artists_X_String(track?.artists ?? [])}"; - - useEffect(() { - (() async { - outputFile.value = - File(path.join(preferences.downloadLocation, "$fileName.m4a")); - }()); - return null; - }, [fileName, track, preferences.downloadLocation]); - - final _downloadTrack = useCallback(() async { - try { - if (track == null || outputFile.value == null) return; - if ((kIsMobile) && - !await Permission.storage.isGranted && - !await Permission.storage.isPermanentlyDenied) { - final status = await Permission.storage.request(); - if (!status.isGranted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: - Text("Couldn't download track. Not enough permissions"), - ), - ); - return; - } - } - StreamManifest manifest = await yt.videos.streamsClient - .getManifest((track as SpotubeTrack).ytTrack.url); - - File outputLyricsFile = File( - path.join(preferences.downloadLocation, "$fileName-lyrics.txt")); - - if (await outputFile.value!.exists()) { - final shouldReplace = await showDialog( - context: context, - builder: (context) { - return ReplaceDownloadedFileDialog(track: track!); - }, - ); - if (shouldReplace != true) return; - } - - final audioStream = yt.videos.streamsClient - .get( - manifest.audioOnly - .where((audio) => audio.codec.mimeType == "audio/mp4") - .withHighestBitrate(), - ) - .asBroadcastStream(); - - final statusCb = audioStream.listen( - (event) { - if (status.value != TrackStatus.downloading) { - status.value = TrackStatus.downloading; - } - }, - onDone: () async { - status.value = TrackStatus.done; - ref.refresh(localTracksProvider); - await Future.delayed( - const Duration(seconds: 3), - () { - if (status.value == TrackStatus.done) { - status.value = TrackStatus.idle; - } - }, - ); - }, - ); - - if (!await outputFile.value!.exists()) { - await outputFile.value!.create(recursive: true); - } - - IOSink outputFileStream = outputFile.value!.openWrite(); - await audioStream.pipe(outputFileStream); - await outputFileStream.flush(); - await outputFileStream.close().then((value) async { - if (status.value == TrackStatus.downloading) { - status.value = TrackStatus.done; - await Future.delayed( - const Duration(seconds: 3), - () { - if (status.value == TrackStatus.done) { - status.value = TrackStatus.idle; - } - }, - ); - } - return statusCb.cancel(); - }); - - if (preferences.saveTrackLyrics && playback.track != null) { - if (!await outputLyricsFile.exists()) { - await outputLyricsFile.create(recursive: true); - } - final lyrics = await ServiceUtils.getLyrics( - playback.track!.name!, - playback.track!.artists - ?.map((s) => s.name) - .whereNotNull() - .toList() ?? - [], - apiKey: preferences.geniusAccessToken, - optimizeQuery: true, - ); - if (lyrics != null) { - await outputLyricsFile.writeAsString( - "$lyrics\n\nPowered by genius.com", - mode: FileMode.writeOnly, - ); - } - } - } on FileSystemException catch (e) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - behavior: SnackBarBehavior.floating, - backgroundColor: Colors.red, - content: Text("Download Failed. ${e.message} ${e.path}"), - ), - ); - } - }, [ - track, - status, - yt, - preferences.saveTrackLyrics, - playback.track, - outputFile.value, - preferences.downloadLocation, - fileName - ]); - - useEffect(() { - return () => yt.close(); - }, []); - - final outputFileExists = useMemoized( - () => outputFile.value?.existsSync() == true, - [outputFile.value, status.value, track], - ); - - if (status.value == TrackStatus.downloading) { - return const SizedBox( - child: CircularProgressIndicator.adaptive( - strokeWidth: 2, - ), - height: 20, - width: 20, - ); - } else if (status.value == TrackStatus.done) { - return const Icon(Icons.download_done_rounded); - } - return IconButton( - icon: Icon( - outputFileExists ? Icons.download_done_rounded : Icons.download_rounded, - ), - onPressed: track != null && - track is SpotubeTrack && - playback.playlist?.isLocal != true - ? _downloadTrack - : null, - ); - } -} - -class ReplaceDownloadedFileDialog extends StatelessWidget { - final Track track; - const ReplaceDownloadedFileDialog({required this.track, Key? key}) - : super(key: key); - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: Text("Track ${track.name} Already Exists"), - content: - const Text("Do you want to replace the already downloaded track?"), - actions: [ - TextButton( - child: const Text("No"), - onPressed: () { - Navigator.pop(context, false); - }, - ), - TextButton( - child: const Text("Yes"), - onPressed: () { - Navigator.pop(context, true); - }, - ) - ], - ); - } -} diff --git a/lib/components/Shared/ReplaceDownloadedFileDialog.dart b/lib/components/Shared/ReplaceDownloadedFileDialog.dart new file mode 100644 index 000000000..d9427411e --- /dev/null +++ b/lib/components/Shared/ReplaceDownloadedFileDialog.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:spotify/spotify.dart'; + +class ReplaceDownloadedFileDialog extends StatelessWidget { + final Track track; + const ReplaceDownloadedFileDialog({required this.track, Key? key}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text("Track ${track.name} Already Exists"), + content: + const Text("Do you want to replace the already downloaded track?"), + actions: [ + TextButton( + child: const Text("No"), + onPressed: () { + Navigator.pop(context, false); + }, + ), + TextButton( + child: const Text("Yes"), + onPressed: () { + Navigator.pop(context, true); + }, + ) + ], + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index b3b45dfc3..b2169978c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,7 +8,7 @@ import 'package:go_router/go_router.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:spotube/components/Shared/DownloadTrackButton.dart'; +import 'package:spotube/components/Shared/ReplaceDownloadedFileDialog.dart'; import 'package:spotube/entities/CacheTrack.dart'; import 'package:spotube/models/GoRouteDeclarations.dart'; import 'package:spotube/models/LocalStorageKeys.dart'; diff --git a/lib/provider/Playback.dart b/lib/provider/Playback.dart index ba93eec8d..8b472f503 100644 --- a/lib/provider/Playback.dart +++ b/lib/provider/Playback.dart @@ -99,6 +99,10 @@ class Playback extends PersistedChangeNotifier { await player.setVolume(volume); } + addListener(() { + _linuxAudioService?.player.updateProperties(this); + }); + _subscriptions.addAll([ player.onPlayerStateChanged.listen( (state) async { diff --git a/lib/services/LinuxAudioService.dart b/lib/services/LinuxAudioService.dart index 9930df555..0b6b2d73a 100644 --- a/lib/services/LinuxAudioService.dart +++ b/lib/services/LinuxAudioService.dart @@ -217,7 +217,7 @@ class _MprisMediaPlayer2 extends DBusObject { } class _MprisMediaPlayer2Player extends DBusObject { - final Playback playback; + Playback playback; /// Creates a new object to expose on [path]. _MprisMediaPlayer2Player({ @@ -447,6 +447,30 @@ class _MprisMediaPlayer2Player extends DBusObject { 'org.mpris.MediaPlayer2.Player', 'Seeked', [DBusInt64(Position)]); } + Future updateProperties(Playback playback) async { + this.playback = playback; + return emitPropertiesChanged( + "org.mpris.MediaPlayer2.Player", + changedProperties: { + "PlaybackStatus": (await getPlaybackStatus()).returnValues.first, + "LoopStatus": (await getLoopStatus()).returnValues.first, + "Rate": (await getRate()).returnValues.first, + "Shuffle": (await getShuffle()).returnValues.first, + "Metadata": (await getMetadata()).returnValues.first, + "Volume": (await getVolume()).returnValues.first, + "Position": (await getPosition()).returnValues.first, + "MinimumRate": (await getMinimumRate()).returnValues.first, + "MaximumRate": (await getMaximumRate()).returnValues.first, + "CanGoNext": (await getCanGoNext()).returnValues.first, + "CanGoPrevious": (await getCanGoPrevious()).returnValues.first, + "CanPlay": (await getCanPlay()).returnValues.first, + "CanPause": (await getCanPause()).returnValues.first, + "CanSeek": (await getCanSeek()).returnValues.first, + "CanControl": (await getCanControl()).returnValues.first, + }, + ); + } + @override List introspect() { return [