diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml deleted file mode 100644 index 4f8f29ec..00000000 --- a/.github/workflows/flutter-build.yml +++ /dev/null @@ -1,89 +0,0 @@ -name: Flutter Cross 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 libwebkit2gtk-4.0-dev keybinder-3.0 python3-pip python3-setuptools patchelf desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse - - 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 deb - - run: make tar - - run: wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage -O /usr/local/bin/appimagetool - - run: chmod +x /usr/local/bin/appimagetool - - run: pip3 install appimage-builder - - run: make appimage - - uses: actions/upload-artifact@v2 - with: - name: Spotube-Linux-Bundle - path: | - build/Spotube-linux-x86_64.deb - build/Spotube-linux-x86_64.tar.xz - build/Spotube-*-x86_64.AppImage - # Building Android Application - - run: echo '${{ secrets.KEYSTORE }}' | base64 --decode > android/app/upload-keystore.jks - - run: echo '${{ secrets.KEY_PROPERTIES }}' > android/key.properties - - run: flutter build apk - - run: make apk - - uses: actions/upload-artifact@v2 - with: - name: Spotube-Android-Bundle - path: | - build/Spotube-android-all-arch.apk - build_windows: - runs-on: windows-latest - steps: - - uses: actions/checkout@v3 - - uses: actions/checkout@v3 - with: - repository: KRTirtho/flutter_distributor - ref: deb-implementation - path: build/flutter_distributor - - 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: choco install innosetup -y - - run: dart pub global activate melos - - run: cd build/flutter_distributor && melos bootstrap && cd ../.. - - run: dart build/flutter_distributor/packages/flutter_distributor/bin/main.dart package --platform=windows --targets=exe --skip-clean - - uses: actions/upload-artifact@v3 - with: - name: Spotube-Windows-Bundle - path: | - dist/**/*.exe - build_macos: - runs-on: macos-11 - steps: - - uses: actions/checkout@v2 - - uses: subosito/flutter-action@v2 - with: - cache: true - - run: flutter config --enable-macos-desktop - - run: flutter pub get - - run: dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}' - - run: flutter build macos - - run: du -sh build/macos/Build/Products/Release/spotube.app - - run: npm install -g appdmg - - run: appdmg appdmg.json build/Spotube-macos-x86_64.dmg - - uses: actions/upload-artifact@v2 - with: - name: Spotube-Macos-Bundle - path: | - build/Spotube-macos-x86_64.dmg diff --git a/.github/workflows/spotube-nightly.yml b/.github/workflows/spotube-nightly.yml new file mode 100644 index 00000000..5600ab46 --- /dev/null +++ b/.github/workflows/spotube-nightly.yml @@ -0,0 +1,134 @@ +name: Spotube Nightly +on: + push: + branches: + - build + workflow_dispatch: + +jobs: + build_ubuntu: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Get latest tag + id: tag + uses: dawidd6/action-get-tag@v1 + with: + strip_v: true + - uses: subosito/flutter-action@v2 + with: + cache: true + - name: Get current date + id: date + run: echo "::set-output name=date::$(date +'%Y-%m-%d')" + - 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: | + wget -O appimage-builder-x86_64.AppImage https://github.com/AppImageCrafters/appimage-builder/releases/download/v1.0.0-beta.1/appimage-builder-1.0.0-677acbd-x86_64.AppImage + chmod +x appimage-builder-x86_64.AppImage + mv appimage-builder-x86_64.AppImage /usr/local/bin/appimage-builder + # replacing & adding new release version with older version + - run: | + sed -i 's|%{{APPDATA_RELEASE}}%||' linux/com.github.KRTirtho.Spotube.appdata.xml + + - run: | + flutter config --enable-linux-desktop + flutter pub get + dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}' + dart pub global activate flutter_distributor + flutter_distributor package --platform=linux --targets=deb,appimage --skip-clean + make tar + - run: | + mv build/Spotube-linux-x86_64.tar.xz dist/ + mv dist/**/spotube-*-linux.deb dist/Spotube-linux-x86_64.deb + mv dist/**/spotube-*-linux.AppImage dist/Spotube-linux-x86_64.AppImage + - uses: actions/upload-artifact@v3 + with: + name: Spotube-Linux-Bundle + path: dist/ + + build_android: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Get latest tag + id: tag + uses: dawidd6/action-get-tag@v1 + with: + strip_v: true + - uses: subosito/flutter-action@v2 + with: + cache: true + - run: | + sudo apt-get update -y + sudo apt-get install -y 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 + - run: | + flutter pub get + dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}' + dart pub global activate flutter_distributor + flutter_distributor package --platform=android --targets=apk --skip-clean + - run: | + mv dist/**/spotube-*-android.apk dist/Spotube-android-all-arch.apk + - uses: actions/upload-artifact@v3 + with: + name: Spotube-Android-Bundle + path: dist/ + + build_windows: + 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 + + # 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 + + # Create Chocolatey Package + # setting the sha256 hash for new bundle + - 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/ + + + build_macos: + runs-on: macos-11 + steps: + - uses: actions/checkout@v2 + - uses: subosito/flutter-action@v2 + with: + cache: true + - run: flutter config --enable-macos-desktop + - run: flutter pub get + - run: dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}' + - run: flutter build macos + - run: du -sh build/macos/Build/Products/Release/spotube.app + - run: npm install -g appdmg + - run: appdmg appdmg.json build/Spotube-macos-x86_64.dmg + - uses: actions/upload-artifact@v2 + with: + name: Spotube-Macos-Bundle + path: | + build/Spotube-macos-x86_64.dmg \ No newline at end of file diff --git a/.github/workflows/release-build.yml b/.github/workflows/spotube-release.yml similarity index 87% rename from .github/workflows/release-build.yml rename to .github/workflows/spotube-release.yml index becf00bf..1951583c 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/spotube-release.yml @@ -1,4 +1,4 @@ -name: Spotube Build & Release +name: Spotube Release on: release: types: @@ -36,9 +36,8 @@ jobs: flutter pub get dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}' dart pub global activate melos - cd build/flutter_distributor && melos bootstrap && cd ../.. make innoinstall - dart build/flutter_distributor/packages/flutter_distributor/bin/main.dart package --platform=windows --targets=exe --skip-clean + flutter_distributor package --platform=windows --targets=exe --skip-clean # Create Chocolatey Package # setting the sha256 hash for new bundle @@ -93,11 +92,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - uses: actions/checkout@v3 - with: - repository: KRTirtho/flutter_distributor - ref: deb-implementation - path: build/flutter_distributor - name: Get latest tag id: tag uses: dawidd6/action-get-tag@v1 @@ -111,7 +105,7 @@ jobs: run: echo "::set-output name=date::$(date +'%Y-%m-%d')" - run: | sudo apt-get update -y - sudo apt-get install -y tar clang cmake ninja-build pkg-config libgtk-3-dev make libwebkit2gtk-4.0-dev keybinder-3.0 python3-pip python3-setuptools patchelf desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse + 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: | wget -O appimage-builder-x86_64.AppImage https://github.com/AppImageCrafters/appimage-builder/releases/download/v1.0.0-beta.1/appimage-builder-1.0.0-677acbd-x86_64.AppImage chmod +x appimage-builder-x86_64.AppImage @@ -124,9 +118,8 @@ jobs: flutter config --enable-linux-desktop flutter pub get dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}' - dart pub global activate melos - cd build/flutter_distributor && melos bootstrap && cd ../.. - dart build/flutter_distributor/packages/flutter_distributor/bin/main.dart package --platform=linux --targets=deb,appimage --skip-clean + dart pub global activate flutter_distributor + flutter_distributor package --platform=linux --targets=deb,appimage --skip-clean make tar - run: | mv build/Spotube-linux-x86_64.tar.xz dist/ @@ -141,11 +134,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - uses: actions/checkout@v3 - with: - repository: KRTirtho/flutter_distributor - ref: deb-implementation - path: build/flutter_distributor - name: Get latest tag id: tag uses: dawidd6/action-get-tag@v1 @@ -156,13 +144,12 @@ jobs: cache: true - run: | sudo apt-get update -y - sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev make libwebkit2gtk-4.0-dev keybinder-3.0 python3-pip python3-setuptools patchelf desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse + sudo apt-get install -y 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 - run: | flutter pub get dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}' - dart pub global activate melos - cd build/flutter_distributor && melos bootstrap && cd ../.. - dart build/flutter_distributor/packages/flutter_distributor/bin/main.dart package --platform=android --targets=apk --skip-clean + dart pub global activate flutter_distributor + flutter_distributor package --platform=android --targets=apk --skip-clean - run: | mv dist/**/spotube-*-android.apk dist/Spotube-android-all-arch.apk - uses: actions/upload-artifact@v3 diff --git a/.vscode/settings.json b/.vscode/settings.json index cad7657d..44bf8e0a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,6 @@ { - "cmake.configureOnOpen": false + "cmake.configureOnOpen": false, + "cSpell.words": [ + "Mpris" + ] } \ No newline at end of file diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md index c218df9a..db5423a8 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,11 +113,12 @@ Enhancement suggestions are tracked as [GitHub issues](https://github.com/KRTirt ### Your First Code Contribution + Do the following: - Download the latest Flutter SDK (>=2.15.1) & enable desktop support - Install Development dependencies in linux - - `libwebkit2gtk-4.0-dev`, `libkeybinder-3.0-0` & `libkeybinder-3.0-dev` (for Debian/Ubuntu) - - `webkit2gtk` & `libkeybinder3` (for Arch/Manjaro) + - `libgstreamer1.0-dev` & `libgstreamer-plugins-base1.0-dev` (for Debian/Ubuntu) + - `gstreamer`, `gst-libav`, `gst-plugins-base` & `gst-plugins-good` (for Arch/Manjaro) - Clone the Repo & Run `flutter pub get` in the Terminal - Create a `secrets.json` in root of the project. The structure should be similar to the following example: ```jsoc name="secrets.json" @@ -137,7 +142,7 @@ Do the following: > You can add more clientId/clientSecret/genius-access-token if you want. The credentials used in the example are dummy (fake). You've to use your own secrets - Finally run these following commands in the root of the project to start the Spotube Locally ```bash - $ dart create-secrets.dart --local + $ dart bin/create-secrets.dart --local $ flutter run -d )> ``` diff --git a/README.md b/README.md index 41117801..807516d8 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ I'm always releasing newer versions of binary of the software each 2-3 month wit | Platform | Package/Installation Method | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Android | [Android Download][android-dlink] | +| Android | [Android Download][android-dlink]
[Android Download][fdroid-dlink]| | Debian/Ubuntu | [Linux Debian/Ubuntu Download][deb-dlink]
Then run: `sudo apt install Spotube-linux-x86_64.deb` | | Flatpak | `flatpak install com.github.KRTirtho.Spotube`
Download on Flathub | | Arch/Manjaro | pamac: `pamac install spotube-bin`
yay: `yay -Sy spotube-bin` | @@ -127,8 +127,7 @@ Bu why? You can learn about it [here](https://dev.to/krtirtho/choosing-open-sour - [AUR](https://aur.archlinux.org/) - AUR stands for Arch User Repository. It is a community-driven repository for Arch-based Linux distributions users - [Flatpak](https://flatpak.org/) - Flatpak is a utility for software deployment and package management for Linux - [spotify (dart)](https://github.com/rinukkusu/spotify-dart) - A dart library for interfacing with the Spotify API -- [just_audio](https://github.com/ryanheise/just_audio/tree/master/just_audio) - A feature-rich cross-platform audio player for Flutter that supports network audio streams too -- [libwinmedia](https://github.com/harmonoid/libwinmedia) - A cross-platform media playback library for C/C++ with good number of features (only Windows & Linux) +- [audioplayers](https://github.com/bluefireteam/audioplayers) - A Flutter plugin to play multiple audio files simultaneously (Android/iOS) - [youtube_explode_dart](https://github.com/Hexer10/youtube_explode_dart) - YoutubeExplode is a library that provides an interface to query metadata of YouTube videos, playlists and channels, as well as to resolve and download video streams and closed caption tracks - [infinite_scroll_pagination](https://github.com/EdsonBueno/infinite_scroll_pagination) - Flutter package to help you lazily load and display pages of items as the user scrolls down your screen - [bitsdojo_window](https://github.com/bitsdojo/bitsdojo_window) - A Flutter package that makes it easy to customize and work with your Flutter desktop app window on Windows, macOS and Linux @@ -165,5 +164,6 @@ Follow me on [Twitter](https://twitter.com/@krtirtho) for newer updates about th [appimage-dlink]: https://github.com/KRTirtho/spotube/releases/latest/download/Spotube-linux-x86_64.AppImage [mac-dlink]: https://github.com/KRTirtho/spotube/releases/latest/download/Spotube-macos-x86_64.dmg [android-dlink]: https://github.com/KRTirtho/spotube/releases/latest/download/Spotube-android-all-arch.apk +[fdroid-dlink]: https://f-droid.org/packages/oss.krtirtho.spotube/ [wiki-installation-instructions]: https://github.com/KRTirtho/spotube/wiki/Installation-Instrcutions diff --git a/lib/components/Album/AlbumCard.dart b/lib/components/Album/AlbumCard.dart index 5e838e0c..465d79e2 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 78c96093..deca5aac 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 1c7cb382..8501fa0b 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/Home/Home.dart b/lib/components/Home/Home.dart index 15495d51..b6433417 100644 --- a/lib/components/Home/Home.dart +++ b/lib/components/Home/Home.dart @@ -18,7 +18,6 @@ import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; import 'package:spotube/components/Player/Player.dart'; import 'package:spotube/components/Library/UserLibrary.dart'; import 'package:spotube/hooks/useBreakpointValue.dart'; -import 'package:spotube/hooks/useHotKeys.dart'; import 'package:spotube/hooks/usePaginatedFutureProvider.dart'; import 'package:spotube/hooks/useUpdateChecker.dart'; import 'package:spotube/models/Logger.dart'; @@ -54,8 +53,6 @@ class Home extends HookConsumerWidget { final _selectedIndex = useState(0); _onSelectedIndexChanged(int index) => _selectedIndex.value = index; - // initializing global hot keys - useHotKeys(ref); // checks for latest version of the application useUpdateChecker(ref); diff --git a/lib/components/LoaderShimmers/ShimmerLyrics.dart b/lib/components/LoaderShimmers/ShimmerLyrics.dart new file mode 100644 index 00000000..6743e303 --- /dev/null +++ b/lib/components/LoaderShimmers/ShimmerLyrics.dart @@ -0,0 +1,67 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:skeleton_text/skeleton_text.dart'; +import 'package:spotube/extensions/ShimmerColorTheme.dart'; +import 'package:spotube/hooks/useBreakpoints.dart'; + +const widths = [20, 56, 89, 60, 25, 69]; + +class ShimmerLyrics extends HookWidget { + const ShimmerLyrics({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final shimmerColor = + Theme.of(context).extension()!.shimmerColor!; + final shimmerBackgroundColor = Theme.of(context) + .extension()! + .shimmerBackgroundColor!; + + final breakpoint = useBreakpoints(); + + return ListView.builder( + itemCount: 20, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + final widthsCp = [...widths]; + if (breakpoint.isMd) { + widthsCp.removeLast(); + } + if (breakpoint.isSm) { + widthsCp.removeLast(); + widthsCp.removeLast(); + } + widthsCp.shuffle(); + return Container( + margin: const EdgeInsets.symmetric(vertical: 5), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: widthsCp.map( + (width) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: SkeletonAnimation( + shimmerColor: shimmerColor, + shimmerDuration: 1000, + child: Container( + height: 10, + width: width.toDouble(), + decoration: BoxDecoration( + color: shimmerBackgroundColor, + borderRadius: BorderRadius.circular(10), + ), + margin: const EdgeInsets.only(top: 10), + ), + ), + ); + }, + ).toList(), + ), + ); + }, + ); + } +} diff --git a/lib/components/Lyrics/Lyrics.dart b/lib/components/Lyrics/Lyrics.dart index 706dc36d..ce3687ef 100644 --- a/lib/components/Lyrics/Lyrics.dart +++ b/lib/components/Lyrics/Lyrics.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/components/LoaderShimmers/ShimmerLyrics.dart'; import 'package:spotube/helpers/artist-to-string.dart'; import 'package:spotube/hooks/useBreakpoints.dart'; import 'package:spotube/provider/Playback.dart'; @@ -22,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), @@ -30,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, @@ -44,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 @@ -52,8 +53,8 @@ class Lyrics extends HookConsumerWidget { ); }, error: (error, __) => Text( - "Sorry, no Lyrics were found for `${playback.currentTrack?.name}` :'("), - loading: () => const CircularProgressIndicator(), + "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 7843fdce..92f1be61 100644 --- a/lib/components/Lyrics/SyncedLyrics.dart +++ b/lib/components/Lyrics/SyncedLyrics.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/components/LoaderShimmers/ShimmerLyrics.dart'; import 'package:spotube/components/Lyrics/Lyrics.dart'; import 'package:spotube/components/Shared/SpotubeMarqueeText.dart'; import 'package:spotube/helpers/artist-to-string.dart'; @@ -42,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) { @@ -98,29 +99,30 @@ 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, ), ), - if (lyricValue != null) + if (lyricValue != null && lyricValue.lyrics.isNotEmpty) Expanded( child: ListView.builder( controller: controller, + itemCount: lyricValue.lyrics.length, itemBuilder: (context, index) { final lyricSlice = lyricValue.lyrics[index]; final isActive = lyricSlice.time.inSeconds == currentTime; @@ -153,9 +155,11 @@ class SyncedLyrics extends HookConsumerWidget { ), ); }, - itemCount: lyricValue.lyrics.length, ), ), + 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 0d7fc3ba..1e939456 100644 --- a/lib/components/Player/Player.dart +++ b/lib/components/Player/Player.dart @@ -1,19 +1,14 @@ -import 'dart:async'; - import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotube/components/Player/PlayerActions.dart'; import 'package:spotube/components/Player/PlayerOverlay.dart'; import 'package:spotube/components/Player/PlayerTrackDetails.dart'; import 'package:spotube/components/Player/PlayerControls.dart'; import 'package:spotube/helpers/image-to-url-string.dart'; import 'package:spotube/hooks/useBreakpoints.dart'; -import 'package:spotube/models/LocalStorageKeys.dart'; import 'package:spotube/models/Logger.dart'; import 'package:spotube/provider/Playback.dart'; import 'package:flutter/material.dart'; -import 'package:spotube/utils/AudioPlayerHandler.dart'; class Player extends HookConsumerWidget { Player({Key? key}) : super(key: key); @@ -23,41 +18,14 @@ class Player extends HookConsumerWidget { Widget build(BuildContext context, ref) { Playback playback = ref.watch(playbackProvider); - final _volume = useState(0.0); - final breakpoint = useBreakpoints(); - final AudioPlayerHandler player = playback.player; - - final Future future = - useMemoized(SharedPreferences.getInstance); - final AsyncSnapshot localStorage = - useFuture(future, initialData: null); - - useEffect(() { - /// warm up the audio player before playing actual audio - /// It's for resolving unresolved issue related to just_audio's - /// [disposeAllPlayers] method which is throwing - /// [UnimplementedException] in the [PlatformInterface] - /// implementation - player.core.setAsset("assets/warmer.mp3"); - return null; - }, []); - - useEffect(() { - if (localStorage.hasData) { - _volume.value = localStorage.data?.getDouble(LocalStorageKeys.volume) ?? - player.core.volume; - } - return null; - }, [localStorage.data]); - String albumArt = useMemoized( () => imageToUrlString( - playback.currentTrack?.album?.images, - index: (playback.currentTrack?.album?.images?.length ?? 1) - 1, + playback.track?.album?.images, + index: (playback.track?.album?.images?.length ?? 1) - 1, ), - [playback.currentTrack?.album?.images], + [playback.track?.album?.images], ); final entryRef = useRef(null); @@ -82,7 +50,7 @@ class Player extends HookConsumerWidget { // entry will result in splashing while resizing the window if (breakpoint.isLessThanOrEqualTo(Breakpoints.md) && entryRef.value == null && - playback.currentTrack != null) { + playback.track != null) { entryRef.value = OverlayEntry( opaque: false, builder: (context) => PlayerOverlay(albumArt: albumArt), @@ -104,7 +72,7 @@ class Player extends HookConsumerWidget { return () { disposeOverlay(); }; - }, [breakpoint, playback.currentTrack]); + }, [breakpoint, playback.track]); // returning an empty non spacious Container as the overlay will take // place in the global overlay stack aka [_entries] @@ -135,22 +103,29 @@ class Player extends HookConsumerWidget { Container( height: 20, constraints: const BoxConstraints(maxWidth: 200), - child: Slider.adaptive( - value: _volume.value, - onChanged: (value) async { - try { - await player.core.setVolume(value).then((_) { - _volume.value = value; - localStorage.data?.setDouble( - LocalStorageKeys.volume, - value, - ); - }); - } catch (e, stack) { - logger.e("onChange", e, stack); - } - }, - ), + child: HookBuilder(builder: (context) { + final volume = useState( + useMemoized(() => playback.volume, []), + ); + return Slider.adaptive( + min: 0, + max: 1, + value: volume.value, + onChanged: (v) { + volume.value = v; + }, + onChangeEnd: (value) async { + try { + // You don't really need to know why but this + // way it works only + await playback.setVolume(value); + await playback.setVolume(value); + } catch (e, stack) { + logger.e("onChange", e, stack); + } + }, + ); + }), ), PlayerActions() ], diff --git a/lib/components/Player/PlayerActions.dart b/lib/components/Player/PlayerActions.dart index abe315f3..3a7bfa72 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 d6f4d6ff..e61f06b1 100644 --- a/lib/components/Player/PlayerControls.dart +++ b/lib/components/Player/PlayerControls.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/helpers/zero-pad-num-str.dart'; import 'package:spotube/hooks/playback.dart'; import 'package:spotube/models/Logger.dart'; import 'package:spotube/provider/Playback.dart'; -import 'package:spotube/utils/AudioPlayerHandler.dart'; class PlayerControls extends HookConsumerWidget { final Color? iconColor; @@ -18,7 +18,6 @@ class PlayerControls extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final Playback playback = ref.watch(playbackProvider); - final AudioPlayerHandler player = playback.player; final onNext = useNextTrack(playback); @@ -26,14 +25,14 @@ class PlayerControls extends HookConsumerWidget { final _playOrPause = useTogglePlayPause(playback); - final duration = playback.duration ?? Duration.zero; + final duration = playback.currentDuration; return Container( constraints: const BoxConstraints(maxWidth: 600), child: Column( children: [ StreamBuilder( - stream: player.core.positionStream, + stream: playback.player.onPositionChanged, builder: (context, snapshot) { final totalMinutes = zeroPadNumStr(duration.inMinutes.remainder(60)); @@ -48,59 +47,71 @@ class PlayerControls extends HookConsumerWidget { final sliderMax = duration.inSeconds; final sliderValue = snapshot.data?.inSeconds ?? 0; - return Column( - children: [ - Slider.adaptive( - // cannot divide by zero - // there's an edge case for value being bigger - // than total duration. Keeping it resolved - value: (sliderMax == 0 || sliderValue > sliderMax) + + return HookBuilder(builder: (context) { + final progressStatic = + (sliderMax == 0 || sliderValue > sliderMax) ? 0 - : sliderValue / sliderMax, - onChanged: (value) {}, - onChangeEnd: (value) { - player.seek( - Duration( - seconds: (value * sliderMax).toInt(), - ), - ); - }, - activeColor: iconColor, - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "$currentMinutes:$currentSeconds", - ), - Text("$totalMinutes:$totalSeconds"), - ], + : sliderValue / sliderMax; + + final progress = useState( + useMemoized(() => progressStatic, []), + ); + + useEffect(() { + progress.value = progressStatic; + return null; + }, [progressStatic]); + + return Column( + children: [ + Slider.adaptive( + // cannot divide by zero + // there's an edge case for value being bigger + // than total duration. Keeping it resolved + value: progress.value.toDouble(), + onChanged: (v) { + progress.value = v; + }, + onChangeEnd: (value) async { + await playback.seekPosition( + Duration( + seconds: (value * sliderMax).toInt(), + ), + ); + }, + activeColor: iconColor, ), - ), - ], - ); + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "$currentMinutes:$currentSeconds", + ), + Text("$totalMinutes:$totalSeconds"), + ], + ), + ), + ], + ); + }); }), Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ IconButton( icon: const Icon(Icons.shuffle_rounded), - color: playback.shuffled + color: playback.isShuffled ? Theme.of(context).primaryColor : iconColor, onPressed: () { - if (playback.currentTrack == null || - playback.currentPlaylist == null) { + if (playback.track == null || playback.playlist == null) { return; } try { - if (!playback.shuffled) { - playback.shuffle(); - } else { - playback.unshuffle(); - } + playback.toggleShuffle(); } catch (e, stack) { logger.e("onShuffle", e, stack); } @@ -128,12 +139,10 @@ class PlayerControls extends HookConsumerWidget { IconButton( icon: const Icon(Icons.stop_rounded), color: iconColor, - onPressed: playback.currentTrack != null + onPressed: playback.track != null ? () async { try { - await player.pause(); - await player.seek(Duration.zero); - playback.reset(); + await playback.stop(); } catch (e, stack) { logger.e("onStop", e, stack); } diff --git a/lib/components/Player/PlayerTrackDetails.dart b/lib/components/Player/PlayerTrackDetails.dart index eb0b25ab..83370719 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 6d580bc3..63c2b074 100644 --- a/lib/components/Player/PlayerView.dart +++ b/lib/components/Player/PlayerView.dart @@ -23,7 +23,7 @@ class PlayerView extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final currentTrack = ref.watch(playbackProvider.select( - (value) => value.currentTrack, + (value) => value.track, )); final breakpoint = useBreakpoints(); @@ -59,7 +59,6 @@ class PlayerView extends HookConsumerWidget { ), backgroundColor: paletteColor.color, body: Column( - // mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Padding( padding: const EdgeInsets.all(10), diff --git a/lib/components/Playlist/PlaylistCard.dart b/lib/components/Playlist/PlaylistCard.dart index a7ac5132..135af4f9 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/PlaylistCreateDialog.dart b/lib/components/Playlist/PlaylistCreateDialog.dart index 45ca6161..e55581ea 100644 --- a/lib/components/Playlist/PlaylistCreateDialog.dart +++ b/lib/components/Playlist/PlaylistCreateDialog.dart @@ -73,11 +73,11 @@ class PlaylistCreateDialog extends HookConsumerWidget { const SizedBox(height: 10), TextField( controller: description, - keyboardType: TextInputType.multiline, - maxLines: 5, decoration: const InputDecoration( hintText: "Description...", ), + keyboardType: TextInputType.multiline, + maxLines: 5, ), const SizedBox(height: 10), CheckboxListTile( diff --git a/lib/components/Playlist/PlaylistView.dart b/lib/components/Playlist/PlaylistView.dart index 203f1948..df1be1df 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 d872b999..10320eef 100644 --- a/lib/components/Search/Search.dart +++ b/lib/components/Search/Search.dart @@ -48,8 +48,8 @@ class Search extends HookConsumerWidget { children: [ Expanded( child: TextField( - decoration: const InputDecoration(hintText: "Search..."), controller: controller, + decoration: const InputDecoration(hintText: "Search..."), onSubmitted: (value) { ref.read(searchTermStateProvider.notifier).state = controller.value.text; @@ -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/Settings/Login.dart b/lib/components/Settings/Login.dart index ac330117..a66e4e8f 100644 --- a/lib/components/Settings/Login.dart +++ b/lib/components/Settings/Login.dart @@ -77,14 +77,16 @@ class Login extends HookConsumerWidget { hintText: "Spotify Client ID", label: Text("ClientID"), ), + keyboardType: TextInputType.visiblePassword, ), const SizedBox(height: 10), TextField( + controller: clientSecretController, decoration: const InputDecoration( hintText: "Spotify Client Secret", label: Text("Client Secret"), ), - controller: clientSecretController, + keyboardType: TextInputType.visiblePassword, ), const SizedBox(height: 20), ElevatedButton( diff --git a/lib/components/Settings/Settings.dart b/lib/components/Settings/Settings.dart index ce28fea8..59ace618 100644 --- a/lib/components/Settings/Settings.dart +++ b/lib/components/Settings/Settings.dart @@ -1,19 +1,15 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/components/Settings/About.dart'; import 'package:spotube/components/Settings/ColorSchemePickerDialog.dart'; -import 'package:spotube/components/Settings/SettingsHotkeyTile.dart'; import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; import 'package:spotube/helpers/search-youtube.dart'; import 'package:spotube/models/SpotifyMarkets.dart'; import 'package:spotube/models/SpotubeTrack.dart'; import 'package:spotube/provider/Auth.dart'; import 'package:spotube/provider/UserPreferences.dart'; -import 'package:spotube/utils/platform.dart'; import 'package:url_launcher/url_launcher_string.dart'; class Settings extends HookConsumerWidget { @@ -57,29 +53,6 @@ class Settings extends HookConsumerWidget { constraints: const BoxConstraints(maxWidth: 1366), child: ListView( children: [ - if (!kIsMobile) ...[ - SettingsHotKeyTile( - title: "Next track global shortcut", - currentHotKey: preferences.nextTrackHotKey, - onHotKeyRecorded: (value) { - preferences.setNextTrackHotKey(value); - }, - ), - SettingsHotKeyTile( - title: "Prev track global shortcut", - currentHotKey: preferences.prevTrackHotKey, - onHotKeyRecorded: (value) { - preferences.setPrevTrackHotKey(value); - }, - ), - SettingsHotKeyTile( - title: "Play/Pause global shortcut", - currentHotKey: preferences.playPauseHotKey, - onHotKeyRecorded: (value) { - preferences.setPlayPauseHotKey(value); - }, - ), - ], ListTile( title: const Text("Theme"), horizontalTitleGap: 10, diff --git a/lib/components/Settings/SettingsHotkeyTile.dart b/lib/components/Settings/SettingsHotkeyTile.dart deleted file mode 100644 index a1f32abf..00000000 --- a/lib/components/Settings/SettingsHotkeyTile.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hotkey_manager/hotkey_manager.dart'; -import 'package:spotube/components/Shared/RecordHotKeyDialog.dart'; - -class SettingsHotKeyTile extends StatelessWidget { - final String title; - final HotKey? currentHotKey; - final ValueChanged onHotKeyRecorded; - const SettingsHotKeyTile({ - required this.onHotKeyRecorded, - required this.title, - Key? key, - this.currentHotKey, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return ListTile( - title: Text(title), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (currentHotKey != null) HotKeyVirtualView(hotKey: currentHotKey!), - const SizedBox(width: 10), - ElevatedButton( - child: const Text("Set Shortcut"), - onPressed: () { - showDialog( - context: context, - builder: (context) { - return RecordHotKeyDialog( - onHotKeyRecorded: onHotKeyRecorded, - ); - }, - ); - }, - ), - ], - ), - ); - } -} diff --git a/lib/components/Shared/DownloadTrackButton.dart b/lib/components/Shared/DownloadTrackButton.dart index f11493dd..125ab68b 100644 --- a/lib/components/Shared/DownloadTrackButton.dart +++ b/lib/components/Shared/DownloadTrackButton.dart @@ -115,28 +115,6 @@ class DownloadTrackButton extends HookConsumerWidget { if (!await outputFile.exists()) await outputFile.create(recursive: true); - if (preferences.saveTrackLyrics && playback.currentTrack != 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() ?? - [], - apiKey: preferences.geniusAccessToken, - optimizeQuery: true, - ); - if (lyrics != null) { - await outputLyricsFile.writeAsString( - "$lyrics\n\nPowered by genius.com", - mode: FileMode.writeOnly, - ); - } - } - IOSink outputFileStream = outputFile.openWrite(); await audioStream.pipe(outputFileStream); await outputFileStream.flush(); @@ -154,12 +132,31 @@ class DownloadTrackButton extends HookConsumerWidget { } return statusCb.cancel(); }); + + if (preferences.saveTrackLyrics && playback.track != null) { + if (!await outputLyricsFile.exists()) { + await outputLyricsFile.create(recursive: true); + } + final lyrics = await getLyrics( + playback.track!.name!, + playback.track!.artists?.map((s) => s.name).whereNotNull().toList() ?? + [], + apiKey: preferences.geniusAccessToken, + optimizeQuery: true, + ); + if (lyrics != null) { + await outputLyricsFile.writeAsString( + "$lyrics\n\nPowered by genius.com", + mode: FileMode.writeOnly, + ); + } + } }, [ track, status, yt, preferences.saveTrackLyrics, - playback.currentTrack, + playback.track, ]); useEffect(() { diff --git a/lib/components/Shared/RecordHotKeyDialog.dart b/lib/components/Shared/RecordHotKeyDialog.dart deleted file mode 100644 index ff8a6885..00000000 --- a/lib/components/Shared/RecordHotKeyDialog.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hotkey_manager/hotkey_manager.dart'; - -class RecordHotKeyDialog extends HookWidget { - final ValueChanged onHotKeyRecorded; - - const RecordHotKeyDialog({ - Key? key, - required this.onHotKeyRecorded, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final _hotKey = useState(null); - return AlertDialog( - content: SingleChildScrollView( - child: ListBody( - children: [ - Text( - 'Press the keys you want to use', - style: Theme.of(context).textTheme.headline5, - ), - const SizedBox(height: 10), - const Text( - "DO NOT Use only letters (e.g. k, g etc..)\nUse in combination with these"), - const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - KeyCode.control, - KeyCode.shift, - KeyCode.alt, - KeyCode.superKey, - KeyCode.meta, - ] - .map((key) => HotKeyVirtualView( - hotKey: HotKey(key), - )) - .toList(), - ), - Container( - width: 100, - height: 60, - margin: const EdgeInsets.only(top: 20), - decoration: BoxDecoration( - border: Border.all( - color: Theme.of(context).primaryColor, - ), - ), - child: Stack( - alignment: Alignment.center, - children: [ - HotKeyRecorder( - onHotKeyRecorded: (hotKey) { - _hotKey.value = hotKey; - }, - ), - ], - ), - ), - ], - ), - ), - actions: [ - TextButton( - child: const Text('Cancel'), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - TextButton( - child: const Text('OK'), - onPressed: _hotKey.value == null - ? null - : () { - onHotKeyRecorded(_hotKey.value!); - GoRouter.of(context).pop(); - }, - ), - ], - ); - } -} diff --git a/lib/components/Shared/TrackTile.dart b/lib/components/Shared/TrackTile.dart index 789985d0..f9fdcc94 100644 --- a/lib/components/Shared/TrackTile.dart +++ b/lib/components/Shared/TrackTile.dart @@ -84,7 +84,7 @@ class TrackTile extends HookConsumerWidget { }); } - actionAddToPlaylist() async { + Future actionAddToPlaylist() async { showDialog( context: context, builder: (context) { @@ -196,8 +196,7 @@ class TrackTile extends HookConsumerWidget { ), IconButton( icon: Icon( - playback.currentTrack?.id != null && - playback.currentTrack?.id == track.value.id + playback.track?.id != null && playback.track?.id == track.value.id ? Icons.pause_circle_rounded : Icons.play_circle_rounded, color: Theme.of(context).primaryColor, diff --git a/lib/extensions/yt-video-from-cache-track.dart b/lib/extensions/yt-video-from-cache-track.dart index 3aed8b5b..1777d8cb 100644 --- a/lib/extensions/yt-video-from-cache-track.dart +++ b/lib/extensions/yt-video-from-cache-track.dart @@ -30,3 +30,71 @@ extension VideoFromCacheTrackExtension on Video { ); } } + +extension ThumbnailSetJson on ThumbnailSet { + static ThumbnailSet fromJson(Map map) { + return ThumbnailSet(map["videoId"]); + } + + Map toJson() { + return { + "videoId": videoId, + }; + } +} + +extension EngagementJson on Engagement { + static Engagement fromJson(Map map) { + return Engagement( + map["viewCount"], + map["likeCount"], + map["dislikeCount"], + ); + } + + Map toJson() { + return { + "dislikeCount": dislikeCount, + "likeCount": likeCount, + "viewCount": viewCount, + }; + } +} + +extension VideoToJson on Video { + static Video fromJson(Map map) { + return Video( + VideoId(map["id"]), + map["title"], + map["author"], + ChannelId(map["channelId"]), + DateTime.tryParse(map["uploadDate"]), + DateTime.tryParse(map["publishDate"]), + map["description"], + parseDuration(map["duration"]), + ThumbnailSetJson.fromJson(map["thumbnails"]), + List.castFrom(map["keywords"]), + EngagementJson.fromJson(map["engagement"]), + map["isLive"], + ); + } + + Map toJson() { + return { + "hasWatchPage": hasWatchPage, + "url": url, + "author": author, + "channelId": channelId.value, + "description": description, + "duration": duration.toString(), + "engagement": engagement.toJson(), + "id": id.value, + "isLive": isLive, + "keywords": keywords.toList(), + "publishDate": publishDate.toString(), + "thumbnails": thumbnails.toJson(), + "title": title, + "uploadDate": uploadDate.toString(), + }; + } +} diff --git a/lib/helpers/search-youtube.dart b/lib/helpers/search-youtube.dart index 18f680c8..a6ae0144 100644 --- a/lib/helpers/search-youtube.dart +++ b/lib/helpers/search-youtube.dart @@ -107,10 +107,16 @@ Future toSpotubeTrack({ "[YouTube Matched Track] ${ytVideo.title} | ${ytVideo.author} - ${ytVideo.url}", ); - final audioManifest = (Platform.isMacOS || Platform.isIOS) - ? trackManifest.audioOnly - .where((info) => info.codec.mimeType == "audio/mp4") - : trackManifest.audioOnly; + final audioManifest = trackManifest.audioOnly.where((info) { + final isMp4a = info.codec.mimeType == "audio/mp4"; + if (Platform.isLinux) { + return !isMp4a; + } else if (Platform.isMacOS || Platform.isIOS) { + return isMp4a; + } else { + return true; + } + }); final ytUri = (audioQuality == AudioQuality.high ? audioManifest.withHighestBitrate() diff --git a/lib/hooks/playback.dart b/lib/hooks/playback.dart index 2dc4f035..f62fee26 100644 --- a/lib/hooks/playback.dart +++ b/lib/hooks/playback.dart @@ -8,7 +8,7 @@ Future Function() useNextTrack(Playback playback) { try { await playback.player.pause(); await playback.player.seek(Duration.zero); - playback.movePlaylistPositionBy(1); + playback.seekForward(); } catch (e, stack) { logger.e("useNextTrack", e, stack); } @@ -20,7 +20,7 @@ Future Function() usePreviousTrack(Playback playback) { try { await playback.player.pause(); await playback.player.seek(Duration.zero); - playback.movePlaylistPositionBy(-1); + playback.seekBackward(); } catch (e, stack) { logger.e("onPrevious", e, stack); } @@ -30,10 +30,15 @@ Future Function() usePreviousTrack(Playback playback) { Future Function([dynamic]) useTogglePlayPause(Playback playback) { return ([key]) async { try { - if (playback.currentTrack == null) return; - playback.isPlaying - ? await playback.player.pause() - : await playback.player.play(); + if (playback.track == null) { + return; + } else if (playback.track != null && + playback.currentDuration == Duration.zero && + await playback.player.getCurrentPosition() == Duration.zero) { + await playback.play(playback.track!); + } else { + await playback.togglePlayPause(); + } } catch (e, stack) { logger.e("useTogglePlayPause", e, stack); } diff --git a/lib/hooks/useHotKeys.dart b/lib/hooks/useHotKeys.dart deleted file mode 100644 index e4989870..00000000 --- a/lib/hooks/useHotKeys.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:hotkey_manager/hotkey_manager.dart'; -import 'package:spotube/hooks/playback.dart'; -import 'package:spotube/models/GlobalKeyActions.dart'; -import 'package:spotube/provider/Playback.dart'; -import 'package:spotube/provider/UserPreferences.dart'; -import 'package:spotube/utils/platform.dart'; - -useHotKeys(WidgetRef ref) { - final playback = ref.watch(playbackProvider); - final preferences = ref.watch(userPreferencesProvider); - List _hotKeys = []; - - final onNext = useNextTrack(playback); - - final onPrevious = usePreviousTrack(playback); - - final _playOrPause = useTogglePlayPause(playback); - - useEffect(() { - if (kIsMobile) return null; - _hotKeys = [ - GlobalKeyActions( - HotKey(KeyCode.space, scope: HotKeyScope.inapp), - _playOrPause, - ), - if (preferences.nextTrackHotKey != null) - GlobalKeyActions(preferences.nextTrackHotKey!, (key) => onNext()), - if (preferences.prevTrackHotKey != null) - GlobalKeyActions(preferences.prevTrackHotKey!, (key) => onPrevious()), - if (preferences.playPauseHotKey != null) - GlobalKeyActions(preferences.playPauseHotKey!, _playOrPause) - ]; - Future.wait( - _hotKeys.map((e) { - return hotKeyManager.register( - e.hotKey, - keyDownHandler: e.onKeyDown, - ); - }), - ); - return () { - Future.wait(_hotKeys.map((e) => hotKeyManager.unregister(e.hotKey))); - }; - }); -} diff --git a/lib/hooks/useSyncedLyrics.dart b/lib/hooks/useSyncedLyrics.dart index f48686ab..5f18014c 100644 --- a/lib/hooks/useSyncedLyrics.dart +++ b/lib/hooks/useSyncedLyrics.dart @@ -6,7 +6,7 @@ useSyncedLyrics(WidgetRef ref, Map lyricsMap) { final player = ref.watch(playbackProvider.select( (value) => (value.player), )); - final stream = player.core.positionStream; + final stream = player.onPositionChanged; final currentTime = useState(0); diff --git a/lib/main.dart b/lib/main.dart index 97dd971e..e6b47da2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:audio_service/audio_service.dart'; import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:flutter/material.dart'; @@ -7,7 +5,6 @@ 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:hotkey_manager/hotkey_manager.dart'; import 'package:spotube/entities/CacheTrack.dart'; import 'package:spotube/models/GoRouteDeclarations.dart'; import 'package:spotube/models/Logger.dart'; @@ -15,26 +12,19 @@ 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 { 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(); - await hotKeyManager.unregisterAll(); + // final client = DBusClient.session(); + // await client.registerObject(Media_Player()); doWhenWindowReady(() { appWindow.minSize = const Size(359, 700); appWindow.alignment = Alignment.center; @@ -43,17 +33,38 @@ void main() async { appWindow.show(); }); } + MobileAudioService? audioServiceHandler; runApp(ProviderScope( child: Spotube(), overrides: [ playbackProvider.overrideWithProvider(ChangeNotifierProvider( (ref) { final youtube = ref.watch(youtubeProvider); - return Playback( - player: audioPlayerHandler, + final player = ref.watch(audioPlayerProvider); + + final playback = Playback( + player: player, youtube: youtube, ref: ref, ); + + if (audioServiceHandler == null) { + AudioService.init( + builder: () => MobileAudioService(playback), + config: const AudioServiceConfig( + androidNotificationChannelId: 'com.krtirtho.Spotube', + androidNotificationChannelName: 'Spotube', + androidNotificationOngoing: true, + ), + ).then( + (value) { + playback.mobileAudioService = value; + audioServiceHandler = value; + }, + ); + } + + return playback; }, )) ], diff --git a/lib/models/GlobalKeyActions.dart b/lib/models/GlobalKeyActions.dart deleted file mode 100644 index b4348539..00000000 --- a/lib/models/GlobalKeyActions.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:hotkey_manager/hotkey_manager.dart'; - -class GlobalKeyActions { - late final HotKey hotKey; - late final Function(HotKey hotKey) onKeyDown; - GlobalKeyActions(this.hotKey, this.onKeyDown); -} diff --git a/lib/models/GoRouteDeclarations.dart b/lib/models/GoRouteDeclarations.dart index 75666930..894a1d97 100644 --- a/lib/models/GoRouteDeclarations.dart +++ b/lib/models/GoRouteDeclarations.dart @@ -24,8 +24,8 @@ GoRouter createGoRouter() => GoRouter( ), GoRoute( path: "/settings", - pageBuilder: (context, state) => SpotubePage( - child: const Settings(), + pageBuilder: (context, state) => const SpotubePage( + child: Settings(), ), ), GoRoute( diff --git a/lib/models/SpotubeTrack.dart b/lib/models/SpotubeTrack.dart index a1edaaaa..5b2657d4 100644 --- a/lib/models/SpotubeTrack.dart +++ b/lib/models/SpotubeTrack.dart @@ -1,4 +1,6 @@ import 'package:spotify/spotify.dart'; +import 'package:spotube/models/CurrentPlaylist.dart'; +import 'package:spotube/extensions/yt-video-from-cache-track.dart'; import 'package:youtube_explode_dart/youtube_explode_dart.dart'; enum SpotubeTrackMatchAlgorithm { @@ -14,11 +16,16 @@ class SpotubeTrack extends Track { Video ytTrack; String ytUri; + SpotubeTrack( + this.ytTrack, + this.ytUri, + ) : super(); + SpotubeTrack.fromTrack({ required Track track, required this.ytTrack, required this.ytUri, - }) { + }) : super() { album = track.album; artists = track.artists; availableMarkets = track.availableMarkets; @@ -38,4 +45,38 @@ class SpotubeTrack extends Track { type = track.type; uri = track.uri; } + + static SpotubeTrack fromJson(Map map) { + return SpotubeTrack.fromTrack( + track: Track.fromJson(map), + ytTrack: VideoToJson.fromJson(map["ytTrack"]), + ytUri: map["ytUri"], + ); + } + + Map toJson() { + return { + "album": album?.toJson(), + "artists": artists?.map((artist) => artist.toJson()).toList(), + "availableMarkets": availableMarkets, + "discNumber": discNumber, + "duration": duration.toString(), + "durationMs": durationMs, + "explicit": explicit, + // "externalIds": externalIds, + // "externalUrls": externalUrls, + "href": href, + "id": id, + "isPlayable": isPlayable, + // "linkedFrom": linkedFrom, + "name": name, + "popularity": popularity, + "previewUrl": previewUrl, + "trackNumber": trackNumber, + "type": type, + "uri": uri, + "ytTrack": ytTrack.toJson(), + "ytUri": ytUri, + }; + } } diff --git a/lib/provider/AudioPlayer.dart b/lib/provider/AudioPlayer.dart index 6aff379a..3fd44b98 100644 --- a/lib/provider/AudioPlayer.dart +++ b/lib/provider/AudioPlayer.dart @@ -1,5 +1,5 @@ +import 'package:audioplayers/audioplayers.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:just_audio/just_audio.dart'; final audioPlayerProvider = Provider((ref) { return AudioPlayer(); diff --git a/lib/provider/DBus.dart b/lib/provider/DBus.dart new file mode 100644 index 00000000..62704659 --- /dev/null +++ b/lib/provider/DBus.dart @@ -0,0 +1,12 @@ +import 'dart:io'; + +import 'package:dbus/dbus.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final Provider dbusClientProvider = Provider((ref) { + if (Platform.isLinux) { + return DBusClient.session(); + } +}); + +final dbus = DBusClient.session(); diff --git a/lib/provider/Playback.dart b/lib/provider/Playback.dart index 2475588c..a543b7a2 100644 --- a/lib/provider/Playback.dart +++ b/lib/provider/Playback.dart @@ -1,304 +1,383 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:audio_service/audio_service.dart'; +import 'package:audioplayers/audioplayers.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive/hive.dart'; -import 'package:just_audio/just_audio.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/entities/CacheTrack.dart'; +import 'package:spotube/extensions/yt-video-from-cache-track.dart'; import 'package:spotube/helpers/artist-to-string.dart'; +import 'package:spotube/helpers/contains-text-in-bracket.dart'; +import 'package:spotube/helpers/getLyrics.dart'; import 'package:spotube/helpers/image-to-url-string.dart'; import 'package:spotube/helpers/search-youtube.dart'; import 'package:spotube/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'; +import 'package:youtube_explode_dart/youtube_explode_dart.dart' hide Playlist; +import 'package:collection/collection.dart'; +import 'package:spotube/extensions/list-sort-multiple.dart'; class Playback extends PersistedChangeNotifier { - AudioSource? _currentAudioSource; - final _logger = getLogger(Playback); - CurrentPlaylist? _currentPlaylist; - Track? _currentTrack; + // player properties + bool isShuffled; + bool isPlaying; + Duration currentDuration; + double volume; - // states - bool _isPlaying = false; - Duration? duration; + // class dependencies + LinuxAudioService? _linuxAudioService; + MobileAudioService? mobileAudioService; - Duration _prevPosition = Duration.zero; - bool _shuffled = false; - - AudioPlayerHandler player; + // foreign/passed properties + AudioPlayer player; YoutubeExplode youtube; Ref ref; + UserPreferences get preferences => ref.read(userPreferencesProvider); - LazyBox? cacheTrackBox; + // playlist & track list properties + late LazyBox cache; + CurrentPlaylist? playlist; + SpotubeTrack? track; + + // internal stuff + final List _subscriptions; + final _logger = getLogger(Playback); Playback({ required this.player, required this.youtube, required this.ref, - 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); - }; + if (Platform.isLinux) { + _linuxAudioService = LinuxAudioService(this); + } - _init(); - } - - StreamSubscription? _durationStream; - StreamSubscription? _positionStream; - StreamSubscription? _playingStream; - - void _init() async { - cacheTrackBox = await Hive.openLazyBox("track-cache"); - - _playingStream = player.core.playingStream.listen( - (playing) { - _isPlaying = playing; - 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(); - } - }); - - _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(); - } - } - }); + (() async { + cache = await Hive.openLazyBox("track-cache"); + _subscriptions.addAll([ + player.onPlayerStateChanged.listen( + (state) async { + isPlaying = state == PlayerState.playing; + notifyListeners(); + }, + ), + player.onPlayerComplete.listen((_) { + if (track?.id != null) { + seekForward(); + } else { + isPlaying = false; + currentDuration = Duration.zero; + notifyListeners(); + } + }), + player.onDurationChanged.listen((event) { + if (event != currentDuration) { + currentDuration = event; + notifyListeners(); + } + }), + player.onPositionChanged.listen((pos) async { + if (pos > Duration.zero && currentDuration == Duration.zero) { + currentDuration = await player.getDuration() ?? Duration.zero; + notifyListeners(); + } + }), + ]); + }()); } @override void dispose() { - _positionStream?.cancel(); - _playingStream?.cancel(); - _durationStream?.cancel(); - cacheTrackBox?.close(); + _linuxAudioService?.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!; + }); + } - set setCurrentTrack(Track track) { - _logger.v("[Setting Current Track] ${track.name} - ${track.id}"); - _currentTrack = 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.id == this.track?.id) return; + final tag = MediaItem( + id: track.id!, + title: track.name!, + album: track.album?.name, + artist: artistsToString(track.artists ?? []), + artUri: Uri.parse(imageToUrlString(track.album?.images)), + ); + mobileAudioService?.addItem(tag); + + // the track is not a SpotubeTrack so turning it to one + if (track is! SpotubeTrack) { + track = await toSpotubeTrack(track); + } + _logger.v("[Track Direct Source] - ${(track).ytUri}"); + this.track = track; + notifyListeners(); + updatePersistence(); + await player.play(UrlSource(track.ytUri)); + } catch (e, stack) { + _logger.e("play", e, stack); + } + } + + Future resume() async { + if (isPlaying || (playlist == null && track == null)) return; + await player.resume(); + isPlaying = true; + notifyListeners(); + } + + Future pause() async { + if (!isPlaying || (playlist == null && track == null)) return; + await player.pause(); + isPlaying = false; + notifyListeners(); + } + + 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(); } - 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; + Future stop() async { + await player.stop(); + await player.release(); + isPlaying = false; + isShuffled = false; + playlist = null; + track = null; + currentDuration = Duration.zero; notifyListeners(); updatePersistence(clearNullEntries: true); } - /// sets the provided id matched track's uri\ - /// Doesn't notify listeners\ - /// @returns `bool` - `true` if succeed & `false` when failed - bool setTrackUriById(String id, String uri) { - if (_currentPlaylist == null) return false; - try { - int index = - _currentPlaylist!.tracks.indexWhere((element) => element.id == id); - if (index == -1) return false; - _currentPlaylist!.tracks[index].uri = uri; - updatePersistence(); - return _currentPlaylist!.tracks[index].uri == uri; - } catch (e) { - return false; - } + void destroy() { + stop(); + player.dispose(); } - void movePlaylistPositionBy(int pos) { - _logger.v("[Playlist Position Move] $pos"); - if (_currentTrack != null && _currentPlaylist != null) { - int index = _currentPlaylist!.trackIds.indexOf(_currentTrack!.id!) + pos; + // 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"); - 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(); + Video ytVideo; + final cachedTrack = await cache.get(track.id); + if (cachedTrack != null && cachedTrack.mode == matchAlgorithm.name) { + _logger.v( + "[Playing track from cache] youtubeId: ${cachedTrack.id} mode: ${cachedTrack.mode}", + ); + ytVideo = VideoFromCacheTrackExtension.fromCacheTrack(cachedTrack); + } else { + VideoSearchList videos = await youtube.search.search(queryString); + if (matchAlgorithm != SpotubeTrackMatchAlgorithm.youtube) { + List ratedRankedVideos = videos + .map((video) { + // the find should be lazy thus everything case insensitive + final ytTitle = video.title.toLowerCase(); + final bool hasTitle = ytTitle.contains(title); + final bool hasAllArtists = track.artists?.every( + (artist) => ytTitle.contains(artist.name!.toLowerCase()), + ) ?? + false; + final bool authorIsArtist = + track.artists?.first.name?.toLowerCase() == + video.author.toLowerCase(); + + final bool hasNoLiveInTitle = + !containsTextInBracket(ytTitle, "live"); + + int rate = 0; + for (final el in [ + hasTitle, + hasAllArtists, + if (matchAlgorithm == + SpotubeTrackMatchAlgorithm.authenticPopular) + authorIsArtist, + hasNoLiveInTitle, + !video.isLive, + ]) { + if (el) rate++; + } + // can't let pass any non title matching track + if (!hasTitle) rate = rate - 2; + return { + "video": video, + "points": rate, + "views": video.engagement.viewCount, + }; + }) + .toList() + .sortByProperties( + [false, false], + ["points", "views"], + ); + + ytVideo = ratedRankedVideos.first["video"] as Video; + } else { + ytVideo = videos.where((video) => !video.isLive).first; } } - } - Future startPlaying([Track? track]) async { - _logger.v("[Track Playing] ${track?.name} - ${track?.id}"); - try { - // the track is already playing so no need to change that - if (track != null && track.id == _currentTrack?.id) return; - track ??= _currentTrack; - if (track != null) { - Uri? parsedUri = Uri.tryParse(track.uri ?? ""); - final tag = MediaItem( - id: track.id!, - title: track.name!, - album: track.album?.name, - artist: artistsToString(track.artists ?? []), - artUri: Uri.parse(imageToUrlString(track.album?.images)), - ); - player.addItem(tag); - if (parsedUri != null && parsedUri.hasAbsolutePath) { - _currentAudioSource = AudioSource.uri(parsedUri); - await player.core - .setAudioSource( - _currentAudioSource!, - preload: true, - ) - .then((value) async { - _currentTrack = track; - notifyListeners(); - updatePersistence(); - }); - return; - } - final preferences = ref.read(userPreferencesProvider); - final spotubeTrack = await toSpotubeTrack( - youtube: youtube, - track: track, - format: preferences.ytSearchFormat, - matchAlgorithm: preferences.trackMatchAlgorithm, - audioQuality: preferences.audioQuality, - box: cacheTrackBox, - ); - if (setTrackUriById(track.id!, spotubeTrack.ytUri)) { - logger.v("[Track Direct Source] - ${spotubeTrack.ytUri}"); - _currentAudioSource = AudioSource.uri(Uri.parse(spotubeTrack.ytUri)); - await player.core - .setAudioSource( - _currentAudioSource!, - preload: true, - ) - .then((value) { - _currentTrack = spotubeTrack; - notifyListeners(); - updatePersistence(); - }); - } + final trackManifest = await youtube.videos.streams.getManifest(ytVideo.id); + + _logger.v( + "[YouTube Matched Track] ${ytVideo.title} | ${ytVideo.author} - ${ytVideo.url}", + ); + + final audioManifest = trackManifest.audioOnly.where((info) { + final isMp4a = info.codec.mimeType == "audio/mp4"; + if (Platform.isLinux) { + return !isMp4a; + } else if (Platform.isMacOS || Platform.isIOS) { + return isMp4a; + } else { + return true; } - } catch (e, stack) { - _logger.e("startPlaying", e, stack); + }); + + final ytUri = (audioQuality == AudioQuality.high + ? audioManifest.withHighestBitrate() + : audioManifest.sortByBitrate().last) + .url + .toString(); + + // only save when the track isn't available in the cache with same + // matchAlgorithm + if (cachedTrack == null || cachedTrack.mode != matchAlgorithm.name) { + await cache.put( + track.id!, CacheTrack.fromVideo(ytVideo, matchAlgorithm.name)); } + + return SpotubeTrack.fromTrack( + track: track, + ytTrack: ytVideo, + // Since Mac OS's & IOS's CodeAudio doesn't support WebMedia + // ('audio/webm', 'video/webm' & 'image/webp') thus using 'audio/mpeg' + // codec/mimetype for those Platforms + ytUri: ytUri, + ); } - void shuffle() { - if (currentPlaylist?.shuffle() == true) { - _shuffled = true; - notifyListeners(); - } + Future setPlaylistPosition(int position) async { + if (playlist == null) return; + await playPlaylist(playlist!, position); } - void unshuffle() { - if (currentPlaylist?.unshuffle() == true) { - _shuffled = false; - notifyListeners(); - } + Future seekForward() async { + if (playlist == null || track == null) return; + final int nextTrackIndex = + (playlist!.trackIds.indexOf(track!.id!) + 1).toInt(); + // checking if there's any track available forward + if (nextTrackIndex > (playlist?.tracks.length ?? 0) - 1) return; + await play(playlist!.tracks.elementAt(nextTrackIndex)); + } + + Future seekBackward() async { + if (playlist == null || track == null) return; + final int prevTrackIndex = + (playlist!.trackIds.indexOf(track!.id!) - 1).toInt(); + // checking if there's any track available behind + if (prevTrackIndex < 0) return; + await play(playlist!.tracks.elementAt(prevTrackIndex)); } @override - FutureOr loadFromLocal(Map map) { - if (map["currentPlaylist"] != null) { - _currentPlaylist = - CurrentPlaylist.fromJson(jsonDecode(map["currentPlaylist"])); + FutureOr loadFromLocal(Map map) async { + if (map["playlist"] != null) { + playlist = CurrentPlaylist.fromJson(jsonDecode(map["playlist"])); } - if (map["currentTrack"] != null) { - _currentTrack = Track.fromJson(jsonDecode(map["currentTrack"])); - startPlaying().then((_) { - Timer.periodic(const Duration(milliseconds: 100), (timer) { - if (player.core.playing) { - player.pause(); - timer.cancel(); - } - }); - }); + if (map["track"] != null) { + track = SpotubeTrack.fromJson(jsonDecode(map["track"])); } + volume = map["volume"] ?? volume; } @override FutureOr> toMap() { return { - "currentPlaylist": currentPlaylist != null - ? jsonEncode(currentPlaylist?.toJson()) - : null, - "currentTrack": - currentTrack != null ? jsonEncode(currentTrack?.toJson()) : null, + "playlist": playlist != null ? jsonEncode(playlist?.toJson()) : null, + "track": track != null ? jsonEncode(track?.toJson()) : null, + "volume": volume, }; } } final playbackProvider = ChangeNotifierProvider((ref) { - final player = AudioPlayerHandler(); final youtube = ref.watch(youtubeProvider); + final player = ref.watch(audioPlayerProvider); return Playback( player: player, youtube: youtube, diff --git a/lib/provider/SpotifyRequests.dart b/lib/provider/SpotifyRequests.dart index 76fe6f5f..aabe6d2e 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/provider/UserPreferences.dart b/lib/provider/UserPreferences.dart index 55780d7e..be069305 100644 --- a/lib/provider/UserPreferences.dart +++ b/lib/provider/UserPreferences.dart @@ -1,9 +1,7 @@ import 'dart:async'; -import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:hotkey_manager/hotkey_manager.dart'; import 'package:spotube/components/Settings/ColorSchemePickerDialog.dart'; import 'package:spotube/helpers/get-random-element.dart'; import 'package:spotube/helpers/search-youtube.dart'; @@ -18,9 +16,6 @@ class UserPreferences extends PersistedChangeNotifier { String recommendationMarket; bool saveTrackLyrics; String geniusAccessToken; - HotKey? nextTrackHotKey; - HotKey? prevTrackHotKey; - HotKey? playPauseHotKey; bool checkUpdate; SpotubeTrackMatchAlgorithm trackMatchAlgorithm; AudioQuality audioQuality; @@ -35,9 +30,6 @@ class UserPreferences extends PersistedChangeNotifier { this.saveTrackLyrics = false, this.accentColorScheme = Colors.green, this.backgroundColorScheme = Colors.grey, - this.nextTrackHotKey, - this.prevTrackHotKey, - this.playPauseHotKey, this.checkUpdate = true, this.trackMatchAlgorithm = SpotubeTrackMatchAlgorithm.authenticPopular, this.audioQuality = AudioQuality.high, @@ -67,24 +59,6 @@ class UserPreferences extends PersistedChangeNotifier { updatePersistence(); } - void setNextTrackHotKey(HotKey? value) { - nextTrackHotKey = value; - notifyListeners(); - updatePersistence(); - } - - void setPrevTrackHotKey(HotKey? value) { - prevTrackHotKey = value; - notifyListeners(); - updatePersistence(); - } - - void setPlayPauseHotKey(HotKey? value) { - playPauseHotKey = value; - notifyListeners(); - updatePersistence(); - } - void setYtSearchFormat(String format) { ytSearchFormat = format; notifyListeners(); @@ -128,15 +102,7 @@ class UserPreferences extends PersistedChangeNotifier { checkUpdate = map["checkUpdate"] ?? checkUpdate; geniusAccessToken = map["geniusAccessToken"] ?? getRandomElement(lyricsSecrets); - nextTrackHotKey = map["nextTrackHotKey"] != null - ? HotKey.fromJson(jsonDecode(map["nextTrackHotKey"])) - : null; - prevTrackHotKey = map["prevTrackHotKey"] != null - ? HotKey.fromJson(jsonDecode(map["prevTrackHotKey"])) - : null; - playPauseHotKey = map["playPauseHotKey"] != null - ? HotKey.fromJson(jsonDecode(map["playPauseHotKey"])) - : null; + ytSearchFormat = map["ytSearchFormat"] ?? ytSearchFormat; themeMode = ThemeMode.values[map["themeMode"] ?? 0]; backgroundColorScheme = colorsMap.values @@ -159,15 +125,6 @@ class UserPreferences extends PersistedChangeNotifier { "saveTrackLyrics": saveTrackLyrics, "recommendationMarket": recommendationMarket, "geniusAccessToken": geniusAccessToken, - "nextTrackHotKey": nextTrackHotKey != null - ? jsonEncode(nextTrackHotKey?.toJson()) - : null, - "prevTrackHotKey": prevTrackHotKey != null - ? jsonEncode(prevTrackHotKey?.toJson()) - : null, - "playPauseHotKey": playPauseHotKey != null - ? jsonEncode(playPauseHotKey?.toJson()) - : null, "ytSearchFormat": ytSearchFormat, "themeMode": themeMode.index, "backgroundColorScheme": backgroundColorScheme.value, diff --git a/lib/services/LinuxAudioService.dart b/lib/services/LinuxAudioService.dart new file mode 100644 index 00000000..ea620fef --- /dev/null +++ b/lib/services/LinuxAudioService.dart @@ -0,0 +1,697 @@ +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'; + +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]. + _MprisMediaPlayer2Player({ + required this.playback, + }) : 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 = playback.isPlaying + ? "Playing" + : playback.playlist == null + ? "Stopped" + : "Paused"; + return DBusMethodSuccessResponse([DBusString(status)]); + } + + /// Gets value of property org.mpris.MediaPlayer2.Player.LoopStatus + Future getLoopStatus() async { + return DBusMethodSuccessResponse([const DBusString("Playlist")]); + } + + /// Sets property org.mpris.MediaPlayer2.Player.LoopStatus + Future setLoopStatus(String value) async { + return DBusMethodErrorResponse.failed( + 'Set org.mpris.MediaPlayer2.Player.LoopStatus not implemented'); + } + + /// Gets value of property org.mpris.MediaPlayer2.Player.Rate + Future getRate() async { + return DBusMethodSuccessResponse([DBusDouble(1)]); + } + + /// Sets property org.mpris.MediaPlayer2.Player.Rate + Future setRate(double value) async { + return DBusMethodSuccessResponse(); + } + + /// Gets value of property org.mpris.MediaPlayer2.Player.Shuffle + Future getShuffle() async { + return DBusMethodSuccessResponse([DBusBoolean(playback.isShuffled)]); + } + + /// Sets property org.mpris.MediaPlayer2.Player.Shuffle + Future setShuffle(bool value) async { + playback.toggleShuffle(); + return DBusMethodSuccessResponse(); + } + + /// Gets value of property org.mpris.MediaPlayer2.Player.Metadata + Future getMetadata() async { + try { + if (playback.track == null) { + return DBusMethodSuccessResponse([DBusDict.stringVariant({})]); + } + final id = (playback.playlist != null + ? playback.playlist!.tracks.indexWhere( + (track) => playback.track!.id == track.id!, + ) + : 0) + .abs(); + + return DBusMethodSuccessResponse([ + DBusDict.stringVariant({ + "mpris:trackid": DBusString("${path.value}/Track/$id"), + "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.track!.artists!.map((artist) => artist.name!), + ), + "xesam:title": DBusString(playback.track!.name!), + "xesam:url": DBusString( + playback.track is SpotubeTrack + ? (playback.track as SpotubeTrack).ytUri + : playback.track!.previewUrl!, + ), + "xesam:genre": const DBusString("Unknown"), + }), + ]); + } catch (e) { + print("[DBUS ERROR] $e"); + rethrow; + } + } + + /// Gets value of property org.mpris.MediaPlayer2.Player.Volume + Future getVolume() async { + return DBusMethodSuccessResponse([DBusDouble(playback.volume)]); + } + + /// Sets property org.mpris.MediaPlayer2.Player.Volume + Future setVolume(double value) async { + playback.setVolume(value); + return DBusMethodSuccessResponse(); + } + + /// Gets value of property org.mpris.MediaPlayer2.Player.Position + Future getPosition() async { + return DBusMethodSuccessResponse([ + DBusInt64((await playback.player.getDuration())?.inMicroseconds ?? 0), + ]); + } + + /// Gets value of property org.mpris.MediaPlayer2.Player.MinimumRate + Future getMinimumRate() async { + return DBusMethodSuccessResponse([const DBusDouble(1)]); + } + + /// Gets value of property org.mpris.MediaPlayer2.Player.MaximumRate + Future getMaximumRate() async { + return DBusMethodSuccessResponse([const DBusDouble(1)]); + } + + /// Gets value of property org.mpris.MediaPlayer2.Player.CanGoNext + Future getCanGoNext() async { + return DBusMethodSuccessResponse([ + DBusBoolean( + playback.playlist?.tracks.isNotEmpty == true, + ) + ]); + } + + /// Gets value of property org.mpris.MediaPlayer2.Player.CanGoPrevious + Future getCanGoPrevious() async { + return DBusMethodSuccessResponse([ + DBusBoolean( + playback.playlist?.tracks.isNotEmpty == true, + ) + ]); + } + + /// Gets value of property org.mpris.MediaPlayer2.Player.CanPlay + Future getCanPlay() async { + return DBusMethodSuccessResponse([const DBusBoolean(true)]); + } + + /// Gets value of property org.mpris.MediaPlayer2.Player.CanPause + Future getCanPause() async { + return DBusMethodSuccessResponse([const DBusBoolean(true)]); + } + + /// Gets value of property org.mpris.MediaPlayer2.Player.CanSeek + Future getCanSeek() async { + return DBusMethodSuccessResponse([const DBusBoolean(true)]); + } + + /// Gets value of property org.mpris.MediaPlayer2.Player.CanControl + Future getCanControl() async { + return DBusMethodSuccessResponse([const DBusBoolean(true)]); + } + + /// Implementation of org.mpris.MediaPlayer2.Player.Next() + Future doNext() async { + playback.seekForward(); + return DBusMethodSuccessResponse(); + } + + /// Implementation of org.mpris.MediaPlayer2.Player.Previous() + Future doPrevious() async { + playback.seekBackward(); + return DBusMethodSuccessResponse(); + } + + /// Implementation of org.mpris.MediaPlayer2.Player.Pause() + Future doPause() async { + playback.pause(); + return DBusMethodSuccessResponse(); + } + + /// Implementation of org.mpris.MediaPlayer2.Player.PlayPause() + Future doPlayPause() async { + playback.isPlaying ? playback.pause() : playback.resume(); + return DBusMethodSuccessResponse(); + } + + /// Implementation of org.mpris.MediaPlayer2.Player.Stop() + Future doStop() async { + playback.stop(); + return DBusMethodSuccessResponse(); + } + + /// Implementation of org.mpris.MediaPlayer2.Player.Play() + Future doPlay() async { + playback.resume(); + return DBusMethodSuccessResponse(); + } + + /// Implementation of org.mpris.MediaPlayer2.Player.Seek() + Future doSeek(int offset) async { + playback.seekPosition(Duration(microseconds: offset)); + return DBusMethodSuccessResponse(); + } + + /// Implementation of org.mpris.MediaPlayer2.Player.SetPosition() + Future doSetPosition(String TrackId, int Position) async { + return DBusMethodSuccessResponse(); + } + + /// Implementation of org.mpris.MediaPlayer2.Player.OpenUri() + Future doOpenUri(String Uri) async { + return DBusMethodSuccessResponse(); + } + + /// Emits signal org.mpris.MediaPlayer2.Player.Seeked + Future emitSeeked(int Position) async { + await emitSignal( + 'org.mpris.MediaPlayer2.Player', 'Seeked', [DBusInt64(Position)]); + } + + @override + List introspect() { + return [ + DBusIntrospectInterface('org.mpris.MediaPlayer2.Player', methods: [ + DBusIntrospectMethod('Next'), + DBusIntrospectMethod('Previous'), + DBusIntrospectMethod('Pause'), + DBusIntrospectMethod('PlayPause'), + DBusIntrospectMethod('Stop'), + DBusIntrospectMethod('Play'), + DBusIntrospectMethod('Seek', args: [ + DBusIntrospectArgument(DBusSignature('x'), DBusArgumentDirection.in_, + name: 'Offset') + ]), + DBusIntrospectMethod('SetPosition', args: [ + DBusIntrospectArgument(DBusSignature('o'), DBusArgumentDirection.in_, + name: 'TrackId'), + DBusIntrospectArgument(DBusSignature('x'), DBusArgumentDirection.in_, + name: 'Position') + ]), + DBusIntrospectMethod('OpenUri', args: [ + DBusIntrospectArgument(DBusSignature('s'), DBusArgumentDirection.in_, + name: 'Uri') + ]) + ], signals: [ + DBusIntrospectSignal('Seeked', args: [ + DBusIntrospectArgument(DBusSignature('x'), DBusArgumentDirection.out, + name: 'Position') + ]) + ], properties: [ + DBusIntrospectProperty('PlaybackStatus', DBusSignature('s'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('LoopStatus', DBusSignature('s'), + access: DBusPropertyAccess.readwrite), + DBusIntrospectProperty('Rate', DBusSignature('d'), + access: DBusPropertyAccess.readwrite), + DBusIntrospectProperty('Shuffle', DBusSignature('b'), + access: DBusPropertyAccess.readwrite), + DBusIntrospectProperty('Metadata', DBusSignature('a{sv}'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('Volume', DBusSignature('d'), + access: DBusPropertyAccess.readwrite), + DBusIntrospectProperty('Position', DBusSignature('x'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('MinimumRate', DBusSignature('d'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('MaximumRate', DBusSignature('d'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('CanGoNext', DBusSignature('b'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('CanGoPrevious', DBusSignature('b'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('CanPlay', DBusSignature('b'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('CanPause', DBusSignature('b'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('CanSeek', DBusSignature('b'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('CanControl', DBusSignature('b'), + access: DBusPropertyAccess.read) + ]) + ]; + } + + @override + Future handleMethodCall(DBusMethodCall methodCall) async { + if (methodCall.interface == 'org.mpris.MediaPlayer2.Player') { + if (methodCall.name == 'Next') { + if (methodCall.values.isNotEmpty) { + return DBusMethodErrorResponse.invalidArgs(); + } + return doNext(); + } else if (methodCall.name == 'Previous') { + if (methodCall.values.isNotEmpty) { + return DBusMethodErrorResponse.invalidArgs(); + } + return doPrevious(); + } else if (methodCall.name == 'Pause') { + if (methodCall.values.isNotEmpty) { + return DBusMethodErrorResponse.invalidArgs(); + } + return doPause(); + } else if (methodCall.name == 'PlayPause') { + if (methodCall.values.isNotEmpty) { + return DBusMethodErrorResponse.invalidArgs(); + } + return doPlayPause(); + } else if (methodCall.name == 'Stop') { + if (methodCall.values.isNotEmpty) { + return DBusMethodErrorResponse.invalidArgs(); + } + return doStop(); + } else if (methodCall.name == 'Play') { + if (methodCall.values.isNotEmpty) { + return DBusMethodErrorResponse.invalidArgs(); + } + return doPlay(); + } else if (methodCall.name == 'Seek') { + if (methodCall.signature != DBusSignature('x')) { + return DBusMethodErrorResponse.invalidArgs(); + } + return doSeek((methodCall.values[0] as DBusInt64).value); + } else if (methodCall.name == 'SetPosition') { + if (methodCall.signature != DBusSignature('ox')) { + return DBusMethodErrorResponse.invalidArgs(); + } + return doSetPosition((methodCall.values[0] as DBusObjectPath).value, + (methodCall.values[1] as DBusInt64).value); + } else if (methodCall.name == 'OpenUri') { + if (methodCall.signature != DBusSignature('s')) { + return DBusMethodErrorResponse.invalidArgs(); + } + return doOpenUri((methodCall.values[0] as DBusString).value); + } else { + return DBusMethodErrorResponse.unknownMethod(); + } + } else { + return DBusMethodErrorResponse.unknownInterface(); + } + } + + @override + Future getProperty(String interface, String name) async { + if (interface == 'org.mpris.MediaPlayer2.Player') { + if (name == 'PlaybackStatus') { + return getPlaybackStatus(); + } else if (name == 'LoopStatus') { + return getLoopStatus(); + } else if (name == 'Rate') { + return getRate(); + } else if (name == 'Shuffle') { + return getShuffle(); + } else if (name == 'Metadata') { + return getMetadata(); + } else if (name == 'Volume') { + return getVolume(); + } else if (name == 'Position') { + return getPosition(); + } else if (name == 'MinimumRate') { + return getMinimumRate(); + } else if (name == 'MaximumRate') { + return getMaximumRate(); + } else if (name == 'CanGoNext') { + return getCanGoNext(); + } else if (name == 'CanGoPrevious') { + return getCanGoPrevious(); + } else if (name == 'CanPlay') { + return getCanPlay(); + } else if (name == 'CanPause') { + return getCanPause(); + } else if (name == 'CanSeek') { + return getCanSeek(); + } else if (name == 'CanControl') { + return getCanControl(); + } else { + return DBusMethodErrorResponse.unknownProperty(); + } + } else { + return DBusMethodErrorResponse.unknownProperty(); + } + } + + @override + Future setProperty( + String interface, String name, DBusValue value) async { + if (interface == 'org.mpris.MediaPlayer2.Player') { + if (name == 'PlaybackStatus') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'LoopStatus') { + if (value.signature != DBusSignature('s')) { + return DBusMethodErrorResponse.invalidArgs(); + } + return setLoopStatus((value as DBusString).value); + } else if (name == 'Rate') { + if (value.signature != DBusSignature('d')) { + return DBusMethodErrorResponse.invalidArgs(); + } + return setRate((value as DBusDouble).value); + } else if (name == 'Shuffle') { + if (value.signature != DBusSignature('b')) { + return DBusMethodErrorResponse.invalidArgs(); + } + return setShuffle((value as DBusBoolean).value); + } else if (name == 'Metadata') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'Volume') { + if (value.signature != DBusSignature('d')) { + return DBusMethodErrorResponse.invalidArgs(); + } + return setVolume((value as DBusDouble).value); + } else if (name == 'Position') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'MinimumRate') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'MaximumRate') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'CanGoNext') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'CanGoPrevious') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'CanPlay') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'CanPause') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'CanSeek') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'CanControl') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else { + return DBusMethodErrorResponse.unknownProperty(); + } + } else { + return DBusMethodErrorResponse.unknownProperty(); + } + } + + @override + Future getAllProperties(String interface) async { + var properties = {}; + if (interface == 'org.mpris.MediaPlayer2.Player') { + properties['PlaybackStatus'] = + (await getPlaybackStatus()).returnValues[0]; + properties['LoopStatus'] = (await getLoopStatus()).returnValues[0]; + properties['Rate'] = (await getRate()).returnValues[0]; + properties['Shuffle'] = (await getShuffle()).returnValues[0]; + properties['Metadata'] = (await getMetadata()).returnValues[0]; + properties['Volume'] = (await getVolume()).returnValues[0]; + properties['Position'] = (await getPosition()).returnValues[0]; + properties['MinimumRate'] = (await getMinimumRate()).returnValues[0]; + properties['MaximumRate'] = (await getMaximumRate()).returnValues[0]; + properties['CanGoNext'] = (await getCanGoNext()).returnValues[0]; + properties['CanGoPrevious'] = (await getCanGoPrevious()).returnValues[0]; + properties['CanPlay'] = (await getCanPlay()).returnValues[0]; + properties['CanPause'] = (await getCanPause()).returnValues[0]; + properties['CanSeek'] = (await getCanSeek()).returnValues[0]; + properties['CanControl'] = (await getCanControl()).returnValues[0]; + } + return DBusMethodSuccessResponse([DBusDict.stringVariant(properties)]); + } +} + +class LinuxAudioService { + _MprisMediaPlayer2 mp2; + _MprisMediaPlayer2Player player; + + LinuxAudioService(Playback playback) + : mp2 = _MprisMediaPlayer2(), + player = _MprisMediaPlayer2Player(playback: playback); + + void dispose() { + mp2.dispose(); + player.dispose(); + } +} diff --git a/lib/services/MobileAudioService.dart b/lib/services/MobileAudioService.dart new file mode 100644 index 00000000..d6f97f10 --- /dev/null +++ b/lib/services/MobileAudioService.dart @@ -0,0 +1,84 @@ +import 'dart:async'; + +import 'package:audio_service/audio_service.dart'; +import 'package:audioplayers/audioplayers.dart'; +import 'package:spotube/provider/Playback.dart'; + +class MobileAudioService extends BaseAudioHandler { + final Playback playback; + + MobileAudioService(this.playback) { + final _player = playback.player; + _player.onPlayerStateChanged.listen((state) async { + if (state != PlayerState.completed) { + playbackState.add(await _transformEvent()); + } + }); + + _player.onPlayerComplete.listen((_) { + if (playback.playlist == null && playback.track == null) { + playbackState.add( + PlaybackState( + processingState: AudioProcessingState.completed, + ), + ); + } + }); + } + + void addItem(MediaItem item) { + mediaItem.add(item); + } + + @override + Future play() => playback.resume(); + + @override + Future pause() => playback.pause(); + + @override + Future seek(Duration position) => playback.seekPosition(position); + + @override + Future stop() => playback.stop(); + + @override + Future skipToNext() async { + playback.seekForward(); + await super.skipToNext(); + } + + @override + Future skipToPrevious() async { + playback.seekBackward(); + await super.skipToPrevious(); + } + + @override + Future onTaskRemoved() { + playback.destroy(); + return super.onTaskRemoved(); + } + + Future _transformEvent() async { + return PlaybackState( + controls: [ + MediaControl.skipToPrevious, + playback.player.state == PlayerState.playing + ? MediaControl.pause + : MediaControl.play, + MediaControl.skipToNext, + MediaControl.stop, + ], + androidCompactActionIndices: const [0, 1, 2], + playing: playback.player.state == PlayerState.playing, + updatePosition: + (await playback.player.getCurrentPosition()) ?? Duration.zero, + processingState: playback.player.state == PlayerState.paused + ? AudioProcessingState.buffering + : playback.player.state == PlayerState.playing + ? AudioProcessingState.ready + : AudioProcessingState.idle, + ); + } +} diff --git a/lib/utils/AudioPlayerHandler.dart b/lib/utils/AudioPlayerHandler.dart deleted file mode 100644 index bdab99a1..00000000 --- a/lib/utils/AudioPlayerHandler.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'dart:async'; - -import 'package:audio_service/audio_service.dart'; -import 'package:just_audio/just_audio.dart'; - -/// An [AudioHandler] for playing a single item. -class AudioPlayerHandler extends BaseAudioHandler { - final _player = AudioPlayer(); - - FutureOr Function()? onNextRequest; - FutureOr Function()? onPreviousRequest; - - /// Initialise our audio handler. - AudioPlayerHandler() { - // So that our clients (the Flutter UI and the system notification) know - // what state to display, here we set up our audio handler to broadcast all - // playback state changes as they happen via playbackState... - _player.playbackEventStream.map(_transformEvent).pipe(playbackState); - } - - AudioPlayer get core => _player; - - void addItem(MediaItem item) { - mediaItem.add(item); - } - - // In this simple example, we handle only 4 actions: play, pause, seek and - // stop. Any button press from the Flutter UI, notification, lock screen or - // headset will be routed through to these 4 methods so that you can handle - // your audio playback logic in one place. - - @override - Future play() => _player.play(); - - @override - Future pause() => _player.pause(); - - @override - Future seek(Duration position) => _player.seek(position); - - @override - Future stop() => _player.stop(); - - @override - Future skipToNext() async { - await onNextRequest?.call(); - await super.skipToNext(); - } - - @override - Future skipToPrevious() async { - await onPreviousRequest?.call(); - await super.skipToPrevious(); - } - - /// Transform a just_audio event into an audio_service state. - /// - /// This method is used from the constructor. Every event received from the - /// just_audio player will be transformed into an audio_service state so that - /// it can be broadcast to audio_service clients. - PlaybackState _transformEvent(PlaybackEvent event) { - return PlaybackState( - controls: [ - MediaControl.skipToPrevious, - if (_player.playing) MediaControl.pause else MediaControl.play, - MediaControl.skipToNext, - ], - androidCompactActionIndices: const [0, 1, 2], - processingState: const { - ProcessingState.idle: AudioProcessingState.idle, - ProcessingState.loading: AudioProcessingState.loading, - ProcessingState.buffering: AudioProcessingState.buffering, - ProcessingState.ready: AudioProcessingState.ready, - ProcessingState.completed: AudioProcessingState.completed, - }[_player.processingState]!, - playing: _player.playing, - updatePosition: _player.position, - bufferedPosition: _player.bufferedPosition, - speed: _player.speed, - queueIndex: event.currentIndex, - ); - } -} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index f914b16d..01b8e0f7 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,21 +6,17 @@ #include "generated_plugin_registrant.h" +#include #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) hotkey_manager_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "HotkeyManagerPlugin"); - hotkey_manager_plugin_register_with_registrar(hotkey_manager_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 5553b585..9aebc645 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,9 +3,8 @@ # list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_linux bitsdojo_window_linux - hotkey_manager - libwinmedia url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index fc68c0c2..3e375fb3 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,9 +7,8 @@ import Foundation import audio_service import audio_session +import audioplayers_darwin import bitsdojo_window_macos -import hotkey_manager -import just_audio import package_info_plus_macos import path_provider_macos import shared_preferences_macos @@ -19,9 +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")) - HotkeyManagerPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerPlugin")) - 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 f0519b87..fd237e39 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: @@ -267,6 +316,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.2.3" + dbus: + dependency: "direct main" + description: + name: dbus + url: "https://pub.dartlang.org" + source: hosted + version: "0.7.3" fading_edge_scrollview: dependency: transitive description: @@ -429,13 +485,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.4" - hotkey_manager: - dependency: "direct main" - description: - name: hotkey_manager - url: "https://pub.dartlang.org" - source: hosted - version: "0.1.7" html: 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 730394a8..4c4b00ab 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,9 +42,6 @@ dependencies: url_launcher: ^6.0.17 youtube_explode_dart: ^1.10.8 bitsdojo_window: ^0.1.2 - hotkey_manager: ^0.1.6 - 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 +61,8 @@ dependencies: skeleton_text: ^3.0.0 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 e52f4036..3e689c38 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,19 +6,16 @@ #include "generated_plugin_registrant.h" +#include #include -#include -#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + AudioplayersWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); BitsdojoWindowPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("BitsdojoWindowPlugin")); - HotkeyManagerPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("HotkeyManagerPlugin")); - LibwinmediaPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("LibwinmediaPlugin")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 2019ee88..c8e970a8 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,9 +3,8 @@ # list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_windows bitsdojo_window_windows - hotkey_manager - libwinmedia permission_handler_windows url_launcher_windows )