From f896f65095d9d1afdc5bfb543b4f5fbfd8de7016 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 1 Jul 2022 11:10:50 +0600 Subject: [PATCH 01/10] initial ingration with audioplayers flutter plugin seek doesn't work on Endeavour OS (Arch) --- .github/workflows/feature-audioplayers.yaml | 28 ++++++ CONTRIBUTION.md | 19 ++-- lib/components/Player/Player.dart | 19 +--- lib/components/Player/PlayerControls.dart | 40 ++++++--- lib/helpers/search-youtube.dart | 6 +- lib/hooks/useSyncedLyrics.dart | 2 +- lib/interfaces/media_player2_player.dart | 18 ++-- lib/provider/AudioPlayer.dart | 2 +- lib/provider/Playback.dart | 89 ++++++++----------- lib/utils/AudioPlayerHandler.dart | 36 ++++---- linux/flutter/generated_plugin_registrant.cc | 8 +- linux/flutter/generated_plugins.cmake | 2 +- macos/Flutter/GeneratedPluginRegistrant.swift | 4 +- pubspec.lock | 84 +++++++++-------- pubspec.yaml | 3 +- .../flutter/generated_plugin_registrant.cc | 6 +- windows/flutter/generated_plugins.cmake | 2 +- 17 files changed, 202 insertions(+), 166 deletions(-) create mode 100644 .github/workflows/feature-audioplayers.yaml diff --git a/.github/workflows/feature-audioplayers.yaml b/.github/workflows/feature-audioplayers.yaml new file mode 100644 index 000000000..39e699249 --- /dev/null +++ b/.github/workflows/feature-audioplayers.yaml @@ -0,0 +1,28 @@ +name: audioplayers integration build +on: + push: + branches: + - build + workflow_dispatch: + +jobs: + build_ubuntu: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: subosito/flutter-action@v2.2.0 + with: + cache: true + - run: | + sudo apt-get update -y + sudo apt-get install -y tar clang cmake ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools patchelf desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev + - run: flutter config --enable-linux-desktop + - run: flutter pub get + - run: dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}' + - run: flutter clean + - run: flutter build linux + - run: make tar + - uses: actions/upload-artifact@v2 + with: + name: Spotube-Linux-Bundle + path: build/Spotube-linux-x86_64.tar.xz 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/Player/Player.dart b/lib/components/Player/Player.dart index 0d7fc3ba5..5757bd9b5 100644 --- a/lib/components/Player/Player.dart +++ b/lib/components/Player/Player.dart @@ -1,5 +1,6 @@ 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'; @@ -34,24 +35,6 @@ class Player extends HookConsumerWidget { 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, diff --git a/lib/components/Player/PlayerControls.dart b/lib/components/Player/PlayerControls.dart index d6f4d6ff3..bc4892f27 100644 --- a/lib/components/Player/PlayerControls.dart +++ b/lib/components/Player/PlayerControls.dart @@ -33,7 +33,7 @@ class PlayerControls extends HookConsumerWidget { child: Column( children: [ StreamBuilder( - stream: player.core.positionStream, + stream: player.core.onPositionChanged, builder: (context, snapshot) { final totalMinutes = zeroPadNumStr(duration.inMinutes.remainder(60)); @@ -48,23 +48,41 @@ class PlayerControls extends HookConsumerWidget { final sliderMax = duration.inSeconds; final sliderValue = snapshot.data?.inSeconds ?? 0; + // final value = (sliderMax == 0 || sliderValue > sliderMax) + // ? 0 + // : sliderValue / sliderMax; + + final _duration = playback.duration; + final _position = snapshot.data; + final value = (_position != null && + _duration != null && + _position.inMilliseconds > 0 && + _position.inMilliseconds < _duration.inMilliseconds) + ? _position.inMilliseconds / _duration.inMilliseconds + : 0.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) - ? 0 - : sliderValue / sliderMax, - onChanged: (value) {}, - onChangeEnd: (value) { - player.seek( - Duration( - seconds: (value * sliderMax).toInt(), - ), - ); + value: value, + onChanged: (v) async { + final duration = _duration; + if (duration == null) { + return; + } + final position = v * duration.inMilliseconds; + await player + .seek(Duration(milliseconds: position.round())); }, + // onChangeEnd: (value) async { + // await player.seek( + // Duration( + // seconds: (value * sliderMax).toInt(), + // ), + // ); + // }, activeColor: iconColor, ), Padding( diff --git a/lib/helpers/search-youtube.dart b/lib/helpers/search-youtube.dart index 18f680c8e..71a0daf8c 100644 --- a/lib/helpers/search-youtube.dart +++ b/lib/helpers/search-youtube.dart @@ -107,10 +107,8 @@ 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) => info.codec.mimeType == "audio/mp4"); final ytUri = (audioQuality == AudioQuality.high ? audioManifest.withHighestBitrate() diff --git a/lib/hooks/useSyncedLyrics.dart b/lib/hooks/useSyncedLyrics.dart index f48686ab8..c7f53d2b5 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.core.onPositionChanged; final currentTime = useState(0); diff --git a/lib/interfaces/media_player2_player.dart b/lib/interfaces/media_player2_player.dart index 8e65c62ee..f34a9bb76 100644 --- a/lib/interfaces/media_player2_player.dart +++ b/lib/interfaces/media_player2_player.dart @@ -1,8 +1,8 @@ // This file was generated using the following command and may be overwritten. // dart-dbus generate-object defs/org.mpris.MediaPlayer2.Player.xml +import 'package:audioplayers/audioplayers.dart'; import 'package:dbus/dbus.dart'; -import 'package:just_audio/just_audio.dart'; import 'package:spotube/helpers/image-to-url-string.dart'; import 'package:spotube/models/SpotubeTrack.dart'; import 'package:spotube/provider/Playback.dart'; @@ -19,7 +19,7 @@ class Player_Interface extends DBusObject { /// Gets value of property org.mpris.MediaPlayer2.Player.PlaybackStatus Future getPlaybackStatus() async { - final status = player.playing + final status = player.state == PlayerState.playing ? "Playing" : playback.currentPlaylist == null ? "Stopped" @@ -40,12 +40,12 @@ 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); + player.setPlaybackRate(value); return DBusMethodSuccessResponse(); } @@ -104,19 +104,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 player.getDuration())?.inMicroseconds ?? 0), ]); } @@ -188,7 +188,7 @@ class Player_Interface extends DBusObject { /// Implementation of org.mpris.MediaPlayer2.Player.PlayPause() Future doPlayPause() async { - player.playing ? player.pause() : player.play(); + player.state == PlayerState.playing ? player.pause() : player.resume(); return DBusMethodSuccessResponse(); } @@ -202,7 +202,7 @@ class Player_Interface extends DBusObject { /// Implementation of org.mpris.MediaPlayer2.Player.Play() Future doPlay() async { - player.play(); + player.resume(); return DBusMethodSuccessResponse(); } 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/Playback.dart b/lib/provider/Playback.dart index 30efe2270..6fdd9b597 100644 --- a/lib/provider/Playback.dart +++ b/lib/provider/Playback.dart @@ -3,11 +3,11 @@ 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:just_audio/just_audio.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/entities/CacheTrack.dart'; import 'package:spotube/helpers/artist-to-string.dart'; @@ -25,7 +25,7 @@ import 'package:spotube/utils/PersistedChangeNotifier.dart'; import 'package:youtube_explode_dart/youtube_explode_dart.dart'; class Playback extends PersistedChangeNotifier { - AudioSource? _currentAudioSource; + UrlSource? _currentAudioSource; final _logger = getLogger(Playback); CurrentPlaylist? _currentPlaylist; Track? _currentTrack; @@ -34,7 +34,6 @@ class Playback extends PersistedChangeNotifier { bool _isPlaying = false; Duration? duration; - Duration _prevPosition = Duration.zero; bool _shuffled = false; AudioPlayerHandler player; @@ -48,6 +47,8 @@ class Playback extends PersistedChangeNotifier { final Media_Player _media_player; late final Player_Interface _mpris; + double volume = 1; + Playback({ required this.player, required this.youtube, @@ -71,8 +72,8 @@ class Playback extends PersistedChangeNotifier { } StreamSubscription? _durationStream; + StreamSubscription? _playingStream; StreamSubscription? _positionStream; - StreamSubscription? _playingStream; void _init() async { // dbus m.p.r.i.s stuff @@ -90,60 +91,40 @@ class Playback extends PersistedChangeNotifier { cacheTrackBox = await Hive.openLazyBox("track-cache"); - _playingStream = player.core.playingStream.listen( - (playing) { - _isPlaying = playing; + _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.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(); - } + _durationStream = player.core.onDurationChanged.listen((event) { + duration = event; + notifyListeners(); }); - _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(); - } + _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() { - _positionStream?.cancel(); _playingStream?.cancel(); _durationStream?.cancel(); + _positionStream?.cancel(); cacheTrackBox?.close(); dbus.unregisterObject(_media_player); dbus.unregisterObject(_mpris); @@ -180,6 +161,12 @@ class Playback extends PersistedChangeNotifier { 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 @@ -238,11 +225,10 @@ class Playback extends PersistedChangeNotifier { ); player.addItem(tag); if (parsedUri != null && parsedUri.hasAbsolutePath) { - _currentAudioSource = AudioSource.uri(parsedUri); + _currentAudioSource = UrlSource(parsedUri.toString()); await player.core - .setAudioSource( + .play( _currentAudioSource!, - preload: true, ) .then((value) async { _currentTrack = track; @@ -262,11 +248,10 @@ class Playback extends PersistedChangeNotifier { ); if (setTrackUriById(track.id!, spotubeTrack.ytUri)) { logger.v("[Track Direct Source] - ${spotubeTrack.ytUri}"); - _currentAudioSource = AudioSource.uri(Uri.parse(spotubeTrack.ytUri)); + _currentAudioSource = UrlSource(spotubeTrack.ytUri); await player.core - .setAudioSource( + .play( _currentAudioSource!, - preload: true, ) .then((value) { _currentTrack = spotubeTrack; @@ -304,13 +289,14 @@ class Playback extends PersistedChangeNotifier { _currentTrack = Track.fromJson(jsonDecode(map["currentTrack"])); startPlaying().then((_) { Timer.periodic(const Duration(milliseconds: 100), (timer) { - if (player.core.playing) { + if (player.core.state == PlayerState.playing) { player.pause(); timer.cancel(); } }); }); } + volume = map["volume"] ?? volume; } @override @@ -321,6 +307,7 @@ class Playback extends PersistedChangeNotifier { : null, "currentTrack": currentTrack != null ? jsonEncode(currentTrack?.toJson()) : null, + "volume": volume, }; } } diff --git a/lib/utils/AudioPlayerHandler.dart b/lib/utils/AudioPlayerHandler.dart index eeb37b4a4..266737a6b 100644 --- a/lib/utils/AudioPlayerHandler.dart +++ b/lib/utils/AudioPlayerHandler.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:audio_service/audio_service.dart'; -import 'package:just_audio/just_audio.dart'; +import 'package:audioplayers/audioplayers.dart'; /// An [AudioHandler] for playing a single item. class AudioPlayerHandler extends BaseAudioHandler { @@ -15,7 +15,16 @@ class AudioPlayerHandler extends BaseAudioHandler { // 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); + // _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()); + }); } AudioPlayer get core => _player; @@ -30,7 +39,7 @@ class AudioPlayerHandler extends BaseAudioHandler { // your audio playback logic in one place. @override - Future play() => _player.play(); + Future play() => _player.resume(); @override Future pause() => _player.pause(); @@ -64,27 +73,20 @@ class AudioPlayerHandler extends BaseAudioHandler { /// 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) { + Future _transformEvent() async { return PlaybackState( controls: [ MediaControl.skipToPrevious, - if (_player.playing) MediaControl.pause else MediaControl.play, + if (_player.state == PlayerState.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, + playing: _player.state == PlayerState.playing, + updatePosition: (await _player.getCurrentPosition()) ?? Duration.zero, ); } } 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 ) From a7082d572a546f95d3e18373fa84250c24d033ff Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 1 Jul 2022 11:11:29 +0600 Subject: [PATCH 02/10] Updated GHA build script --- .github/workflows/feature-audioplayers.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/feature-audioplayers.yaml b/.github/workflows/feature-audioplayers.yaml index 39e699249..f0f06e40e 100644 --- a/.github/workflows/feature-audioplayers.yaml +++ b/.github/workflows/feature-audioplayers.yaml @@ -2,7 +2,7 @@ name: audioplayers integration build on: push: branches: - - build + - audioplayers_integration workflow_dispatch: jobs: From 6a05c57dcfdb5513ffd0a69829ce1bd60d1b90dc Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 1 Jul 2022 13:48:25 +0600 Subject: [PATCH 03/10] Linux Seek not working fixed --- lib/components/Player/PlayerControls.dart | 38 +++++++---------------- lib/helpers/search-youtube.dart | 12 +++++-- lib/provider/DBus.dart | 8 +++-- lib/provider/Playback.dart | 37 ++++++++++++---------- 4 files changed, 48 insertions(+), 47 deletions(-) diff --git a/lib/components/Player/PlayerControls.dart b/lib/components/Player/PlayerControls.dart index bc4892f27..3d30ce694 100644 --- a/lib/components/Player/PlayerControls.dart +++ b/lib/components/Player/PlayerControls.dart @@ -48,41 +48,25 @@ class PlayerControls extends HookConsumerWidget { final sliderMax = duration.inSeconds; final sliderValue = snapshot.data?.inSeconds ?? 0; - // final value = (sliderMax == 0 || sliderValue > sliderMax) - // ? 0 - // : sliderValue / sliderMax; + final value = (sliderMax == 0 || sliderValue > sliderMax) + ? 0 + : sliderValue / sliderMax; - final _duration = playback.duration; - final _position = snapshot.data; - final value = (_position != null && - _duration != null && - _position.inMilliseconds > 0 && - _position.inMilliseconds < _duration.inMilliseconds) - ? _position.inMilliseconds / _duration.inMilliseconds - : 0.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: value, - onChanged: (v) async { - final duration = _duration; - if (duration == null) { - return; - } - final position = v * duration.inMilliseconds; - await player - .seek(Duration(milliseconds: position.round())); + value: value.toDouble(), + onChanged: (_) {}, + onChangeEnd: (value) async { + await player.seek( + Duration( + seconds: (value * sliderMax).toInt(), + ), + ); }, - // onChangeEnd: (value) async { - // await player.seek( - // Duration( - // seconds: (value * sliderMax).toInt(), - // ), - // ); - // }, activeColor: iconColor, ), Padding( diff --git a/lib/helpers/search-youtube.dart b/lib/helpers/search-youtube.dart index 71a0daf8c..a6ae01440 100644 --- a/lib/helpers/search-youtube.dart +++ b/lib/helpers/search-youtube.dart @@ -107,8 +107,16 @@ Future toSpotubeTrack({ "[YouTube Matched Track] ${ytVideo.title} | ${ytVideo.author} - ${ytVideo.url}", ); - final audioManifest = trackManifest.audioOnly - .where((info) => info.codec.mimeType == "audio/mp4"); + 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/provider/DBus.dart b/lib/provider/DBus.dart index a0b0e942a..dd20edd2c 100644 --- a/lib/provider/DBus.dart +++ b/lib/provider/DBus.dart @@ -1,6 +1,10 @@ +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(); + } }); diff --git a/lib/provider/Playback.dart b/lib/provider/Playback.dart index 6fdd9b597..31930fa15 100644 --- a/lib/provider/Playback.dart +++ b/lib/provider/Playback.dart @@ -43,9 +43,9 @@ class Playback extends PersistedChangeNotifier { LazyBox? cacheTrackBox; @protected - final DBusClient dbus; - final Media_Player _media_player; - late final Player_Interface _mpris; + final DBusClient? dbus; + Media_Player? _media_player; + Player_Interface? _mpris; double volume = 1; @@ -58,9 +58,7 @@ class Playback extends PersistedChangeNotifier { Track? currentTrack, }) : _currentPlaylist = currentPlaylist, _currentTrack = currentTrack, - _media_player = Media_Player(), super() { - _mpris = Player_Interface(player: player.core, playback: this); player.onNextRequest = () { movePlaylistPositionBy(1); }; @@ -77,16 +75,21 @@ class Playback extends PersistedChangeNotifier { 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"); + if (Platform.isLinux) { + try { + _media_player = Media_Player(); + _mpris = Player_Interface(player: player.core, playback: this); + final nameStatus = + await dbus?.requestName("org.mpris.MediaPlayer2.spotube"); + if (nameStatus == DBusRequestNameReply.exists) { + await dbus + ?.requestName("org.mpris.MediaPlayer2.spotube.instance$pid"); + } + await dbus?.registerObject(_media_player!); + await dbus?.registerObject(_mpris!); + } catch (e) { + logger.e("[MPRIS initialization error]", e); } - await dbus.registerObject(_media_player); - await dbus.registerObject(_mpris); - } catch (e) { - logger.e("[MPRIS initialization error]", e); } cacheTrackBox = await Hive.openLazyBox("track-cache"); @@ -126,8 +129,10 @@ class Playback extends PersistedChangeNotifier { _durationStream?.cancel(); _positionStream?.cancel(); cacheTrackBox?.close(); - dbus.unregisterObject(_media_player); - dbus.unregisterObject(_mpris); + if (Platform.isLinux && _media_player != null && _mpris != null) { + dbus?.unregisterObject(_media_player!); + dbus?.unregisterObject(_mpris!); + } super.dispose(); } From f07a142274280030635bbec1a92896af48101881 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 3 Jul 2022 08:24:46 +0600 Subject: [PATCH 04/10] Refactored Playback works nicely in Desktop --- lib/components/Album/AlbumCard.dart | 14 +- lib/components/Album/AlbumView.dart | 28 +- lib/components/Artist/ArtistProfile.dart | 21 +- lib/components/Lyrics/Lyrics.dart | 8 +- lib/components/Lyrics/SyncedLyrics.dart | 14 +- lib/components/Player/Player.dart | 24 +- lib/components/Player/PlayerActions.dart | 12 +- lib/components/Player/PlayerControls.dart | 24 +- lib/components/Player/PlayerTrackDetails.dart | 6 +- lib/components/Player/PlayerView.dart | 2 +- lib/components/Playlist/PlaylistCard.dart | 18 +- lib/components/Playlist/PlaylistView.dart | 27 +- lib/components/Search/Search.dart | 28 +- .../Shared/DownloadTrackButton.dart | 11 +- lib/components/Shared/TrackTile.dart | 5 +- lib/hooks/playback.dart | 10 +- lib/hooks/useSyncedLyrics.dart | 2 +- lib/interfaces/media_player2.dart | 9 +- lib/interfaces/media_player2_player.dart | 82 +-- lib/main.dart | 38 +- lib/provider/DBus.dart | 2 + lib/provider/LegacyPlayback.dart | 325 +++++++++++ lib/provider/Playback.dart | 526 ++++++++++-------- lib/provider/SpotifyRequests.dart | 6 +- lib/utils/AudioPlayerHandler.dart | 36 +- 25 files changed, 813 insertions(+), 465 deletions(-) create mode 100644 lib/provider/LegacyPlayback.dart 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 5757bd9b5..329b3b340 100644 --- a/lib/components/Player/Player.dart +++ b/lib/components/Player/Player.dart @@ -24,12 +24,8 @@ class Player extends HookConsumerWidget { Widget build(BuildContext context, ref) { Playback playback = ref.watch(playbackProvider); - final _volume = useState(0.0); - final breakpoint = useBreakpoints(); - final AudioPlayerHandler player = playback.player; - final Future future = useMemoized(SharedPreferences.getInstance); final AsyncSnapshot localStorage = @@ -37,10 +33,10 @@ class Player extends HookConsumerWidget { String albumArt = useMemoized( () => imageToUrlString( - playback.currentTrack?.album?.images, - index: (playback.currentTrack?.album?.images?.length ?? 1) - 1, + playback.track?.album?.images, + index: (playback.track?.album?.images?.length ?? 1) - 1, ), - [playback.currentTrack?.album?.images], + [playback.track?.album?.images], ); final entryRef = useRef(null); @@ -65,7 +61,7 @@ class Player extends HookConsumerWidget { // entry will result in splashing while resizing the window if (breakpoint.isLessThanOrEqualTo(Breakpoints.md) && entryRef.value == null && - playback.currentTrack != null) { + playback.track != null) { entryRef.value = OverlayEntry( opaque: false, builder: (context) => PlayerOverlay(albumArt: albumArt), @@ -87,7 +83,7 @@ class Player extends HookConsumerWidget { return () { disposeOverlay(); }; - }, [breakpoint, playback.currentTrack]); + }, [breakpoint, playback.track]); // returning an empty non spacious Container as the overlay will take // place in the global overlay stack aka [_entries] @@ -119,16 +115,10 @@ class Player extends HookConsumerWidget { height: 20, constraints: const BoxConstraints(maxWidth: 200), child: Slider.adaptive( - value: _volume.value, + value: playback.volume, onChanged: (value) async { try { - await player.core.setVolume(value).then((_) { - _volume.value = value; - localStorage.data?.setDouble( - LocalStorageKeys.volume, - value, - ); - }); + await playback.setVolume(value); } catch (e, stack) { logger.e("onChange", e, stack); } diff --git a/lib/components/Player/PlayerActions.dart b/lib/components/Player/PlayerActions.dart index 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 3d30ce694..6a70a7fba 100644 --- a/lib/components/Player/PlayerControls.dart +++ b/lib/components/Player/PlayerControls.dart @@ -18,7 +18,6 @@ class PlayerControls extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final Playback playback = ref.watch(playbackProvider); - final AudioPlayerHandler player = playback.player; final onNext = useNextTrack(playback); @@ -26,14 +25,14 @@ class PlayerControls extends HookConsumerWidget { final _playOrPause = useTogglePlayPause(playback); - final duration = playback.duration ?? Duration.zero; + final duration = playback.currentDuration; return Container( constraints: const BoxConstraints(maxWidth: 600), child: Column( children: [ StreamBuilder( - stream: player.core.onPositionChanged, + stream: playback.player.onPositionChanged, builder: (context, snapshot) { final totalMinutes = zeroPadNumStr(duration.inMinutes.remainder(60)); @@ -61,7 +60,7 @@ class PlayerControls extends HookConsumerWidget { value: value.toDouble(), onChanged: (_) {}, onChangeEnd: (value) async { - await player.seek( + await playback.seekPosition( Duration( seconds: (value * sliderMax).toInt(), ), @@ -89,20 +88,15 @@ class PlayerControls extends HookConsumerWidget { children: [ IconButton( icon: const Icon(Icons.shuffle_rounded), - color: playback.shuffled + color: playback.isShuffled ? Theme.of(context).primaryColor : iconColor, onPressed: () { - if (playback.currentTrack == null || - playback.currentPlaylist == null) { + if (playback.track == null || playback.playlist == null) { return; } try { - if (!playback.shuffled) { - playback.shuffle(); - } else { - playback.unshuffle(); - } + playback.toggleShuffle(); } catch (e, stack) { logger.e("onShuffle", e, stack); } @@ -130,12 +124,10 @@ class PlayerControls extends HookConsumerWidget { IconButton( icon: const Icon(Icons.stop_rounded), color: iconColor, - onPressed: playback.currentTrack != null + onPressed: playback.track != null ? () async { try { - await player.pause(); - await player.seek(Duration.zero); - playback.reset(); + await playback.stop(); } catch (e, stack) { logger.e("onStop", e, stack); } diff --git a/lib/components/Player/PlayerTrackDetails.dart b/lib/components/Player/PlayerTrackDetails.dart index 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..3ea3f7217 100644 --- a/lib/components/Player/PlayerView.dart +++ b/lib/components/Player/PlayerView.dart @@ -23,7 +23,7 @@ class PlayerView extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final currentTrack = ref.watch(playbackProvider.select( - (value) => value.currentTrack, + (value) => value.track, )); final breakpoint = useBreakpoints(); diff --git a/lib/components/Playlist/PlaylistCard.dart b/lib/components/Playlist/PlaylistCard.dart index 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/hooks/playback.dart b/lib/hooks/playback.dart index 2dc4f0350..07f1f3ae8 100644 --- a/lib/hooks/playback.dart +++ b/lib/hooks/playback.dart @@ -8,7 +8,7 @@ Future Function() useNextTrack(Playback playback) { try { await playback.player.pause(); await playback.player.seek(Duration.zero); - playback.movePlaylistPositionBy(1); + playback.seekForward(); } catch (e, stack) { logger.e("useNextTrack", e, stack); } @@ -20,7 +20,7 @@ Future Function() usePreviousTrack(Playback playback) { try { await playback.player.pause(); await playback.player.seek(Duration.zero); - playback.movePlaylistPositionBy(-1); + playback.seekBackward(); } catch (e, stack) { logger.e("onPrevious", e, stack); } @@ -30,10 +30,8 @@ Future Function() usePreviousTrack(Playback playback) { Future Function([dynamic]) useTogglePlayPause(Playback playback) { return ([key]) async { try { - if (playback.currentTrack == null) return; - playback.isPlaying - ? await playback.player.pause() - : await playback.player.play(); + if (playback.track == null) return; + await playback.togglePlayPause(); } catch (e, stack) { logger.e("useTogglePlayPause", e, stack); } diff --git a/lib/hooks/useSyncedLyrics.dart b/lib/hooks/useSyncedLyrics.dart index c7f53d2b5..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.onPositionChanged; + final stream = player.onPositionChanged; final currentTime = useState(0); diff --git a/lib/interfaces/media_player2.dart b/lib/interfaces/media_player2.dart index cbacc307a..44286db26 100644 --- a/lib/interfaces/media_player2.dart +++ b/lib/interfaces/media_player2.dart @@ -3,10 +3,17 @@ import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:dbus/dbus.dart'; +import 'package:spotube/provider/DBus.dart'; class Media_Player extends DBusObject { /// Creates a new object to expose on [path]. - Media_Player() : super(DBusObjectPath('/org/mpris/MediaPlayer2')); + Media_Player() : super(DBusObjectPath('/org/mpris/MediaPlayer2')) { + dbus.registerObject(this); + } + + void dispose() { + dbus.unregisterObject(this); + } /// Gets value of property org.mpris.MediaPlayer2.CanQuit Future getCanQuit() async { diff --git a/lib/interfaces/media_player2_player.dart b/lib/interfaces/media_player2_player.dart index f34a9bb76..a315636db 100644 --- a/lib/interfaces/media_player2_player.dart +++ b/lib/interfaces/media_player2_player.dart @@ -1,27 +1,40 @@ // This file was generated using the following command and may be overwritten. // dart-dbus generate-object defs/org.mpris.MediaPlayer2.Player.xml -import 'package:audioplayers/audioplayers.dart'; +import 'dart:io'; + import 'package:dbus/dbus.dart'; import 'package:spotube/helpers/image-to-url-string.dart'; import 'package:spotube/models/SpotubeTrack.dart'; import 'package:spotube/provider/Playback.dart'; +import 'package:spotube/provider/DBus.dart'; class Player_Interface extends DBusObject { - final AudioPlayer player; final Playback playback; /// Creates a new object to expose on [path]. Player_Interface({ - required this.player, required this.playback, - }) : super(DBusObjectPath("/org/mpris/MediaPlayer2")); + }) : super(DBusObjectPath("/org/mpris/MediaPlayer2")) { + (() async { + final nameStatus = + await dbus.requestName("org.mpris.MediaPlayer2.spotube"); + if (nameStatus == DBusRequestNameReply.exists) { + await dbus.requestName("org.mpris.MediaPlayer2.spotube.instance$pid"); + } + await dbus.registerObject(this); + }()); + } + + void dispose() { + dbus.unregisterObject(this); + } /// Gets value of property org.mpris.MediaPlayer2.Player.PlaybackStatus Future getPlaybackStatus() async { - final status = player.state == PlayerState.playing + final status = playback.isPlaying ? "Playing" - : playback.currentPlaylist == null + : playback.playlist == null ? "Stopped" : "Paused"; return DBusMethodSuccessResponse([DBusString(status)]); @@ -45,34 +58,29 @@ class Player_Interface extends DBusObject { /// Sets property org.mpris.MediaPlayer2.Player.Rate Future setRate(double value) async { - player.setPlaybackRate(value); return DBusMethodSuccessResponse(); } /// Gets value of property org.mpris.MediaPlayer2.Player.Shuffle Future getShuffle() async { - return DBusMethodSuccessResponse([DBusBoolean(playback.shuffled)]); + return DBusMethodSuccessResponse([DBusBoolean(playback.isShuffled)]); } /// Sets property org.mpris.MediaPlayer2.Player.Shuffle Future setShuffle(bool value) async { - if (value) { - playback.shuffle(); - } else { - playback.unshuffle(); - } + playback.toggleShuffle(); return DBusMethodSuccessResponse(); } /// Gets value of property org.mpris.MediaPlayer2.Player.Metadata Future getMetadata() async { try { - if (playback.currentTrack == null) { + if (playback.track == null) { return DBusMethodSuccessResponse([DBusDict.stringVariant({})]); } - final id = (playback.currentPlaylist != null - ? playback.currentPlaylist!.tracks.indexWhere( - (track) => playback.currentTrack!.id == track.id!, + final id = (playback.playlist != null + ? playback.playlist!.tracks.indexWhere( + (track) => playback.track!.id == track.id!, ) : 0) .abs(); @@ -80,18 +88,18 @@ class Player_Interface extends DBusObject { return DBusMethodSuccessResponse([ DBusDict.stringVariant({ "mpris:trackid": DBusString("${path.value}/Track/$id"), - "mpris:length": DBusInt32(playback.duration?.inMicroseconds ?? 0), - "mpris:artUrl": DBusString( - imageToUrlString(playback.currentTrack?.album?.images)), - "xesam:album": DBusString(playback.currentTrack!.album!.name!), + "mpris:length": DBusInt32(playback.currentDuration.inMicroseconds), + "mpris:artUrl": + DBusString(imageToUrlString(playback.track?.album?.images)), + "xesam:album": DBusString(playback.track!.album!.name!), "xesam:artist": DBusArray.string( - playback.currentTrack!.artists!.map((artist) => artist.name!), + playback.track!.artists!.map((artist) => artist.name!), ), - "xesam:title": DBusString(playback.currentTrack!.name!), + "xesam:title": DBusString(playback.track!.name!), "xesam:url": DBusString( - playback.currentTrack is SpotubeTrack - ? (playback.currentTrack as SpotubeTrack).ytUri - : playback.currentTrack!.previewUrl!, + playback.track is SpotubeTrack + ? (playback.track as SpotubeTrack).ytUri + : playback.track!.previewUrl!, ), "xesam:genre": const DBusString("Unknown"), }), @@ -116,7 +124,7 @@ class Player_Interface extends DBusObject { /// Gets value of property org.mpris.MediaPlayer2.Player.Position Future getPosition() async { return DBusMethodSuccessResponse([ - DBusInt64((await player.getDuration())?.inMicroseconds ?? 0), + DBusInt64((await playback.player.getDuration())?.inMicroseconds ?? 0), ]); } @@ -134,7 +142,7 @@ class Player_Interface extends DBusObject { Future getCanGoNext() async { return DBusMethodSuccessResponse([ DBusBoolean( - playback.currentPlaylist?.tracks.isNotEmpty == true, + playback.playlist?.tracks.isNotEmpty == true, ) ]); } @@ -143,7 +151,7 @@ class Player_Interface extends DBusObject { Future getCanGoPrevious() async { return DBusMethodSuccessResponse([ DBusBoolean( - playback.currentPlaylist?.tracks.isNotEmpty == true, + playback.playlist?.tracks.isNotEmpty == true, ) ]); } @@ -170,45 +178,43 @@ class Player_Interface extends DBusObject { /// Implementation of org.mpris.MediaPlayer2.Player.Next() Future doNext() async { - playback.movePlaylistPositionBy(1); + playback.seekForward(); return DBusMethodSuccessResponse(); } /// Implementation of org.mpris.MediaPlayer2.Player.Previous() Future doPrevious() async { - playback.movePlaylistPositionBy(-1); + playback.seekBackward(); return DBusMethodSuccessResponse(); } /// Implementation of org.mpris.MediaPlayer2.Player.Pause() Future doPause() async { - player.pause(); + playback.pause(); return DBusMethodSuccessResponse(); } /// Implementation of org.mpris.MediaPlayer2.Player.PlayPause() Future doPlayPause() async { - player.state == PlayerState.playing ? player.pause() : player.resume(); + playback.isPlaying ? playback.pause() : playback.resume(); return DBusMethodSuccessResponse(); } /// Implementation of org.mpris.MediaPlayer2.Player.Stop() Future doStop() async { - await player.pause(); - await player.seek(Duration.zero); - playback.reset(); + playback.stop(); return DBusMethodSuccessResponse(); } /// Implementation of org.mpris.MediaPlayer2.Player.Play() Future doPlay() async { - player.resume(); + playback.resume(); return DBusMethodSuccessResponse(); } /// Implementation of org.mpris.MediaPlayer2.Player.Seek() Future doSeek(int offset) async { - player.seek(Duration(microseconds: offset)); + playback.seekPosition(Duration(microseconds: offset)); return DBusMethodSuccessResponse(); } diff --git a/lib/main.dart b/lib/main.dart index 9f3d97ff7..4a888ec83 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,17 +1,14 @@ import 'package:audio_service/audio_service.dart'; import 'package:bitsdojo_window/bitsdojo_window.dart'; -import 'package:dbus/dbus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/entities/CacheTrack.dart'; -import 'package:spotube/interfaces/media_player2.dart'; import 'package:spotube/models/GoRouteDeclarations.dart'; import 'package:spotube/models/Logger.dart'; import 'package:spotube/provider/AudioPlayer.dart'; -import 'package:spotube/provider/DBus.dart'; import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/UserPreferences.dart'; import 'package:spotube/provider/YouTube.dart'; @@ -24,14 +21,6 @@ void main() async { await Hive.initFlutter(); Hive.registerAdapter(CacheTrackAdapter()); Hive.registerAdapter(CacheTrackEngagementAdapter()); - AudioPlayerHandler audioPlayerHandler = await AudioService.init( - builder: () => AudioPlayerHandler(), - config: const AudioServiceConfig( - androidNotificationChannelId: 'com.krtirtho.Spotube', - androidNotificationChannelName: 'Spotube', - androidNotificationOngoing: true, - ), - ); if (kIsDesktop) { WidgetsFlutterBinding.ensureInitialized(); // final client = DBusClient.session(); @@ -44,19 +33,38 @@ void main() async { appWindow.show(); }); } + AudioPlayerHandler? audioServiceHandler; runApp(ProviderScope( child: Spotube(), overrides: [ playbackProvider.overrideWithProvider(ChangeNotifierProvider( (ref) { final youtube = ref.watch(youtubeProvider); - final dbus = ref.watch(dbusClientProvider); - return Playback( - player: audioPlayerHandler, + final player = ref.watch(audioPlayerProvider); + + final playback = Playback( + player: player, youtube: youtube, ref: ref, - dbus: dbus, ); + + if (audioServiceHandler == null) { + AudioService.init( + builder: () => AudioPlayerHandler(playback), + config: const AudioServiceConfig( + androidNotificationChannelId: 'com.krtirtho.Spotube', + androidNotificationChannelName: 'Spotube', + androidNotificationOngoing: true, + ), + ).then( + (value) { + playback.mobileAudioService = value; + audioServiceHandler = value; + }, + ); + } + + return playback; }, )) ], diff --git a/lib/provider/DBus.dart b/lib/provider/DBus.dart index dd20edd2c..627046595 100644 --- a/lib/provider/DBus.dart +++ b/lib/provider/DBus.dart @@ -8,3 +8,5 @@ final Provider dbusClientProvider = Provider((ref) { return DBusClient.session(); } }); + +final dbus = DBusClient.session(); diff --git a/lib/provider/LegacyPlayback.dart b/lib/provider/LegacyPlayback.dart new file mode 100644 index 000000000..1cf4efafa --- /dev/null +++ b/lib/provider/LegacyPlayback.dart @@ -0,0 +1,325 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:audio_service/audio_service.dart'; +import 'package:audioplayers/audioplayers.dart'; +import 'package:dbus/dbus.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive/hive.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/entities/CacheTrack.dart'; +import 'package:spotube/helpers/artist-to-string.dart'; +import 'package:spotube/helpers/image-to-url-string.dart'; +import 'package:spotube/helpers/search-youtube.dart'; +import 'package:spotube/interfaces/media_player2.dart'; +import 'package:spotube/interfaces/media_player2_player.dart'; +import 'package:spotube/models/CurrentPlaylist.dart'; +import 'package:spotube/models/Logger.dart'; +import 'package:spotube/provider/DBus.dart'; +import 'package:spotube/provider/UserPreferences.dart'; +import 'package:spotube/provider/YouTube.dart'; +import 'package:spotube/utils/AudioPlayerHandler.dart'; +import 'package:spotube/utils/PersistedChangeNotifier.dart'; +import 'package:youtube_explode_dart/youtube_explode_dart.dart'; + +class LegacyPlayback extends PersistedChangeNotifier { + UrlSource? _currentAudioSource; + final _logger = getLogger(LegacyPlayback); + CurrentPlaylist? _currentPlaylist; + Track? _currentTrack; + + // states + bool _isPlaying = false; + Duration? duration; + + bool _shuffled = false; + + AudioPlayerHandler player; + YoutubeExplode youtube; + Ref ref; + + LazyBox? cacheTrackBox; + + @protected + final DBusClient? dbus; + Media_Player? _media_player; + Player_Interface? _mpris; + + double volume = 1; + + LegacyPlayback({ + required this.player, + required this.youtube, + required this.ref, + required this.dbus, + CurrentPlaylist? currentPlaylist, + Track? currentTrack, + }) : _currentPlaylist = currentPlaylist, + _currentTrack = currentTrack, + super() { + player.onNextRequest = () { + movePlaylistPositionBy(1); + }; + player.onPreviousRequest = () { + movePlaylistPositionBy(-1); + }; + + _init(); + } + + StreamSubscription? _durationStream; + StreamSubscription? _playingStream; + StreamSubscription? _positionStream; + + void _init() async { + // dbus m.p.r.i.s stuff + if (Platform.isLinux) { + try { + _media_player = Media_Player(); + _mpris = Player_Interface(player: player.core, playback: this); + await dbus?.registerObject(_media_player!); + await dbus?.registerObject(_mpris!); + } catch (e) { + logger.e("[MPRIS initialization error]", e); + } + } + + cacheTrackBox = await Hive.openLazyBox("track-cache"); + + _playingStream = player.core.onPlayerStateChanged.listen( + (state) async { + _isPlaying = state == PlayerState.playing; + if (state == PlayerState.completed) { + if (_currentTrack?.id != null) { + movePlaylistPositionBy(1); + } else { + _isPlaying = false; + duration = null; + } + } + notifyListeners(); + }, + ); + + _durationStream = player.core.onDurationChanged.listen((event) { + duration = event; + notifyListeners(); + }); + + _positionStream = player.core.onPositionChanged.listen((pos) async { + if (pos > Duration.zero && + (duration == null || duration == Duration.zero)) { + duration = await player.core.getDuration(); + notifyListeners(); + } + }); + } + + @override + void dispose() { + _playingStream?.cancel(); + _durationStream?.cancel(); + _positionStream?.cancel(); + cacheTrackBox?.close(); + if (Platform.isLinux && _media_player != null && _mpris != null) { + dbus?.unregisterObject(_media_player!); + dbus?.unregisterObject(_mpris!); + } + super.dispose(); + } + + bool get shuffled => _shuffled; + CurrentPlaylist? get currentPlaylist => _currentPlaylist; + Track? get currentTrack => _currentTrack; + bool get isPlaying => _isPlaying; + + set setCurrentTrack(Track track) { + _logger.v("[Setting Current Track] ${track.name} - ${track.id}"); + _currentTrack = track; + notifyListeners(); + updatePersistence(); + } + + set setCurrentPlaylist(CurrentPlaylist playlist) { + _logger.v("[Current Playlist Changed] ${playlist.name} - ${playlist.id}"); + _currentPlaylist = playlist; + notifyListeners(); + updatePersistence(); + } + + void reset() { + _logger.v("Playback Reset"); + _isPlaying = false; + _shuffled = false; + duration = null; + _currentPlaylist = null; + _currentTrack = null; + notifyListeners(); + updatePersistence(clearNullEntries: true); + } + + void setVolume(double newVolume) { + volume = newVolume; + notifyListeners(); + updatePersistence(); + } + + /// sets the provided id matched track's uri\ + /// Doesn't notify listeners\ + /// @returns `bool` - `true` if succeed & `false` when failed + bool setTrackUriById(String id, String uri) { + if (_currentPlaylist == null) return false; + try { + int index = + _currentPlaylist!.tracks.indexWhere((element) => element.id == id); + if (index == -1) return false; + _currentPlaylist!.tracks[index].uri = uri; + updatePersistence(); + return _currentPlaylist!.tracks[index].uri == uri; + } catch (e) { + return false; + } + } + + void movePlaylistPositionBy(int pos) { + _logger.v("[Playlist Position Move] $pos"); + if (_currentTrack != null && _currentPlaylist != null) { + final int index = + _currentPlaylist!.trackIds.indexOf(_currentTrack!.id!) + pos; + + final safeIndex = index > _currentPlaylist!.trackIds.length - 1 + ? 0 + : index < 0 + ? _currentPlaylist!.trackIds.length + : index; + Track? track = _currentPlaylist!.tracks.asMap().containsKey(safeIndex) + ? _currentPlaylist!.tracks.elementAt(safeIndex) + : null; + if (track != null) { + duration = null; + _currentTrack = track; + notifyListeners(); + updatePersistence(); + // starts to play the newly entered next/prev track + startPlaying(); + } + } + } + + Future startPlaying([Track? track]) async { + _logger.v("[Track Playing] ${track?.name} - ${track?.id}"); + try { + // the track is already playing so no need to change that + if (track != null && track.id == _currentTrack?.id) return; + track ??= _currentTrack; + if (track != null) { + Uri? parsedUri = Uri.tryParse(track.uri ?? ""); + final tag = MediaItem( + id: track.id!, + title: track.name!, + album: track.album?.name, + artist: artistsToString(track.artists ?? []), + artUri: Uri.parse(imageToUrlString(track.album?.images)), + ); + player.addItem(tag); + if (parsedUri != null && parsedUri.hasAbsolutePath) { + _currentAudioSource = UrlSource(parsedUri.toString()); + await player.core + .play( + _currentAudioSource!, + ) + .then((value) async { + _currentTrack = track; + notifyListeners(); + updatePersistence(); + }); + return; + } + final preferences = ref.read(userPreferencesProvider); + final spotubeTrack = await toSpotubeTrack( + youtube: youtube, + track: track, + format: preferences.ytSearchFormat, + matchAlgorithm: preferences.trackMatchAlgorithm, + audioQuality: preferences.audioQuality, + box: cacheTrackBox, + ); + if (setTrackUriById(track.id!, spotubeTrack.ytUri)) { + logger.v("[Track Direct Source] - ${spotubeTrack.ytUri}"); + _currentAudioSource = UrlSource(spotubeTrack.ytUri); + await player.core + .play( + _currentAudioSource!, + ) + .then((value) { + _currentTrack = spotubeTrack; + notifyListeners(); + updatePersistence(); + }); + } + } + } catch (e, stack) { + _logger.e("startPlaying", e, stack); + } + } + + void shuffle() { + if (currentPlaylist?.shuffle() == true) { + _shuffled = true; + notifyListeners(); + } + } + + void unshuffle() { + if (currentPlaylist?.unshuffle() == true) { + _shuffled = false; + notifyListeners(); + } + } + + @override + FutureOr loadFromLocal(Map map) { + if (map["currentPlaylist"] != null) { + _currentPlaylist = + CurrentPlaylist.fromJson(jsonDecode(map["currentPlaylist"])); + } + if (map["currentTrack"] != null) { + _currentTrack = Track.fromJson(jsonDecode(map["currentTrack"])); + startPlaying().then((_) { + Timer.periodic(const Duration(milliseconds: 100), (timer) { + if (player.core.state == PlayerState.playing) { + player.pause(); + timer.cancel(); + } + }); + }); + } + volume = map["volume"] ?? volume; + } + + @override + FutureOr> toMap() { + return { + "currentPlaylist": currentPlaylist != null + ? jsonEncode(currentPlaylist?.toJson()) + : null, + "currentTrack": + currentTrack != null ? jsonEncode(currentTrack?.toJson()) : null, + "volume": volume, + }; + } +} + +final legacyPlaybackProvider = ChangeNotifierProvider((ref) { + final player = AudioPlayerHandler(); + final youtube = ref.watch(youtubeProvider); + final dbus = ref.watch(dbusClientProvider); + return LegacyPlayback( + player: player, + youtube: youtube, + ref: ref, + dbus: dbus, + ); +}); diff --git a/lib/provider/Playback.dart b/lib/provider/Playback.dart index 31930fa15..5459777fe 100644 --- a/lib/provider/Playback.dart +++ b/lib/provider/Playback.dart @@ -1,330 +1,364 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:io'; import 'package:audio_service/audio_service.dart'; import 'package:audioplayers/audioplayers.dart'; -import 'package:dbus/dbus.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive/hive.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/entities/CacheTrack.dart'; +import 'package:spotube/extensions/yt-video-from-cache-track.dart'; import 'package:spotube/helpers/artist-to-string.dart'; +import 'package:spotube/helpers/contains-text-in-bracket.dart'; +import 'package:spotube/helpers/getLyrics.dart'; import 'package:spotube/helpers/image-to-url-string.dart'; import 'package:spotube/helpers/search-youtube.dart'; import 'package:spotube/interfaces/media_player2.dart'; import 'package:spotube/interfaces/media_player2_player.dart'; import 'package:spotube/models/CurrentPlaylist.dart'; import 'package:spotube/models/Logger.dart'; -import 'package:spotube/provider/DBus.dart'; +import 'package:spotube/models/SpotubeTrack.dart'; +import 'package:spotube/provider/AudioPlayer.dart'; import 'package:spotube/provider/UserPreferences.dart'; import 'package:spotube/provider/YouTube.dart'; import 'package:spotube/utils/AudioPlayerHandler.dart'; -import 'package:spotube/utils/PersistedChangeNotifier.dart'; -import 'package:youtube_explode_dart/youtube_explode_dart.dart'; +import 'package:youtube_explode_dart/youtube_explode_dart.dart' hide Playlist; +import 'package:collection/collection.dart'; +import 'package:spotube/extensions/list-sort-multiple.dart'; -class Playback extends PersistedChangeNotifier { - UrlSource? _currentAudioSource; - final _logger = getLogger(Playback); - CurrentPlaylist? _currentPlaylist; - Track? _currentTrack; - - // states - bool _isPlaying = false; - Duration? duration; +class Playback with ChangeNotifier { + // player properties + bool isShuffled; + bool isPlaying; + Duration currentDuration; + double volume; - bool _shuffled = false; + // class dependencies + Media_Player? linuxMPRIS; + Player_Interface? linuxMPRIS_Player; + AudioPlayerHandler? mobileAudioService; - AudioPlayerHandler player; + // foreign/passed properties + AudioPlayer player; YoutubeExplode youtube; Ref ref; + UserPreferences get preferences => ref.read(userPreferencesProvider); - LazyBox? cacheTrackBox; - - @protected - final DBusClient? dbus; - Media_Player? _media_player; - Player_Interface? _mpris; + // playlist & track list properties + late LazyBox cache; + CurrentPlaylist? playlist; + SpotubeTrack? track; - double volume = 1; + // internal stuff + final List _subscriptions; + final _logger = getLogger(Playback); Playback({ required this.player, required this.youtube, required this.ref, - required this.dbus, - CurrentPlaylist? currentPlaylist, - Track? currentTrack, - }) : _currentPlaylist = currentPlaylist, - _currentTrack = currentTrack, + this.mobileAudioService, + }) : volume = 0, + isShuffled = false, + isPlaying = false, + currentDuration = Duration.zero, + _subscriptions = [], super() { - player.onNextRequest = () { - movePlaylistPositionBy(1); - }; - player.onPreviousRequest = () { - movePlaylistPositionBy(-1); - }; - - _init(); - } - - StreamSubscription? _durationStream; - StreamSubscription? _playingStream; - StreamSubscription? _positionStream; - - void _init() async { - // dbus m.p.r.i.s stuff if (Platform.isLinux) { - try { - _media_player = Media_Player(); - _mpris = Player_Interface(player: player.core, playback: this); - final nameStatus = - await dbus?.requestName("org.mpris.MediaPlayer2.spotube"); - if (nameStatus == DBusRequestNameReply.exists) { - await dbus - ?.requestName("org.mpris.MediaPlayer2.spotube.instance$pid"); - } - await dbus?.registerObject(_media_player!); - await dbus?.registerObject(_mpris!); - } catch (e) { - logger.e("[MPRIS initialization error]", e); - } + linuxMPRIS = Media_Player(); + linuxMPRIS_Player = Player_Interface(playback: this); } - cacheTrackBox = await Hive.openLazyBox("track-cache"); - - _playingStream = player.core.onPlayerStateChanged.listen( - (state) async { - _isPlaying = state == PlayerState.playing; - if (state == PlayerState.completed) { - if (_currentTrack?.id != null) { - movePlaylistPositionBy(1); + (() async { + cache = await Hive.openLazyBox("track-cache"); + _subscriptions.addAll([ + player.onPlayerStateChanged.listen( + (state) async { + isPlaying = state == PlayerState.playing; + notifyListeners(); + }, + ), + player.onPlayerComplete.listen((_) { + if (track?.id != null) { + seekForward(); } else { - _isPlaying = false; - duration = null; + isPlaying = false; + currentDuration = Duration.zero; + notifyListeners(); } - } - notifyListeners(); - }, - ); - - _durationStream = player.core.onDurationChanged.listen((event) { - duration = event; - notifyListeners(); - }); - - _positionStream = player.core.onPositionChanged.listen((pos) async { - if (pos > Duration.zero && - (duration == null || duration == Duration.zero)) { - duration = await player.core.getDuration(); - notifyListeners(); - } - }); + }), + player.onDurationChanged.listen((event) { + currentDuration = event; + notifyListeners(); + }), + player.onPositionChanged.listen((pos) async { + if (pos > Duration.zero && currentDuration == Duration.zero) { + currentDuration = await player.getDuration() ?? Duration.zero; + notifyListeners(); + } + }), + ]); + }()); } @override void dispose() { - _playingStream?.cancel(); - _durationStream?.cancel(); - _positionStream?.cancel(); - cacheTrackBox?.close(); - if (Platform.isLinux && _media_player != null && _mpris != null) { - dbus?.unregisterObject(_media_player!); - dbus?.unregisterObject(_mpris!); + linuxMPRIS?.dispose(); + linuxMPRIS_Player?.dispose(); + for (var subscription in _subscriptions) { + subscription.cancel(); } super.dispose(); } - bool get shuffled => _shuffled; - CurrentPlaylist? get currentPlaylist => _currentPlaylist; - Track? get currentTrack => _currentTrack; - bool get isPlaying => _isPlaying; + 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!; + }); + } + + // player methods + Future play([Track? track]) async { + _logger.v("[Track Playing] ${track?.name} - ${track?.id}"); + try { + // the track is already playing so no need to change that + if ((track != null && track.id == this.track?.id) || + (this.track == null && track == null)) return; + track ??= this.track; + final tag = MediaItem( + id: track!.id!, + title: track.name!, + album: track.album?.name, + artist: artistsToString(track.artists ?? []), + artUri: Uri.parse(imageToUrlString(track.album?.images)), + ); + mobileAudioService?.addItem(tag); - set setCurrentTrack(Track track) { - _logger.v("[Setting Current Track] ${track.name} - ${track.id}"); - _currentTrack = track; - notifyListeners(); - updatePersistence(); + // 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}"); + await player.play(UrlSource(track.ytUri)).then((_) { + this.track = track as SpotubeTrack; + notifyListeners(); + }); + } catch (e, stack) { + _logger.e("play", e, stack); + } } - set setCurrentPlaylist(CurrentPlaylist playlist) { - _logger.v("[Current Playlist Changed] ${playlist.name} - ${playlist.id}"); - _currentPlaylist = playlist; + Future resume() async { + if (isPlaying || (playlist == null && track == null)) return; + await player.resume(); + isPlaying = true; notifyListeners(); - updatePersistence(); } - void reset() { - _logger.v("Playback Reset"); - _isPlaying = false; - _shuffled = false; - duration = null; - _currentPlaylist = null; - _currentTrack = null; + Future pause() async { + if (!isPlaying || (playlist == null && track == null)) return; + await player.pause(); + isPlaying = false; notifyListeners(); - updatePersistence(clearNullEntries: true); } - void setVolume(double newVolume) { + Future togglePlayPause() async { + isPlaying ? await pause() : await resume(); + } + + toggleShuffle() { + final result = isShuffled ? playlist?.unshuffle() : playlist?.shuffle(); + if (result == true) { + isShuffled = !isShuffled; + notifyListeners(); + } + } + + Future seekPosition(Duration position) { + return player.seek(position); + } + + Future setVolume(double newVolume) async { + await player.setVolume(volume); volume = newVolume; notifyListeners(); - 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; - } + Future stop() async { + await player.stop(); + await player.release(); + isPlaying = false; + isShuffled = false; + playlist = null; + track = null; + currentDuration = Duration.zero; + notifyListeners(); } - 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(); + destroy() {} + + // playlist & track list methods + Future toSpotubeTrack(Track track) async { + final format = preferences.ytSearchFormat; + final matchAlgorithm = preferences.trackMatchAlgorithm; + final artistsName = + track.artists?.map((ar) => ar.name).toList().whereNotNull().toList() ?? + []; + final audioQuality = preferences.audioQuality; + _logger.v("[Track Search Artists] $artistsName"); + final mainArtist = artistsName.first; + final featuredArtists = artistsName.length > 1 + ? "feat. " + artistsName.sublist(1).join(" ") + : ""; + final title = getTitle( + track.name!, + artists: artistsName, + onlyCleanArtist: true, + ).trim(); + _logger.v("[Track Search Title] $title"); + final queryString = format + .replaceAll("\$MAIN_ARTIST", mainArtist) + .replaceAll("\$TITLE", title) + .replaceAll("\$FEATURED_ARTISTS", featuredArtists); + _logger.v("[Youtube Search Term] $queryString"); + + Video ytVideo; + final cachedTrack = await cache.get(track.id); + if (cachedTrack != null && cachedTrack.mode == matchAlgorithm.name) { + _logger.v( + "[Playing track from cache] youtubeId: ${cachedTrack.id} mode: ${cachedTrack.mode}", + ); + ytVideo = VideoFromCacheTrackExtension.fromCacheTrack(cachedTrack); + } else { + VideoSearchList videos = await youtube.search.search(queryString); + if (matchAlgorithm != SpotubeTrackMatchAlgorithm.youtube) { + List ratedRankedVideos = videos + .map((video) { + // the find should be lazy thus everything case insensitive + final ytTitle = video.title.toLowerCase(); + final bool hasTitle = ytTitle.contains(title); + final bool hasAllArtists = track.artists?.every( + (artist) => ytTitle.contains(artist.name!.toLowerCase()), + ) ?? + false; + final bool authorIsArtist = + track.artists?.first.name?.toLowerCase() == + video.author.toLowerCase(); + + final bool hasNoLiveInTitle = + !containsTextInBracket(ytTitle, "live"); + + int rate = 0; + for (final el in [ + hasTitle, + hasAllArtists, + if (matchAlgorithm == + SpotubeTrackMatchAlgorithm.authenticPopular) + authorIsArtist, + hasNoLiveInTitle, + !video.isLive, + ]) { + if (el) rate++; + } + // can't let pass any non title matching track + if (!hasTitle) rate = rate - 2; + return { + "video": video, + "points": rate, + "views": video.engagement.viewCount, + }; + }) + .toList() + .sortByProperties( + [false, false], + ["points", "views"], + ); + + ytVideo = ratedRankedVideos.first["video"] as Video; + } else { + ytVideo = videos.where((video) => !video.isLive).first; } } - } - 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(); - }); - } + 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); - } - } + }); - void shuffle() { - if (currentPlaylist?.shuffle() == true) { - _shuffled = true; - notifyListeners(); + 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 unshuffle() { - if (currentPlaylist?.unshuffle() == true) { - _shuffled = false; - notifyListeners(); - } + Future setPlaylistPosition(int position) async { + if (playlist == null) return; + await playPlaylist(playlist!, position); } - @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; + 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)); } - @override - FutureOr> toMap() { - return { - "currentPlaylist": currentPlaylist != null - ? jsonEncode(currentPlaylist?.toJson()) - : null, - "currentTrack": - currentTrack != null ? jsonEncode(currentTrack?.toJson()) : null, - "volume": volume, - }; + Future seekBackward() async { + if (playlist == null || track == null) return; + final int prevTrackIndex = + (playlist!.trackIds.indexOf(track!.id!) - 1).toInt(); + // checking if there's any track available behind + if (prevTrackIndex < 0) return; + await play(playlist!.tracks.elementAt(prevTrackIndex)); } } final playbackProvider = ChangeNotifierProvider((ref) { - final player = AudioPlayerHandler(); final youtube = ref.watch(youtubeProvider); - final dbus = ref.watch(dbusClientProvider); + final player = ref.watch(audioPlayerProvider); return Playback( player: player, youtube: youtube, ref: ref, - dbus: dbus, ); }); diff --git a/lib/provider/SpotifyRequests.dart b/lib/provider/SpotifyRequests.dart index 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/utils/AudioPlayerHandler.dart b/lib/utils/AudioPlayerHandler.dart index 266737a6b..ed2a825b0 100644 --- a/lib/utils/AudioPlayerHandler.dart +++ b/lib/utils/AudioPlayerHandler.dart @@ -1,17 +1,15 @@ import 'dart:async'; import 'package:audio_service/audio_service.dart'; -import 'package:audioplayers/audioplayers.dart'; +import 'package:spotube/provider/Playback.dart'; /// An [AudioHandler] for playing a single item. class AudioPlayerHandler extends BaseAudioHandler { - final _player = AudioPlayer(); - - FutureOr Function()? onNextRequest; - FutureOr Function()? onPreviousRequest; + final Playback playback; /// Initialise our audio handler. - AudioPlayerHandler() { + AudioPlayerHandler(this.playback) { + final _player = playback.player; // So that our clients (the Flutter UI and the system notification) know // what state to display, here we set up our audio handler to broadcast all // playback state changes as they happen via playbackState... @@ -27,8 +25,6 @@ class AudioPlayerHandler extends BaseAudioHandler { }); } - AudioPlayer get core => _player; - void addItem(MediaItem item) { mediaItem.add(item); } @@ -39,32 +35,32 @@ class AudioPlayerHandler extends BaseAudioHandler { // your audio playback logic in one place. @override - Future play() => _player.resume(); + Future play() => playback.resume(); @override - Future pause() => _player.pause(); + Future pause() => playback.pause(); @override - Future seek(Duration position) => _player.seek(position); + Future seek(Duration position) => playback.seekPosition(position); @override - Future stop() => _player.stop(); + Future stop() => playback.stop(); @override Future skipToNext() async { - await onNextRequest?.call(); + playback.seekForward(); await super.skipToNext(); } @override Future skipToPrevious() async { - await onPreviousRequest?.call(); + playback.seekBackward(); await super.skipToPrevious(); } @override Future onTaskRemoved() { - _player.stop(); + playback.destroy(); return super.onTaskRemoved(); } @@ -77,16 +73,14 @@ class AudioPlayerHandler extends BaseAudioHandler { return PlaybackState( controls: [ MediaControl.skipToPrevious, - if (_player.state == PlayerState.playing) - MediaControl.pause - else - MediaControl.play, + if (playback.isPlaying) MediaControl.pause else MediaControl.play, MediaControl.skipToNext, MediaControl.stop, ], androidCompactActionIndices: const [0, 1, 2], - playing: _player.state == PlayerState.playing, - updatePosition: (await _player.getCurrentPosition()) ?? Duration.zero, + playing: playback.isPlaying, + updatePosition: + (await playback.player.getCurrentPosition()) ?? Duration.zero, ); } } From bc1334dd6dc491059ab7ab5f456cb8a5a8e78907 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 3 Jul 2022 13:14:49 +0600 Subject: [PATCH 05/10] Got playlist caching working again with proper volume controls --- .vscode/settings.json | 5 +- lib/components/Player/Player.dart | 44 +-- lib/components/Player/PlayerControls.dart | 83 +++-- lib/components/Player/PlayerView.dart | 1 - lib/extensions/yt-video-from-cache-track.dart | 68 ++++ lib/hooks/playback.dart | 11 +- lib/interfaces/media_player2.dart | 214 ------------ lib/main.dart | 6 +- lib/models/SpotubeTrack.dart | 43 ++- lib/provider/LegacyPlayback.dart | 325 ------------------ lib/provider/Playback.dart | 55 ++- .../LinuxAudioService.dart} | 233 ++++++++++++- lib/services/MobileAudioService.dart | 84 +++++ lib/utils/AudioPlayerHandler.dart | 86 ----- 14 files changed, 549 insertions(+), 709 deletions(-) delete mode 100644 lib/interfaces/media_player2.dart delete mode 100644 lib/provider/LegacyPlayback.dart rename lib/{interfaces/media_player2_player.dart => services/LinuxAudioService.dart} (68%) create mode 100644 lib/services/MobileAudioService.dart delete mode 100644 lib/utils/AudioPlayerHandler.dart 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/lib/components/Player/Player.dart b/lib/components/Player/Player.dart index 329b3b340..1e939456e 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 6a70a7fba..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; @@ -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 3ea3f7217..63c2b0745 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 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/hooks/playback.dart b/lib/hooks/playback.dart index 07f1f3ae8..313691bd0 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 44286db26..000000000 --- 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 4a888ec83..e6b47da2d 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 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/LegacyPlayback.dart b/lib/provider/LegacyPlayback.dart deleted file mode 100644 index 1cf4efafa..000000000 --- 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 5459777fe..6161f4f1e 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 a315636db..ea620fef7 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 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 ed2a825b0..000000000 --- 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, - ); - } -} From 03f1d3e9588ddb853972a5707666dd7151028ea0 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 3 Jul 2022 13:53:38 +0600 Subject: [PATCH 06/10] Fixed track not changing on complete --- lib/hooks/playback.dart | 2 +- lib/provider/Playback.dart | 19 ++++++++----------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/lib/hooks/playback.dart b/lib/hooks/playback.dart index 313691bd0..f62fee26a 100644 --- a/lib/hooks/playback.dart +++ b/lib/hooks/playback.dart @@ -35,7 +35,7 @@ Future Function([dynamic]) useTogglePlayPause(Playback playback) { } else if (playback.track != null && playback.currentDuration == Duration.zero && await playback.player.getCurrentPosition() == Duration.zero) { - await playback.play(); + await playback.play(playback.track!); } else { await playback.togglePlayPause(); } diff --git a/lib/provider/Playback.dart b/lib/provider/Playback.dart index 6161f4f1e..a543b7a24 100644 --- a/lib/provider/Playback.dart +++ b/lib/provider/Playback.dart @@ -126,15 +126,13 @@ class Playback extends PersistedChangeNotifier { } // player methods - Future play([Track? track]) async { - _logger.v("[Track Playing] ${track?.name} - ${track?.id}"); + Future play(Track track) async { + _logger.v("[Track Playing] ${track.name} - ${track.id}"); try { // the track is already playing so no need to change that - if ((track != null && track.id == this.track?.id) || - (this.track == null && track == null)) return; - track ??= this.track; + if (track.id == this.track?.id) return; final tag = MediaItem( - id: track!.id!, + id: track.id!, title: track.name!, album: track.album?.name, artist: artistsToString(track.artists ?? []), @@ -147,11 +145,10 @@ class Playback extends PersistedChangeNotifier { track = await toSpotubeTrack(track); } _logger.v("[Track Direct Source] - ${(track).ytUri}"); - await player.play(UrlSource(track.ytUri)).then((_) { - this.track = track as SpotubeTrack; - notifyListeners(); - updatePersistence(); - }); + this.track = track; + notifyListeners(); + updatePersistence(); + await player.play(UrlSource(track.ytUri)); } catch (e, stack) { _logger.e("play", e, stack); } From e0b8d88ca62156f603e0f8da129f7edb686f2bf5 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 3 Jul 2022 16:50:18 +0600 Subject: [PATCH 07/10] added windows build job --- .github/workflows/feature-audioplayers.yaml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/feature-audioplayers.yaml b/.github/workflows/feature-audioplayers.yaml index f0f06e40e..bbf98bfd5 100644 --- a/.github/workflows/feature-audioplayers.yaml +++ b/.github/workflows/feature-audioplayers.yaml @@ -26,3 +26,23 @@ jobs: with: name: Spotube-Linux-Bundle path: build/Spotube-linux-x86_64.tar.xz + build_windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v2 + - uses: subosito/flutter-action@v2.2.0 + with: + cache: true + - run: flutter config --enable-windows-desktop + - run: flutter pub get + - run: dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}' + - run: flutter build windows + - run: ls build/windows/runner/Release + - run: choco install make -y + - run: make innoinstall + - run: make inno + - uses: actions/upload-artifact@v2 + with: + name: Spotube-Windows-Bundle + path: | + build/installer/Spotube-windows-x86_64-setup.exe \ No newline at end of file From 2f05e3f8d9ad99072ea6a3c6e732d4e72c66679e Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 3 Jul 2022 17:04:05 +0600 Subject: [PATCH 08/10] Windows build configuration fix --- .github/workflows/feature-audioplayers.yaml | 45 +++++++++++++++------ 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/.github/workflows/feature-audioplayers.yaml b/.github/workflows/feature-audioplayers.yaml index bbf98bfd5..4a0b14c7f 100644 --- a/.github/workflows/feature-audioplayers.yaml +++ b/.github/workflows/feature-audioplayers.yaml @@ -29,20 +29,41 @@ jobs: build_windows: runs-on: windows-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 + - name: Get latest tag + id: tag + uses: dawidd6/action-get-tag@v1 + with: + # Optionally strip `v` prefix + strip_v: true + # Replace Version in files + - run: | + choco install sed make -y + sed -i "s/%{{SPOTUBE_VERSION}}%/${{ steps.tag.outputs.tag }}/" windows/runner/Runner.rc + sed -i "s/%{{SPOTUBE_VERSION}}%/${{ steps.tag.outputs.tag }}/" choco-struct/tools/VERIFICATION.txt + sed -i "s/%{{SPOTUBE_VERSION}}%/${{ steps.tag.outputs.tag }}/" choco-struct/spotube.nuspec + + # Build Windows Executable - uses: subosito/flutter-action@v2.2.0 with: cache: true - - run: flutter config --enable-windows-desktop - - run: flutter pub get - - run: dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}' - - run: flutter build windows - - run: ls build/windows/runner/Release - - run: choco install make -y - - run: make innoinstall - - run: make inno - - uses: actions/upload-artifact@v2 + - run: | + flutter config --enable-windows-desktop + flutter pub get + dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}' + dart pub global activate flutter_distributor + make innoinstall + flutter_distributor package --platform=windows --targets=exe --skip-clean + + # Create Chocolatey Package + # setting the sha256 hash for new bundle + - run: | + mv dist/**/spotube-*-windows-setup.exe dist/Spotube-windows-x86_64-setup.exe + Set-Variable -Name HASH -Value (Get-FileHash dist\Spotube-windows-x86_64-setup.exe).Hash + sed -i "s/%{{WIN_SHA256}}%/$HASH/" choco-struct/tools/VERIFICATION.txt + + # Upload artifacts + - uses: actions/upload-artifact@v3 with: name: Spotube-Windows-Bundle - path: | - build/installer/Spotube-windows-x86_64-setup.exe \ No newline at end of file + path: dist/ \ No newline at end of file From bce6a6621c4367025c8c84cfe064e9905318c859 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 3 Jul 2022 17:11:00 +0600 Subject: [PATCH 09/10] fixed no ref in branch in GHA build --- .github/workflows/feature-audioplayers.yaml | 22 +-------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/.github/workflows/feature-audioplayers.yaml b/.github/workflows/feature-audioplayers.yaml index 4a0b14c7f..374ce72d8 100644 --- a/.github/workflows/feature-audioplayers.yaml +++ b/.github/workflows/feature-audioplayers.yaml @@ -30,19 +30,6 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@v3 - - name: Get latest tag - id: tag - uses: dawidd6/action-get-tag@v1 - with: - # Optionally strip `v` prefix - strip_v: true - # Replace Version in files - - run: | - choco install sed make -y - sed -i "s/%{{SPOTUBE_VERSION}}%/${{ steps.tag.outputs.tag }}/" windows/runner/Runner.rc - sed -i "s/%{{SPOTUBE_VERSION}}%/${{ steps.tag.outputs.tag }}/" choco-struct/tools/VERIFICATION.txt - sed -i "s/%{{SPOTUBE_VERSION}}%/${{ steps.tag.outputs.tag }}/" choco-struct/spotube.nuspec - # Build Windows Executable - uses: subosito/flutter-action@v2.2.0 with: @@ -54,14 +41,7 @@ jobs: dart pub global activate flutter_distributor make innoinstall flutter_distributor package --platform=windows --targets=exe --skip-clean - - # Create Chocolatey Package - # setting the sha256 hash for new bundle - - run: | - mv dist/**/spotube-*-windows-setup.exe dist/Spotube-windows-x86_64-setup.exe - Set-Variable -Name HASH -Value (Get-FileHash dist\Spotube-windows-x86_64-setup.exe).Hash - sed -i "s/%{{WIN_SHA256}}%/$HASH/" choco-struct/tools/VERIFICATION.txt - + - run: mv dist/**/spotube-*-windows-setup.exe dist/Spotube-windows-x86_64-setup.exe # Upload artifacts - uses: actions/upload-artifact@v3 with: From 46d6f62a73e970571d55309a42ab40a0456a74e0 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 3 Jul 2022 17:35:00 +0600 Subject: [PATCH 10/10] Removed GHA for this branch --- .github/workflows/feature-audioplayers.yaml | 49 --------------------- 1 file changed, 49 deletions(-) delete mode 100644 .github/workflows/feature-audioplayers.yaml diff --git a/.github/workflows/feature-audioplayers.yaml b/.github/workflows/feature-audioplayers.yaml deleted file mode 100644 index 374ce72d8..000000000 --- a/.github/workflows/feature-audioplayers.yaml +++ /dev/null @@ -1,49 +0,0 @@ -name: audioplayers integration build -on: - push: - branches: - - audioplayers_integration - workflow_dispatch: - -jobs: - build_ubuntu: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: subosito/flutter-action@v2.2.0 - with: - cache: true - - run: | - sudo apt-get update -y - sudo apt-get install -y tar clang cmake ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools patchelf desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev - - run: flutter config --enable-linux-desktop - - run: flutter pub get - - run: dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}' - - run: flutter clean - - run: flutter build linux - - run: make tar - - uses: actions/upload-artifact@v2 - with: - name: Spotube-Linux-Bundle - path: build/Spotube-linux-x86_64.tar.xz - build_windows: - runs-on: windows-latest - steps: - - uses: actions/checkout@v3 - # Build Windows Executable - - uses: subosito/flutter-action@v2.2.0 - with: - cache: true - - run: | - flutter config --enable-windows-desktop - flutter pub get - dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}' - dart pub global activate flutter_distributor - make innoinstall - flutter_distributor package --platform=windows --targets=exe --skip-clean - - run: mv dist/**/spotube-*-windows-setup.exe dist/Spotube-windows-x86_64-setup.exe - # Upload artifacts - - uses: actions/upload-artifact@v3 - with: - name: Spotube-Windows-Bundle - path: dist/ \ No newline at end of file