mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-12 23:45:18 +00:00
feat(mpris): MPRIS metadata are now updated in realtime
refactor(download-button): use download provider and queue instead of custom logic
This commit is contained in:
parent
cae9993429
commit
d9addcda8e
@ -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';
|
||||
|
@ -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<Artist>(
|
||||
element.artists ?? []) ==
|
||||
TypeConversionUtils.artists_X_String<Artist>(
|
||||
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<bool>(
|
||||
future: playback.track?.id != 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>(TrackStatus.idle);
|
||||
YoutubeExplode yt = useMemoized(() => YoutubeExplode());
|
||||
|
||||
final outputFile = useState<File?>(null);
|
||||
String fileName =
|
||||
"${track?.name} - ${TypeConversionUtils.artists_X_String<Artist>(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<bool>(
|
||||
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);
|
||||
},
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
31
lib/components/Shared/ReplaceDownloadedFileDialog.dart
Normal file
31
lib/components/Shared/ReplaceDownloadedFileDialog.dart
Normal file
@ -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);
|
||||
},
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -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';
|
||||
|
@ -99,6 +99,10 @@ class Playback extends PersistedChangeNotifier {
|
||||
await player.setVolume(volume);
|
||||
}
|
||||
|
||||
addListener(() {
|
||||
_linuxAudioService?.player.updateProperties(this);
|
||||
});
|
||||
|
||||
_subscriptions.addAll([
|
||||
player.onPlayerStateChanged.listen(
|
||||
(state) async {
|
||||
|
@ -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<void> 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<DBusIntrospectInterface> introspect() {
|
||||
return [
|
||||
|
Loading…
Reference in New Issue
Block a user