diff --git a/.vscode/settings.json b/.vscode/settings.json index cad7657d..44bf8e0a 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/lib/components/Player/Player.dart b/lib/components/Player/Player.dart index 329b3b34..1e939456 100644 --- a/lib/components/Player/Player.dart +++ b/lib/components/Player/Player.dart @@ -1,20 +1,14 @@ -import 'dart:async'; - -import 'package:audioplayers/audioplayers.dart'; 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); @@ -26,11 +20,6 @@ class Player extends HookConsumerWidget { final breakpoint = useBreakpoints(); - final Future future = - useMemoized(SharedPreferences.getInstance); - final AsyncSnapshot localStorage = - useFuture(future, initialData: null); - String albumArt = useMemoized( () => imageToUrlString( playback.track?.album?.images, @@ -114,16 +103,29 @@ class Player extends HookConsumerWidget { Container( height: 20, constraints: const BoxConstraints(maxWidth: 200), - child: Slider.adaptive( - value: playback.volume, - onChanged: (value) async { - try { - await playback.setVolume(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/PlayerControls.dart b/lib/components/Player/PlayerControls.dart index 6a70a7fb..e61f06b1 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; @@ -47,41 +47,56 @@ class PlayerControls extends HookConsumerWidget { final sliderMax = duration.inSeconds; final sliderValue = snapshot.data?.inSeconds ?? 0; - final value = (sliderMax == 0 || sliderValue > sliderMax) - ? 0 - : sliderValue / sliderMax; - 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: value.toDouble(), - onChanged: (_) {}, - 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"), - ], + return HookBuilder(builder: (context) { + final progressStatic = + (sliderMax == 0 || sliderValue > sliderMax) + ? 0 + : 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, diff --git a/lib/components/Player/PlayerView.dart b/lib/components/Player/PlayerView.dart index 3ea3f721..63c2b074 100644 --- a/lib/components/Player/PlayerView.dart +++ b/lib/components/Player/PlayerView.dart @@ -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/extensions/yt-video-from-cache-track.dart b/lib/extensions/yt-video-from-cache-track.dart index 3aed8b5b..1777d8cb 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/hooks/playback.dart b/lib/hooks/playback.dart index 07f1f3ae..313691bd 100644 --- a/lib/hooks/playback.dart +++ b/lib/hooks/playback.dart @@ -30,8 +30,15 @@ Future Function() usePreviousTrack(Playback playback) { Future Function([dynamic]) useTogglePlayPause(Playback playback) { return ([key]) async { try { - if (playback.track == null) return; - await playback.togglePlayPause(); + if (playback.track == null) { + return; + } else if (playback.track != null && + playback.currentDuration == Duration.zero && + await playback.player.getCurrentPosition() == Duration.zero) { + await playback.play(); + } else { + await playback.togglePlayPause(); + } } catch (e, stack) { logger.e("useTogglePlayPause", e, stack); } diff --git a/lib/interfaces/media_player2.dart b/lib/interfaces/media_player2.dart deleted file mode 100644 index 44286db2..00000000 --- a/lib/interfaces/media_player2.dart +++ /dev/null @@ -1,214 +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'; -import 'package:spotube/provider/DBus.dart'; - -class Media_Player extends DBusObject { - /// Creates a new object to expose on [path]. - Media_Player() : super(DBusObjectPath('/org/mpris/MediaPlayer2')) { - 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)]); - } -} diff --git a/lib/main.dart b/lib/main.dart index 4a888ec8..e6b47da2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,9 +12,9 @@ import 'package:spotube/provider/AudioPlayer.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 { @@ -33,7 +33,7 @@ void main() async { appWindow.show(); }); } - AudioPlayerHandler? audioServiceHandler; + MobileAudioService? audioServiceHandler; runApp(ProviderScope( child: Spotube(), overrides: [ @@ -50,7 +50,7 @@ void main() async { if (audioServiceHandler == null) { AudioService.init( - builder: () => AudioPlayerHandler(playback), + builder: () => MobileAudioService(playback), config: const AudioServiceConfig( androidNotificationChannelId: 'com.krtirtho.Spotube', androidNotificationChannelName: 'Spotube', diff --git a/lib/models/SpotubeTrack.dart b/lib/models/SpotubeTrack.dart index a1edaaaa..5b2657d4 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/LegacyPlayback.dart b/lib/provider/LegacyPlayback.dart deleted file mode 100644 index 1cf4efaf..00000000 --- a/lib/provider/LegacyPlayback.dart +++ /dev/null @@ -1,325 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:audio_service/audio_service.dart'; -import 'package:audioplayers/audioplayers.dart'; -import 'package:dbus/dbus.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:hive/hive.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/entities/CacheTrack.dart'; -import 'package:spotube/helpers/artist-to-string.dart'; -import 'package:spotube/helpers/image-to-url-string.dart'; -import 'package:spotube/helpers/search-youtube.dart'; -import 'package:spotube/interfaces/media_player2.dart'; -import 'package:spotube/interfaces/media_player2_player.dart'; -import 'package:spotube/models/CurrentPlaylist.dart'; -import 'package:spotube/models/Logger.dart'; -import 'package:spotube/provider/DBus.dart'; -import 'package:spotube/provider/UserPreferences.dart'; -import 'package:spotube/provider/YouTube.dart'; -import 'package:spotube/utils/AudioPlayerHandler.dart'; -import 'package:spotube/utils/PersistedChangeNotifier.dart'; -import 'package:youtube_explode_dart/youtube_explode_dart.dart'; - -class LegacyPlayback extends PersistedChangeNotifier { - UrlSource? _currentAudioSource; - final _logger = getLogger(LegacyPlayback); - CurrentPlaylist? _currentPlaylist; - Track? _currentTrack; - - // states - bool _isPlaying = false; - Duration? duration; - - bool _shuffled = false; - - AudioPlayerHandler player; - YoutubeExplode youtube; - Ref ref; - - LazyBox? cacheTrackBox; - - @protected - final DBusClient? dbus; - Media_Player? _media_player; - Player_Interface? _mpris; - - double volume = 1; - - LegacyPlayback({ - required this.player, - required this.youtube, - required this.ref, - required this.dbus, - CurrentPlaylist? currentPlaylist, - Track? currentTrack, - }) : _currentPlaylist = currentPlaylist, - _currentTrack = currentTrack, - super() { - player.onNextRequest = () { - movePlaylistPositionBy(1); - }; - player.onPreviousRequest = () { - movePlaylistPositionBy(-1); - }; - - _init(); - } - - StreamSubscription? _durationStream; - StreamSubscription? _playingStream; - StreamSubscription? _positionStream; - - void _init() async { - // dbus m.p.r.i.s stuff - if (Platform.isLinux) { - try { - _media_player = Media_Player(); - _mpris = Player_Interface(player: player.core, playback: this); - await dbus?.registerObject(_media_player!); - await dbus?.registerObject(_mpris!); - } catch (e) { - logger.e("[MPRIS initialization error]", e); - } - } - - cacheTrackBox = await Hive.openLazyBox("track-cache"); - - _playingStream = player.core.onPlayerStateChanged.listen( - (state) async { - _isPlaying = state == PlayerState.playing; - if (state == PlayerState.completed) { - if (_currentTrack?.id != null) { - movePlaylistPositionBy(1); - } else { - _isPlaying = false; - duration = null; - } - } - notifyListeners(); - }, - ); - - _durationStream = player.core.onDurationChanged.listen((event) { - duration = event; - notifyListeners(); - }); - - _positionStream = player.core.onPositionChanged.listen((pos) async { - if (pos > Duration.zero && - (duration == null || duration == Duration.zero)) { - duration = await player.core.getDuration(); - notifyListeners(); - } - }); - } - - @override - void dispose() { - _playingStream?.cancel(); - _durationStream?.cancel(); - _positionStream?.cancel(); - cacheTrackBox?.close(); - if (Platform.isLinux && _media_player != null && _mpris != null) { - dbus?.unregisterObject(_media_player!); - dbus?.unregisterObject(_mpris!); - } - super.dispose(); - } - - bool get shuffled => _shuffled; - CurrentPlaylist? get currentPlaylist => _currentPlaylist; - Track? get currentTrack => _currentTrack; - bool get isPlaying => _isPlaying; - - set setCurrentTrack(Track track) { - _logger.v("[Setting Current Track] ${track.name} - ${track.id}"); - _currentTrack = track; - notifyListeners(); - updatePersistence(); - } - - set setCurrentPlaylist(CurrentPlaylist playlist) { - _logger.v("[Current Playlist Changed] ${playlist.name} - ${playlist.id}"); - _currentPlaylist = playlist; - notifyListeners(); - updatePersistence(); - } - - void reset() { - _logger.v("Playback Reset"); - _isPlaying = false; - _shuffled = false; - duration = null; - _currentPlaylist = null; - _currentTrack = null; - notifyListeners(); - updatePersistence(clearNullEntries: true); - } - - void setVolume(double newVolume) { - volume = newVolume; - notifyListeners(); - updatePersistence(); - } - - /// sets the provided id matched track's uri\ - /// Doesn't notify listeners\ - /// @returns `bool` - `true` if succeed & `false` when failed - bool setTrackUriById(String id, String uri) { - if (_currentPlaylist == null) return false; - try { - int index = - _currentPlaylist!.tracks.indexWhere((element) => element.id == id); - if (index == -1) return false; - _currentPlaylist!.tracks[index].uri = uri; - updatePersistence(); - return _currentPlaylist!.tracks[index].uri == uri; - } catch (e) { - return false; - } - } - - void movePlaylistPositionBy(int pos) { - _logger.v("[Playlist Position Move] $pos"); - if (_currentTrack != null && _currentPlaylist != null) { - final int index = - _currentPlaylist!.trackIds.indexOf(_currentTrack!.id!) + pos; - - final safeIndex = index > _currentPlaylist!.trackIds.length - 1 - ? 0 - : index < 0 - ? _currentPlaylist!.trackIds.length - : index; - Track? track = _currentPlaylist!.tracks.asMap().containsKey(safeIndex) - ? _currentPlaylist!.tracks.elementAt(safeIndex) - : null; - if (track != null) { - duration = null; - _currentTrack = track; - notifyListeners(); - updatePersistence(); - // starts to play the newly entered next/prev track - startPlaying(); - } - } - } - - Future startPlaying([Track? track]) async { - _logger.v("[Track Playing] ${track?.name} - ${track?.id}"); - try { - // the track is already playing so no need to change that - if (track != null && track.id == _currentTrack?.id) return; - track ??= _currentTrack; - if (track != null) { - Uri? parsedUri = Uri.tryParse(track.uri ?? ""); - final tag = MediaItem( - id: track.id!, - title: track.name!, - album: track.album?.name, - artist: artistsToString(track.artists ?? []), - artUri: Uri.parse(imageToUrlString(track.album?.images)), - ); - player.addItem(tag); - if (parsedUri != null && parsedUri.hasAbsolutePath) { - _currentAudioSource = UrlSource(parsedUri.toString()); - await player.core - .play( - _currentAudioSource!, - ) - .then((value) async { - _currentTrack = track; - notifyListeners(); - updatePersistence(); - }); - return; - } - final preferences = ref.read(userPreferencesProvider); - final spotubeTrack = await toSpotubeTrack( - youtube: youtube, - track: track, - format: preferences.ytSearchFormat, - matchAlgorithm: preferences.trackMatchAlgorithm, - audioQuality: preferences.audioQuality, - box: cacheTrackBox, - ); - if (setTrackUriById(track.id!, spotubeTrack.ytUri)) { - logger.v("[Track Direct Source] - ${spotubeTrack.ytUri}"); - _currentAudioSource = UrlSource(spotubeTrack.ytUri); - await player.core - .play( - _currentAudioSource!, - ) - .then((value) { - _currentTrack = spotubeTrack; - notifyListeners(); - updatePersistence(); - }); - } - } - } catch (e, stack) { - _logger.e("startPlaying", e, stack); - } - } - - void shuffle() { - if (currentPlaylist?.shuffle() == true) { - _shuffled = true; - notifyListeners(); - } - } - - void unshuffle() { - if (currentPlaylist?.unshuffle() == true) { - _shuffled = false; - notifyListeners(); - } - } - - @override - FutureOr loadFromLocal(Map map) { - if (map["currentPlaylist"] != null) { - _currentPlaylist = - CurrentPlaylist.fromJson(jsonDecode(map["currentPlaylist"])); - } - if (map["currentTrack"] != null) { - _currentTrack = Track.fromJson(jsonDecode(map["currentTrack"])); - startPlaying().then((_) { - Timer.periodic(const Duration(milliseconds: 100), (timer) { - if (player.core.state == PlayerState.playing) { - player.pause(); - timer.cancel(); - } - }); - }); - } - volume = map["volume"] ?? volume; - } - - @override - FutureOr> toMap() { - return { - "currentPlaylist": currentPlaylist != null - ? jsonEncode(currentPlaylist?.toJson()) - : null, - "currentTrack": - currentTrack != null ? jsonEncode(currentTrack?.toJson()) : null, - "volume": volume, - }; - } -} - -final legacyPlaybackProvider = ChangeNotifierProvider((ref) { - final player = AudioPlayerHandler(); - final youtube = ref.watch(youtubeProvider); - final dbus = ref.watch(dbusClientProvider); - return LegacyPlayback( - player: player, - youtube: youtube, - ref: ref, - dbus: dbus, - ); -}); diff --git a/lib/provider/Playback.dart b/lib/provider/Playback.dart index 5459777f..6161f4f1 100644 --- a/lib/provider/Playback.dart +++ b/lib/provider/Playback.dart @@ -1,9 +1,9 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:audio_service/audio_service.dart'; import 'package:audioplayers/audioplayers.dart'; -import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive/hive.dart'; import 'package:spotify/spotify.dart'; @@ -14,20 +14,20 @@ 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/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' hide Playlist; import 'package:collection/collection.dart'; import 'package:spotube/extensions/list-sort-multiple.dart'; -class Playback with ChangeNotifier { +class Playback extends PersistedChangeNotifier { // player properties bool isShuffled; bool isPlaying; @@ -35,9 +35,8 @@ class Playback with ChangeNotifier { double volume; // class dependencies - Media_Player? linuxMPRIS; - Player_Interface? linuxMPRIS_Player; - AudioPlayerHandler? mobileAudioService; + LinuxAudioService? _linuxAudioService; + MobileAudioService? mobileAudioService; // foreign/passed properties AudioPlayer player; @@ -66,8 +65,7 @@ class Playback with ChangeNotifier { _subscriptions = [], super() { if (Platform.isLinux) { - linuxMPRIS = Media_Player(); - linuxMPRIS_Player = Player_Interface(playback: this); + _linuxAudioService = LinuxAudioService(this); } (() async { @@ -89,8 +87,10 @@ class Playback with ChangeNotifier { } }), player.onDurationChanged.listen((event) { - currentDuration = event; - notifyListeners(); + if (event != currentDuration) { + currentDuration = event; + notifyListeners(); + } }), player.onPositionChanged.listen((pos) async { if (pos > Duration.zero && currentDuration == Duration.zero) { @@ -104,8 +104,7 @@ class Playback with ChangeNotifier { @override void dispose() { - linuxMPRIS?.dispose(); - linuxMPRIS_Player?.dispose(); + _linuxAudioService?.dispose(); for (var subscription in _subscriptions) { subscription.cancel(); } @@ -151,6 +150,7 @@ class Playback with ChangeNotifier { await player.play(UrlSource(track.ytUri)).then((_) { this.track = track as SpotubeTrack; notifyListeners(); + updatePersistence(); }); } catch (e, stack) { _logger.e("play", e, stack); @@ -191,6 +191,7 @@ class Playback with ChangeNotifier { await player.setVolume(volume); volume = newVolume; notifyListeners(); + updatePersistence(); } Future stop() async { @@ -202,9 +203,13 @@ class Playback with ChangeNotifier { track = null; currentDuration = Duration.zero; notifyListeners(); + updatePersistence(clearNullEntries: true); } - destroy() {} + void destroy() { + stop(); + player.dispose(); + } // playlist & track list methods Future toSpotubeTrack(Track track) async { @@ -351,6 +356,26 @@ class Playback with ChangeNotifier { if (prevTrackIndex < 0) return; await play(playlist!.tracks.elementAt(prevTrackIndex)); } + + @override + FutureOr loadFromLocal(Map map) async { + if (map["playlist"] != null) { + playlist = CurrentPlaylist.fromJson(jsonDecode(map["playlist"])); + } + if (map["track"] != null) { + track = SpotubeTrack.fromJson(jsonDecode(map["track"])); + } + volume = map["volume"] ?? volume; + } + + @override + FutureOr> toMap() { + return { + "playlist": playlist != null ? jsonEncode(playlist?.toJson()) : null, + "track": track != null ? jsonEncode(track?.toJson()) : null, + "volume": volume, + }; + } } final playbackProvider = ChangeNotifierProvider((ref) { diff --git a/lib/interfaces/media_player2_player.dart b/lib/services/LinuxAudioService.dart similarity index 68% rename from lib/interfaces/media_player2_player.dart rename to lib/services/LinuxAudioService.dart index a315636d..ea620fef 100644 --- a/lib/interfaces/media_player2_player.dart +++ b/lib/services/LinuxAudioService.dart @@ -1,19 +1,226 @@ -// 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: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'; -import 'package:spotube/provider/DBus.dart'; -class Player_Interface extends DBusObject { +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({ + _MprisMediaPlayer2Player({ required this.playback, }) : super(DBusObjectPath("/org/mpris/MediaPlayer2")) { (() async { @@ -474,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 00000000..d6f97f10 --- /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 ed2a825b..00000000 --- a/lib/utils/AudioPlayerHandler.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'dart:async'; - -import 'package:audio_service/audio_service.dart'; -import 'package:spotube/provider/Playback.dart'; - -/// An [AudioHandler] for playing a single item. -class AudioPlayerHandler extends BaseAudioHandler { - final Playback playback; - - /// Initialise our audio handler. - AudioPlayerHandler(this.playback) { - final _player = playback.player; - // So that our clients (the Flutter UI and the system notification) know - // what state to display, here we set up our audio handler to broadcast all - // playback state changes as they happen via playbackState... - // _player. - _player.onPlayerStateChanged.listen((state) async { - playbackState.add(await _transformEvent()); - }); - _player.onDurationChanged.listen((duration) async { - playbackState.add(await _transformEvent()); - }); - _player.onPositionChanged.listen((state) async { - playbackState.add(await _transformEvent()); - }); - } - - 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() => 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(); - } - - /// 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. - Future _transformEvent() async { - return PlaybackState( - controls: [ - MediaControl.skipToPrevious, - if (playback.isPlaying) MediaControl.pause else MediaControl.play, - MediaControl.skipToNext, - MediaControl.stop, - ], - androidCompactActionIndices: const [0, 1, 2], - playing: playback.isPlaying, - updatePosition: - (await playback.player.getCurrentPosition()) ?? Duration.zero, - ); - } -}