mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 07:55:18 +00:00
Merge branch 'master' into build
This commit is contained in:
commit
c34ff50345
89
.github/workflows/flutter-build.yml
vendored
89
.github/workflows/flutter-build.yml
vendored
@ -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
|
|
134
.github/workflows/spotube-nightly.yml
vendored
Normal file
134
.github/workflows/spotube-nightly.yml
vendored
Normal file
@ -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}}%|<release version="${{ steps.tag.outputs.tag }}" date="${{ steps.date.outputs.date }}" />|' 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
|
@ -1,4 +1,4 @@
|
|||||||
name: Spotube Build & Release
|
name: Spotube Release
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
types:
|
types:
|
||||||
@ -36,9 +36,8 @@ jobs:
|
|||||||
flutter pub get
|
flutter pub get
|
||||||
dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}'
|
dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}'
|
||||||
dart pub global activate melos
|
dart pub global activate melos
|
||||||
cd build/flutter_distributor && melos bootstrap && cd ../..
|
|
||||||
make innoinstall
|
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
|
# Create Chocolatey Package
|
||||||
# setting the sha256 hash for new bundle
|
# setting the sha256 hash for new bundle
|
||||||
@ -93,11 +92,6 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/checkout@v3
|
|
||||||
with:
|
|
||||||
repository: KRTirtho/flutter_distributor
|
|
||||||
ref: deb-implementation
|
|
||||||
path: build/flutter_distributor
|
|
||||||
- name: Get latest tag
|
- name: Get latest tag
|
||||||
id: tag
|
id: tag
|
||||||
uses: dawidd6/action-get-tag@v1
|
uses: dawidd6/action-get-tag@v1
|
||||||
@ -111,7 +105,7 @@ jobs:
|
|||||||
run: echo "::set-output name=date::$(date +'%Y-%m-%d')"
|
run: echo "::set-output name=date::$(date +'%Y-%m-%d')"
|
||||||
- run: |
|
- run: |
|
||||||
sudo apt-get update -y
|
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: |
|
- 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
|
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
|
chmod +x appimage-builder-x86_64.AppImage
|
||||||
@ -124,9 +118,8 @@ jobs:
|
|||||||
flutter config --enable-linux-desktop
|
flutter config --enable-linux-desktop
|
||||||
flutter pub get
|
flutter pub get
|
||||||
dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}'
|
dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}'
|
||||||
dart pub global activate melos
|
dart pub global activate flutter_distributor
|
||||||
cd build/flutter_distributor && melos bootstrap && cd ../..
|
flutter_distributor package --platform=linux --targets=deb,appimage --skip-clean
|
||||||
dart build/flutter_distributor/packages/flutter_distributor/bin/main.dart package --platform=linux --targets=deb,appimage --skip-clean
|
|
||||||
make tar
|
make tar
|
||||||
- run: |
|
- run: |
|
||||||
mv build/Spotube-linux-x86_64.tar.xz dist/
|
mv build/Spotube-linux-x86_64.tar.xz dist/
|
||||||
@ -141,11 +134,6 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/checkout@v3
|
|
||||||
with:
|
|
||||||
repository: KRTirtho/flutter_distributor
|
|
||||||
ref: deb-implementation
|
|
||||||
path: build/flutter_distributor
|
|
||||||
- name: Get latest tag
|
- name: Get latest tag
|
||||||
id: tag
|
id: tag
|
||||||
uses: dawidd6/action-get-tag@v1
|
uses: dawidd6/action-get-tag@v1
|
||||||
@ -156,13 +144,12 @@ jobs:
|
|||||||
cache: true
|
cache: true
|
||||||
- run: |
|
- run: |
|
||||||
sudo apt-get update -y
|
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: |
|
- run: |
|
||||||
flutter pub get
|
flutter pub get
|
||||||
dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}'
|
dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}'
|
||||||
dart pub global activate melos
|
dart pub global activate flutter_distributor
|
||||||
cd build/flutter_distributor && melos bootstrap && cd ../..
|
flutter_distributor package --platform=android --targets=apk --skip-clean
|
||||||
dart build/flutter_distributor/packages/flutter_distributor/bin/main.dart package --platform=android --targets=apk --skip-clean
|
|
||||||
- run: |
|
- run: |
|
||||||
mv dist/**/spotube-*-android.apk dist/Spotube-android-all-arch.apk
|
mv dist/**/spotube-*-android.apk dist/Spotube-android-all-arch.apk
|
||||||
- uses: actions/upload-artifact@v3
|
- uses: actions/upload-artifact@v3
|
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@ -1,3 +1,6 @@
|
|||||||
{
|
{
|
||||||
"cmake.configureOnOpen": false
|
"cmake.configureOnOpen": false,
|
||||||
|
"cSpell.words": [
|
||||||
|
"Mpris"
|
||||||
|
]
|
||||||
}
|
}
|
@ -12,10 +12,14 @@ All types of contributions are encouraged and valued. See the [Table of Contents
|
|||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Contributing to Spotube](#contributing-to-spotube)
|
||||||
|
- [Table of Contents](#table-of-contents)
|
||||||
- [Code of Conduct](#code-of-conduct)
|
- [Code of Conduct](#code-of-conduct)
|
||||||
- [I Have a Question](#i-have-a-question)
|
- [I Have a Question](#i-have-a-question)
|
||||||
- [I Want To Contribute](#i-want-to-contribute)
|
- [I Want To Contribute](#i-want-to-contribute)
|
||||||
- [Reporting Bugs](#reporting-bugs)
|
- [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)
|
- [Suggesting Enhancements](#suggesting-enhancements)
|
||||||
- [Your First Code Contribution](#your-first-code-contribution)
|
- [Your First Code Contribution](#your-first-code-contribution)
|
||||||
|
|
||||||
@ -109,11 +113,12 @@ Enhancement suggestions are tracked as [GitHub issues](https://github.com/KRTirt
|
|||||||
|
|
||||||
### Your First Code Contribution
|
### Your First Code Contribution
|
||||||
|
|
||||||
|
<!-- Download -->
|
||||||
Do the following:
|
Do the following:
|
||||||
- Download the latest Flutter SDK (>=2.15.1) & enable desktop support
|
- Download the latest Flutter SDK (>=2.15.1) & enable desktop support
|
||||||
- Install Development dependencies in linux
|
- Install Development dependencies in linux
|
||||||
- `libwebkit2gtk-4.0-dev`, `libkeybinder-3.0-0` & `libkeybinder-3.0-dev` (for Debian/Ubuntu)
|
- `libgstreamer1.0-dev` & `libgstreamer-plugins-base1.0-dev` (for Debian/Ubuntu)
|
||||||
- `webkit2gtk` & `libkeybinder3` (for Arch/Manjaro)
|
- `gstreamer`, `gst-libav`, `gst-plugins-base` & `gst-plugins-good` (for Arch/Manjaro)
|
||||||
- Clone the Repo & Run `flutter pub get` in the Terminal
|
- 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:
|
- Create a `secrets.json` in root of the project. The structure should be similar to the following example:
|
||||||
```jsoc name="secrets.json"
|
```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
|
> 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
|
- Finally run these following commands in the root of the project to start the Spotube Locally
|
||||||
```bash
|
```bash
|
||||||
$ dart create-secrets.dart --local
|
$ dart bin/create-secrets.dart --local
|
||||||
$ flutter run -d <window|macos|linux|(<android-device-id>)>
|
$ flutter run -d <window|macos|linux|(<android-device-id>)>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -58,7 +58,7 @@ I'm always releasing newer versions of binary of the software each 2-3 month wit
|
|||||||
|
|
||||||
| Platform | Package/Installation Method |
|
| Platform | Package/Installation Method |
|
||||||
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Android | [<img width='240' alt='Android Download' src='https://www.remcsteuben.com/sites/default/files/images/apkdaddy%20download.png'/>][android-dlink] |
|
| Android | [<img width='240' alt='Android Download' src='https://www.remcsteuben.com/sites/default/files/images/apkdaddy%20download.png'/>][android-dlink]<br/>[<img width='240' alt='Android Download' src='https://user-images.githubusercontent.com/61944859/174589876-bace24c0-b3fd-4c4a-bdb4-6fa82b5853ec.png'/>][fdroid-dlink]|
|
||||||
| Debian/Ubuntu | [<img width='240' alt='Linux Debian/Ubuntu Download' src='https://user-images.githubusercontent.com/61944859/169097994-e92aff78-fd75-4c93-b6e4-f072a4b5a7ed.png'/>][deb-dlink] <br/> Then run: `sudo apt install Spotube-linux-x86_64.deb` |
|
| Debian/Ubuntu | [<img width='240' alt='Linux Debian/Ubuntu Download' src='https://user-images.githubusercontent.com/61944859/169097994-e92aff78-fd75-4c93-b6e4-f072a4b5a7ed.png'/>][deb-dlink] <br/> Then run: `sudo apt install Spotube-linux-x86_64.deb` |
|
||||||
| Flatpak | `flatpak install com.github.KRTirtho.Spotube` <br/> <a href='https://flathub.org/apps/details/com.github.KRTirtho.Spotube'><img width='240' alt='Download on Flathub' src='https://flathub.org/assets/badges/flathub-badge-en.png'/></a> |
|
| Flatpak | `flatpak install com.github.KRTirtho.Spotube` <br/> <a href='https://flathub.org/apps/details/com.github.KRTirtho.Spotube'><img width='240' alt='Download on Flathub' src='https://flathub.org/assets/badges/flathub-badge-en.png'/></a> |
|
||||||
| Arch/Manjaro | pamac: `pamac install spotube-bin` <br/> yay: `yay -Sy spotube-bin` |
|
| Arch/Manjaro | pamac: `pamac install spotube-bin` <br/> 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
|
- [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
|
- [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
|
- [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
|
- [audioplayers](https://github.com/bluefireteam/audioplayers) - A Flutter plugin to play multiple audio files simultaneously (Android/iOS)
|
||||||
- [libwinmedia](https://github.com/harmonoid/libwinmedia) - A cross-platform media playback library for C/C++ with good number of features (only Windows & Linux)
|
|
||||||
- [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
|
- [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
|
- [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
|
- [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
|
[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
|
[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
|
[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
|
[wiki-installation-instructions]: https://github.com/KRTirtho/spotube/wiki/Installation-Instrcutions
|
||||||
|
@ -18,15 +18,15 @@ class AlbumCard extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
Playback playback = ref.watch(playbackProvider);
|
Playback playback = ref.watch(playbackProvider);
|
||||||
bool isPlaylistPlaying = playback.currentPlaylist != null &&
|
bool isPlaylistPlaying =
|
||||||
playback.currentPlaylist!.id == album.id;
|
playback.playlist != null && playback.playlist!.id == album.id;
|
||||||
final int marginH =
|
final int marginH =
|
||||||
useBreakpointValue(sm: 10, md: 15, lg: 20, xl: 20, xxl: 20);
|
useBreakpointValue(sm: 10, md: 15, lg: 20, xl: 20, xxl: 20);
|
||||||
return PlaybuttonCard(
|
return PlaybuttonCard(
|
||||||
imageUrl: imageToUrlString(album.images),
|
imageUrl: imageToUrlString(album.images),
|
||||||
margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()),
|
margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()),
|
||||||
isPlaying: playback.currentPlaylist?.id != null &&
|
isPlaying:
|
||||||
playback.currentPlaylist?.id == album.id,
|
playback.playlist?.id != null && playback.playlist?.id == album.id,
|
||||||
title: album.name!,
|
title: album.name!,
|
||||||
description:
|
description:
|
||||||
"Album • ${artistsToString<ArtistSimple>(album.artists ?? [])}",
|
"Album • ${artistsToString<ArtistSimple>(album.artists ?? [])}",
|
||||||
@ -41,14 +41,12 @@ class AlbumCard extends HookConsumerWidget {
|
|||||||
.toList();
|
.toList();
|
||||||
if (tracks.isEmpty) return;
|
if (tracks.isEmpty) return;
|
||||||
|
|
||||||
playback.setCurrentPlaylist = CurrentPlaylist(
|
await playback.playPlaylist(CurrentPlaylist(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
id: album.id!,
|
id: album.id!,
|
||||||
name: album.name!,
|
name: album.name!,
|
||||||
thumbnail: album.images!.first.url!,
|
thumbnail: album.images!.first.url!,
|
||||||
);
|
));
|
||||||
playback.setCurrentTrack = tracks.first;
|
|
||||||
await playback.startPlaying();
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/components/Shared/HeartButton.dart';
|
import 'package:spotube/components/Shared/HeartButton.dart';
|
||||||
@ -18,24 +17,25 @@ class AlbumView extends HookConsumerWidget {
|
|||||||
final AlbumSimple album;
|
final AlbumSimple album;
|
||||||
const AlbumView(this.album, {Key? key}) : super(key: key);
|
const AlbumView(this.album, {Key? key}) : super(key: key);
|
||||||
|
|
||||||
playPlaylist(Playback playback, List<Track> tracks,
|
Future<void> playPlaylist(Playback playback, List<Track> tracks,
|
||||||
{Track? currentTrack}) async {
|
{Track? currentTrack}) async {
|
||||||
currentTrack ??= tracks.first;
|
currentTrack ??= tracks.first;
|
||||||
var isPlaylistPlaying = playback.currentPlaylist?.id == album.id;
|
final isPlaylistPlaying = playback.playlist?.id == album.id;
|
||||||
if (!isPlaylistPlaying) {
|
if (!isPlaylistPlaying) {
|
||||||
playback.setCurrentPlaylist = CurrentPlaylist(
|
await playback.playPlaylist(
|
||||||
|
CurrentPlaylist(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
id: album.id!,
|
id: album.id!,
|
||||||
name: album.name!,
|
name: album.name!,
|
||||||
thumbnail: imageToUrlString(album.images),
|
thumbnail: imageToUrlString(album.images),
|
||||||
|
),
|
||||||
|
tracks.indexWhere((s) => s.id == currentTrack?.id),
|
||||||
);
|
);
|
||||||
playback.setCurrentTrack = currentTrack;
|
|
||||||
} else if (isPlaylistPlaying &&
|
} else if (isPlaylistPlaying &&
|
||||||
currentTrack.id != null &&
|
currentTrack.id != null &&
|
||||||
currentTrack.id != playback.currentTrack?.id) {
|
currentTrack.id != playback.track?.id) {
|
||||||
playback.setCurrentTrack = currentTrack;
|
await playback.play(currentTrack);
|
||||||
}
|
}
|
||||||
await playback.startPlaying();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -54,8 +54,8 @@ class AlbumView extends HookConsumerWidget {
|
|||||||
|
|
||||||
return TrackCollectionView(
|
return TrackCollectionView(
|
||||||
id: album.id!,
|
id: album.id!,
|
||||||
isPlaying: playback.currentPlaylist?.id != null &&
|
isPlaying:
|
||||||
playback.currentPlaylist?.id == album.id,
|
playback.playlist?.id != null && playback.playlist?.id == album.id,
|
||||||
title: album.name!,
|
title: album.name!,
|
||||||
titleImage: albumArt,
|
titleImage: albumArt,
|
||||||
tracksSnapshot: tracksSnapshot,
|
tracksSnapshot: tracksSnapshot,
|
||||||
|
@ -183,24 +183,25 @@ class ArtistProfile extends HookConsumerWidget {
|
|||||||
topTracksSnapshot.when(
|
topTracksSnapshot.when(
|
||||||
data: (topTracks) {
|
data: (topTracks) {
|
||||||
final isPlaylistPlaying =
|
final isPlaylistPlaying =
|
||||||
playback.currentPlaylist?.id == data.id;
|
playback.playlist?.id == data.id;
|
||||||
playPlaylist(List<Track> tracks,
|
playPlaylist(List<Track> tracks,
|
||||||
{Track? currentTrack}) async {
|
{Track? currentTrack}) async {
|
||||||
currentTrack ??= tracks.first;
|
currentTrack ??= tracks.first;
|
||||||
if (!isPlaylistPlaying) {
|
if (!isPlaylistPlaying) {
|
||||||
playback.setCurrentPlaylist = CurrentPlaylist(
|
await playback.playPlaylist(
|
||||||
|
CurrentPlaylist(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
id: data.id!,
|
id: data.id!,
|
||||||
name: "${data.name!} To Tracks",
|
name: "${data.name!} To Tracks",
|
||||||
thumbnail: imageToUrlString(data.images),
|
thumbnail: imageToUrlString(data.images),
|
||||||
|
),
|
||||||
|
tracks.indexWhere((s) => s.id == currentTrack?.id),
|
||||||
);
|
);
|
||||||
playback.setCurrentTrack = currentTrack;
|
|
||||||
} else if (isPlaylistPlaying &&
|
} else if (isPlaylistPlaying &&
|
||||||
currentTrack.id != null &&
|
currentTrack.id != null &&
|
||||||
currentTrack.id != playback.currentTrack?.id) {
|
currentTrack.id != playback.track?.id) {
|
||||||
playback.setCurrentTrack = currentTrack;
|
await playback.play(currentTrack);
|
||||||
}
|
}
|
||||||
await playback.startPlaying();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Column(children: [
|
return Column(children: [
|
||||||
|
@ -18,7 +18,6 @@ import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
|
|||||||
import 'package:spotube/components/Player/Player.dart';
|
import 'package:spotube/components/Player/Player.dart';
|
||||||
import 'package:spotube/components/Library/UserLibrary.dart';
|
import 'package:spotube/components/Library/UserLibrary.dart';
|
||||||
import 'package:spotube/hooks/useBreakpointValue.dart';
|
import 'package:spotube/hooks/useBreakpointValue.dart';
|
||||||
import 'package:spotube/hooks/useHotKeys.dart';
|
|
||||||
import 'package:spotube/hooks/usePaginatedFutureProvider.dart';
|
import 'package:spotube/hooks/usePaginatedFutureProvider.dart';
|
||||||
import 'package:spotube/hooks/useUpdateChecker.dart';
|
import 'package:spotube/hooks/useUpdateChecker.dart';
|
||||||
import 'package:spotube/models/Logger.dart';
|
import 'package:spotube/models/Logger.dart';
|
||||||
@ -54,8 +53,6 @@ class Home extends HookConsumerWidget {
|
|||||||
final _selectedIndex = useState(0);
|
final _selectedIndex = useState(0);
|
||||||
_onSelectedIndexChanged(int index) => _selectedIndex.value = index;
|
_onSelectedIndexChanged(int index) => _selectedIndex.value = index;
|
||||||
|
|
||||||
// initializing global hot keys
|
|
||||||
useHotKeys(ref);
|
|
||||||
// checks for latest version of the application
|
// checks for latest version of the application
|
||||||
useUpdateChecker(ref);
|
useUpdateChecker(ref);
|
||||||
|
|
||||||
|
67
lib/components/LoaderShimmers/ShimmerLyrics.dart
Normal file
67
lib/components/LoaderShimmers/ShimmerLyrics.dart
Normal file
@ -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<ShimmerColorTheme>()!.shimmerColor!;
|
||||||
|
final shimmerBackgroundColor = Theme.of(context)
|
||||||
|
.extension<ShimmerColorTheme>()!
|
||||||
|
.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(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
|
import 'package:spotube/components/LoaderShimmers/ShimmerLyrics.dart';
|
||||||
import 'package:spotube/helpers/artist-to-string.dart';
|
import 'package:spotube/helpers/artist-to-string.dart';
|
||||||
import 'package:spotube/hooks/useBreakpoints.dart';
|
import 'package:spotube/hooks/useBreakpoints.dart';
|
||||||
import 'package:spotube/provider/Playback.dart';
|
import 'package:spotube/provider/Playback.dart';
|
||||||
@ -22,7 +23,7 @@ class Lyrics extends HookConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Center(
|
Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
playback.currentTrack?.name ?? "",
|
playback.track?.name ?? "",
|
||||||
style: breakpoint >= Breakpoints.md
|
style: breakpoint >= Breakpoints.md
|
||||||
? textTheme.headline3
|
? textTheme.headline3
|
||||||
: textTheme.headline4?.copyWith(fontSize: 25),
|
: textTheme.headline4?.copyWith(fontSize: 25),
|
||||||
@ -30,7 +31,7 @@ class Lyrics extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
Center(
|
Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
artistsToString<Artist>(playback.currentTrack?.artists ?? []),
|
artistsToString<Artist>(playback.track?.artists ?? []),
|
||||||
style: breakpoint >= Breakpoints.md
|
style: breakpoint >= Breakpoints.md
|
||||||
? textTheme.headline5
|
? textTheme.headline5
|
||||||
: textTheme.headline6,
|
: textTheme.headline6,
|
||||||
@ -44,7 +45,7 @@ class Lyrics extends HookConsumerWidget {
|
|||||||
child: geniusLyricsSnapshot.when(
|
child: geniusLyricsSnapshot.when(
|
||||||
data: (lyrics) {
|
data: (lyrics) {
|
||||||
return Text(
|
return Text(
|
||||||
lyrics == null && playback.currentTrack == null
|
lyrics == null && playback.track == null
|
||||||
? "No Track being played currently"
|
? "No Track being played currently"
|
||||||
: lyrics!,
|
: lyrics!,
|
||||||
style: textTheme.headline6
|
style: textTheme.headline6
|
||||||
@ -52,8 +53,8 @@ class Lyrics extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
error: (error, __) => Text(
|
error: (error, __) => Text(
|
||||||
"Sorry, no Lyrics were found for `${playback.currentTrack?.name}` :'("),
|
"Sorry, no Lyrics were found for `${playback.track?.name}` :'("),
|
||||||
loading: () => const CircularProgressIndicator(),
|
loading: () => const ShimmerLyrics(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
|
import 'package:spotube/components/LoaderShimmers/ShimmerLyrics.dart';
|
||||||
import 'package:spotube/components/Lyrics/Lyrics.dart';
|
import 'package:spotube/components/Lyrics/Lyrics.dart';
|
||||||
import 'package:spotube/components/Shared/SpotubeMarqueeText.dart';
|
import 'package:spotube/components/Shared/SpotubeMarqueeText.dart';
|
||||||
import 'package:spotube/helpers/artist-to-string.dart';
|
import 'package:spotube/helpers/artist-to-string.dart';
|
||||||
@ -42,7 +43,7 @@ class SyncedLyrics extends HookConsumerWidget {
|
|||||||
controller.scrollToIndex(0);
|
controller.scrollToIndex(0);
|
||||||
failed.value = false;
|
failed.value = false;
|
||||||
return null;
|
return null;
|
||||||
}, [playback.currentTrack]);
|
}, [playback.track]);
|
||||||
|
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
if (lyricValue != null && lyricValue.rating <= 2) {
|
if (lyricValue != null && lyricValue.rating <= 2) {
|
||||||
@ -98,29 +99,30 @@ class SyncedLyrics extends HookConsumerWidget {
|
|||||||
Center(
|
Center(
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: breakpoint >= Breakpoints.md ? 50 : 30,
|
height: breakpoint >= Breakpoints.md ? 50 : 30,
|
||||||
child: playback.currentTrack?.name != null &&
|
child: playback.track?.name != null &&
|
||||||
playback.currentTrack!.name!.length > 29
|
playback.track!.name!.length > 29
|
||||||
? SpotubeMarqueeText(
|
? SpotubeMarqueeText(
|
||||||
text: playback.currentTrack?.name ?? "Not Playing",
|
text: playback.track?.name ?? "Not Playing",
|
||||||
style: headlineTextStyle,
|
style: headlineTextStyle,
|
||||||
)
|
)
|
||||||
: Text(
|
: Text(
|
||||||
playback.currentTrack?.name ?? "Not Playing",
|
playback.track?.name ?? "Not Playing",
|
||||||
style: headlineTextStyle,
|
style: headlineTextStyle,
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
Center(
|
Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
artistsToString<Artist>(playback.currentTrack?.artists ?? []),
|
artistsToString<Artist>(playback.track?.artists ?? []),
|
||||||
style: breakpoint >= Breakpoints.md
|
style: breakpoint >= Breakpoints.md
|
||||||
? textTheme.headline5
|
? textTheme.headline5
|
||||||
: textTheme.headline6,
|
: textTheme.headline6,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (lyricValue != null)
|
if (lyricValue != null && lyricValue.lyrics.isNotEmpty)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
|
itemCount: lyricValue.lyrics.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final lyricSlice = lyricValue.lyrics[index];
|
final lyricSlice = lyricValue.lyrics[index];
|
||||||
final isActive = lyricSlice.time.inSeconds == currentTime;
|
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()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -1,19 +1,14 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/PlayerActions.dart';
|
||||||
import 'package:spotube/components/Player/PlayerOverlay.dart';
|
import 'package:spotube/components/Player/PlayerOverlay.dart';
|
||||||
import 'package:spotube/components/Player/PlayerTrackDetails.dart';
|
import 'package:spotube/components/Player/PlayerTrackDetails.dart';
|
||||||
import 'package:spotube/components/Player/PlayerControls.dart';
|
import 'package:spotube/components/Player/PlayerControls.dart';
|
||||||
import 'package:spotube/helpers/image-to-url-string.dart';
|
import 'package:spotube/helpers/image-to-url-string.dart';
|
||||||
import 'package:spotube/hooks/useBreakpoints.dart';
|
import 'package:spotube/hooks/useBreakpoints.dart';
|
||||||
import 'package:spotube/models/LocalStorageKeys.dart';
|
|
||||||
import 'package:spotube/models/Logger.dart';
|
import 'package:spotube/models/Logger.dart';
|
||||||
import 'package:spotube/provider/Playback.dart';
|
import 'package:spotube/provider/Playback.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:spotube/utils/AudioPlayerHandler.dart';
|
|
||||||
|
|
||||||
class Player extends HookConsumerWidget {
|
class Player extends HookConsumerWidget {
|
||||||
Player({Key? key}) : super(key: key);
|
Player({Key? key}) : super(key: key);
|
||||||
@ -23,41 +18,14 @@ class Player extends HookConsumerWidget {
|
|||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
Playback playback = ref.watch(playbackProvider);
|
Playback playback = ref.watch(playbackProvider);
|
||||||
|
|
||||||
final _volume = useState(0.0);
|
|
||||||
|
|
||||||
final breakpoint = useBreakpoints();
|
final breakpoint = useBreakpoints();
|
||||||
|
|
||||||
final AudioPlayerHandler player = playback.player;
|
|
||||||
|
|
||||||
final Future<SharedPreferences> future =
|
|
||||||
useMemoized(SharedPreferences.getInstance);
|
|
||||||
final AsyncSnapshot<SharedPreferences?> 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(
|
String albumArt = useMemoized(
|
||||||
() => imageToUrlString(
|
() => imageToUrlString(
|
||||||
playback.currentTrack?.album?.images,
|
playback.track?.album?.images,
|
||||||
index: (playback.currentTrack?.album?.images?.length ?? 1) - 1,
|
index: (playback.track?.album?.images?.length ?? 1) - 1,
|
||||||
),
|
),
|
||||||
[playback.currentTrack?.album?.images],
|
[playback.track?.album?.images],
|
||||||
);
|
);
|
||||||
|
|
||||||
final entryRef = useRef<OverlayEntry?>(null);
|
final entryRef = useRef<OverlayEntry?>(null);
|
||||||
@ -82,7 +50,7 @@ class Player extends HookConsumerWidget {
|
|||||||
// entry will result in splashing while resizing the window
|
// entry will result in splashing while resizing the window
|
||||||
if (breakpoint.isLessThanOrEqualTo(Breakpoints.md) &&
|
if (breakpoint.isLessThanOrEqualTo(Breakpoints.md) &&
|
||||||
entryRef.value == null &&
|
entryRef.value == null &&
|
||||||
playback.currentTrack != null) {
|
playback.track != null) {
|
||||||
entryRef.value = OverlayEntry(
|
entryRef.value = OverlayEntry(
|
||||||
opaque: false,
|
opaque: false,
|
||||||
builder: (context) => PlayerOverlay(albumArt: albumArt),
|
builder: (context) => PlayerOverlay(albumArt: albumArt),
|
||||||
@ -104,7 +72,7 @@ class Player extends HookConsumerWidget {
|
|||||||
return () {
|
return () {
|
||||||
disposeOverlay();
|
disposeOverlay();
|
||||||
};
|
};
|
||||||
}, [breakpoint, playback.currentTrack]);
|
}, [breakpoint, playback.track]);
|
||||||
|
|
||||||
// returning an empty non spacious Container as the overlay will take
|
// returning an empty non spacious Container as the overlay will take
|
||||||
// place in the global overlay stack aka [_entries]
|
// place in the global overlay stack aka [_entries]
|
||||||
@ -135,22 +103,29 @@ class Player extends HookConsumerWidget {
|
|||||||
Container(
|
Container(
|
||||||
height: 20,
|
height: 20,
|
||||||
constraints: const BoxConstraints(maxWidth: 200),
|
constraints: const BoxConstraints(maxWidth: 200),
|
||||||
child: Slider.adaptive(
|
child: HookBuilder(builder: (context) {
|
||||||
value: _volume.value,
|
final volume = useState(
|
||||||
onChanged: (value) async {
|
useMemoized(() => playback.volume, []),
|
||||||
try {
|
|
||||||
await player.core.setVolume(value).then((_) {
|
|
||||||
_volume.value = value;
|
|
||||||
localStorage.data?.setDouble(
|
|
||||||
LocalStorageKeys.volume,
|
|
||||||
value,
|
|
||||||
);
|
);
|
||||||
});
|
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) {
|
} catch (e, stack) {
|
||||||
logger.e("onChange", e, stack);
|
logger.e("onChange", e, stack);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
);
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
PlayerActions()
|
PlayerActions()
|
||||||
],
|
],
|
||||||
|
@ -28,12 +28,12 @@ class PlayerActions extends HookConsumerWidget {
|
|||||||
mainAxisAlignment: mainAxisAlignment,
|
mainAxisAlignment: mainAxisAlignment,
|
||||||
children: [
|
children: [
|
||||||
DownloadTrackButton(
|
DownloadTrackButton(
|
||||||
track: playback.currentTrack,
|
track: playback.track,
|
||||||
),
|
),
|
||||||
if (auth.isLoggedIn)
|
if (auth.isLoggedIn)
|
||||||
FutureBuilder<bool>(
|
FutureBuilder<bool>(
|
||||||
future: playback.currentTrack?.id != null
|
future: playback.track?.id != null
|
||||||
? spotifyApi.tracks.me.containsOne(playback.currentTrack!.id!)
|
? spotifyApi.tracks.me.containsOne(playback.track!.id!)
|
||||||
: Future.value(false),
|
: Future.value(false),
|
||||||
initialData: false,
|
initialData: false,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
@ -42,12 +42,12 @@ class PlayerActions extends HookConsumerWidget {
|
|||||||
isLiked: isLiked,
|
isLiked: isLiked,
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
try {
|
try {
|
||||||
if (playback.currentTrack?.id == null) return;
|
if (playback.track?.id == null) return;
|
||||||
isLiked
|
isLiked
|
||||||
? await spotifyApi.tracks.me
|
? await spotifyApi.tracks.me
|
||||||
.removeOne(playback.currentTrack!.id!)
|
.removeOne(playback.track!.id!)
|
||||||
: await spotifyApi.tracks.me
|
: await spotifyApi.tracks.me
|
||||||
.saveOne(playback.currentTrack!.id!);
|
.saveOne(playback.track!.id!);
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
logger.e("FavoriteButton.onPressed", e, stack);
|
logger.e("FavoriteButton.onPressed", e, stack);
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotube/helpers/zero-pad-num-str.dart';
|
import 'package:spotube/helpers/zero-pad-num-str.dart';
|
||||||
import 'package:spotube/hooks/playback.dart';
|
import 'package:spotube/hooks/playback.dart';
|
||||||
import 'package:spotube/models/Logger.dart';
|
import 'package:spotube/models/Logger.dart';
|
||||||
import 'package:spotube/provider/Playback.dart';
|
import 'package:spotube/provider/Playback.dart';
|
||||||
import 'package:spotube/utils/AudioPlayerHandler.dart';
|
|
||||||
|
|
||||||
class PlayerControls extends HookConsumerWidget {
|
class PlayerControls extends HookConsumerWidget {
|
||||||
final Color? iconColor;
|
final Color? iconColor;
|
||||||
@ -18,7 +18,6 @@ class PlayerControls extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
final Playback playback = ref.watch(playbackProvider);
|
final Playback playback = ref.watch(playbackProvider);
|
||||||
final AudioPlayerHandler player = playback.player;
|
|
||||||
|
|
||||||
final onNext = useNextTrack(playback);
|
final onNext = useNextTrack(playback);
|
||||||
|
|
||||||
@ -26,14 +25,14 @@ class PlayerControls extends HookConsumerWidget {
|
|||||||
|
|
||||||
final _playOrPause = useTogglePlayPause(playback);
|
final _playOrPause = useTogglePlayPause(playback);
|
||||||
|
|
||||||
final duration = playback.duration ?? Duration.zero;
|
final duration = playback.currentDuration;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
constraints: const BoxConstraints(maxWidth: 600),
|
constraints: const BoxConstraints(maxWidth: 600),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
StreamBuilder<Duration>(
|
StreamBuilder<Duration>(
|
||||||
stream: player.core.positionStream,
|
stream: playback.player.onPositionChanged,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final totalMinutes =
|
final totalMinutes =
|
||||||
zeroPadNumStr(duration.inMinutes.remainder(60));
|
zeroPadNumStr(duration.inMinutes.remainder(60));
|
||||||
@ -48,18 +47,34 @@ class PlayerControls extends HookConsumerWidget {
|
|||||||
|
|
||||||
final sliderMax = duration.inSeconds;
|
final sliderMax = duration.inSeconds;
|
||||||
final sliderValue = snapshot.data?.inSeconds ?? 0;
|
final sliderValue = snapshot.data?.inSeconds ?? 0;
|
||||||
|
|
||||||
|
return HookBuilder(builder: (context) {
|
||||||
|
final progressStatic =
|
||||||
|
(sliderMax == 0 || sliderValue > sliderMax)
|
||||||
|
? 0
|
||||||
|
: sliderValue / sliderMax;
|
||||||
|
|
||||||
|
final progress = useState<num>(
|
||||||
|
useMemoized(() => progressStatic, []),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
progress.value = progressStatic;
|
||||||
|
return null;
|
||||||
|
}, [progressStatic]);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Slider.adaptive(
|
Slider.adaptive(
|
||||||
// cannot divide by zero
|
// cannot divide by zero
|
||||||
// there's an edge case for value being bigger
|
// there's an edge case for value being bigger
|
||||||
// than total duration. Keeping it resolved
|
// than total duration. Keeping it resolved
|
||||||
value: (sliderMax == 0 || sliderValue > sliderMax)
|
value: progress.value.toDouble(),
|
||||||
? 0
|
onChanged: (v) {
|
||||||
: sliderValue / sliderMax,
|
progress.value = v;
|
||||||
onChanged: (value) {},
|
},
|
||||||
onChangeEnd: (value) {
|
onChangeEnd: (value) async {
|
||||||
player.seek(
|
await playback.seekPosition(
|
||||||
Duration(
|
Duration(
|
||||||
seconds: (value * sliderMax).toInt(),
|
seconds: (value * sliderMax).toInt(),
|
||||||
),
|
),
|
||||||
@ -81,26 +96,22 @@ class PlayerControls extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.shuffle_rounded),
|
icon: const Icon(Icons.shuffle_rounded),
|
||||||
color: playback.shuffled
|
color: playback.isShuffled
|
||||||
? Theme.of(context).primaryColor
|
? Theme.of(context).primaryColor
|
||||||
: iconColor,
|
: iconColor,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (playback.currentTrack == null ||
|
if (playback.track == null || playback.playlist == null) {
|
||||||
playback.currentPlaylist == null) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (!playback.shuffled) {
|
playback.toggleShuffle();
|
||||||
playback.shuffle();
|
|
||||||
} else {
|
|
||||||
playback.unshuffle();
|
|
||||||
}
|
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
logger.e("onShuffle", e, stack);
|
logger.e("onShuffle", e, stack);
|
||||||
}
|
}
|
||||||
@ -128,12 +139,10 @@ class PlayerControls extends HookConsumerWidget {
|
|||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.stop_rounded),
|
icon: const Icon(Icons.stop_rounded),
|
||||||
color: iconColor,
|
color: iconColor,
|
||||||
onPressed: playback.currentTrack != null
|
onPressed: playback.track != null
|
||||||
? () async {
|
? () async {
|
||||||
try {
|
try {
|
||||||
await player.pause();
|
await playback.stop();
|
||||||
await player.seek(Duration.zero);
|
|
||||||
playback.reset();
|
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
logger.e("onStop", e, stack);
|
logger.e("onStop", e, stack);
|
||||||
}
|
}
|
||||||
|
@ -38,7 +38,7 @@ class PlayerTrackDetails extends HookConsumerWidget {
|
|||||||
if (breakpoint.isLessThanOrEqualTo(Breakpoints.md))
|
if (breakpoint.isLessThanOrEqualTo(Breakpoints.md))
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
playback.currentTrack?.name ?? "Not playing",
|
playback.track?.name ?? "Not playing",
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
@ -54,7 +54,7 @@ class PlayerTrackDetails extends HookConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
playback.currentTrack?.name ?? "Not playing",
|
playback.track?.name ?? "Not playing",
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
@ -62,7 +62,7 @@ class PlayerTrackDetails extends HookConsumerWidget {
|
|||||||
?.copyWith(fontWeight: FontWeight.bold, color: color),
|
?.copyWith(fontWeight: FontWeight.bold, color: color),
|
||||||
),
|
),
|
||||||
artistsToClickableArtists(
|
artistsToClickableArtists(
|
||||||
playback.currentTrack?.artists ?? [],
|
playback.track?.artists ?? [],
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
@ -23,7 +23,7 @@ class PlayerView extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
final currentTrack = ref.watch(playbackProvider.select(
|
final currentTrack = ref.watch(playbackProvider.select(
|
||||||
(value) => value.currentTrack,
|
(value) => value.track,
|
||||||
));
|
));
|
||||||
final breakpoint = useBreakpoints();
|
final breakpoint = useBreakpoints();
|
||||||
|
|
||||||
@ -59,7 +59,6 @@ class PlayerView extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
backgroundColor: paletteColor.color,
|
backgroundColor: paletteColor.color,
|
||||||
body: Column(
|
body: Column(
|
||||||
// mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(10),
|
padding: const EdgeInsets.all(10),
|
||||||
|
@ -15,8 +15,8 @@ class PlaylistCard extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
Playback playback = ref.watch(playbackProvider);
|
Playback playback = ref.watch(playbackProvider);
|
||||||
bool isPlaylistPlaying = playback.currentPlaylist != null &&
|
bool isPlaylistPlaying =
|
||||||
playback.currentPlaylist!.id == playlist.id;
|
playback.playlist != null && playback.playlist!.id == playlist.id;
|
||||||
|
|
||||||
final int marginH =
|
final int marginH =
|
||||||
useBreakpointValue(sm: 10, md: 15, lg: 20, xl: 20, xxl: 20);
|
useBreakpointValue(sm: 10, md: 15, lg: 20, xl: 20, xxl: 20);
|
||||||
@ -46,14 +46,14 @@ class PlaylistCard extends HookConsumerWidget {
|
|||||||
|
|
||||||
if (tracks.isEmpty) return;
|
if (tracks.isEmpty) return;
|
||||||
|
|
||||||
playback.setCurrentPlaylist = CurrentPlaylist(
|
await playback.playPlaylist(
|
||||||
|
CurrentPlaylist(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
id: playlist.id!,
|
id: playlist.id!,
|
||||||
name: playlist.name!,
|
name: playlist.name!,
|
||||||
thumbnail: imageToUrlString(playlist.images),
|
thumbnail: imageToUrlString(playlist.images),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
playback.setCurrentTrack = tracks.first;
|
|
||||||
await playback.startPlaying();
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -73,11 +73,11 @@ class PlaylistCreateDialog extends HookConsumerWidget {
|
|||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
TextField(
|
TextField(
|
||||||
controller: description,
|
controller: description,
|
||||||
keyboardType: TextInputType.multiline,
|
|
||||||
maxLines: 5,
|
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
hintText: "Description...",
|
hintText: "Description...",
|
||||||
),
|
),
|
||||||
|
keyboardType: TextInputType.multiline,
|
||||||
|
maxLines: 5,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
CheckboxListTile(
|
CheckboxListTile(
|
||||||
|
@ -24,22 +24,23 @@ class PlaylistView extends HookConsumerWidget {
|
|||||||
playPlaylist(Playback playback, List<Track> tracks,
|
playPlaylist(Playback playback, List<Track> tracks,
|
||||||
{Track? currentTrack}) async {
|
{Track? currentTrack}) async {
|
||||||
currentTrack ??= tracks.first;
|
currentTrack ??= tracks.first;
|
||||||
final isPlaylistPlaying = playback.currentPlaylist?.id != null &&
|
final isPlaylistPlaying =
|
||||||
playback.currentPlaylist?.id == playlist.id;
|
playback.playlist?.id != null && playback.playlist?.id == playlist.id;
|
||||||
if (!isPlaylistPlaying) {
|
if (!isPlaylistPlaying) {
|
||||||
playback.setCurrentPlaylist = CurrentPlaylist(
|
await playback.playPlaylist(
|
||||||
|
CurrentPlaylist(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
id: playlist.id!,
|
id: playlist.id!,
|
||||||
name: playlist.name!,
|
name: playlist.name!,
|
||||||
thumbnail: imageToUrlString(playlist.images),
|
thumbnail: imageToUrlString(playlist.images),
|
||||||
|
),
|
||||||
|
tracks.indexWhere((s) => s.id == currentTrack?.id),
|
||||||
);
|
);
|
||||||
playback.setCurrentTrack = currentTrack;
|
|
||||||
} else if (isPlaylistPlaying &&
|
} else if (isPlaylistPlaying &&
|
||||||
currentTrack.id != null &&
|
currentTrack.id != null &&
|
||||||
currentTrack.id != playback.currentTrack?.id) {
|
currentTrack.id != playback.track?.id) {
|
||||||
playback.setCurrentTrack = currentTrack;
|
await playback.play(currentTrack);
|
||||||
}
|
}
|
||||||
await playback.startPlaying();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -47,8 +48,8 @@ class PlaylistView extends HookConsumerWidget {
|
|||||||
Playback playback = ref.watch(playbackProvider);
|
Playback playback = ref.watch(playbackProvider);
|
||||||
final Auth auth = ref.watch(authProvider);
|
final Auth auth = ref.watch(authProvider);
|
||||||
SpotifyApi spotify = ref.watch(spotifyProvider);
|
SpotifyApi spotify = ref.watch(spotifyProvider);
|
||||||
final isPlaylistPlaying = playback.currentPlaylist?.id != null &&
|
final isPlaylistPlaying =
|
||||||
playback.currentPlaylist?.id == playlist.id;
|
playback.playlist?.id != null && playback.playlist?.id == playlist.id;
|
||||||
|
|
||||||
final meSnapshot = ref.watch(currentUserQuery);
|
final meSnapshot = ref.watch(currentUserQuery);
|
||||||
final tracksSnapshot = ref.watch(playlistTracksQuery(playlist.id!));
|
final tracksSnapshot = ref.watch(playlistTracksQuery(playlist.id!));
|
||||||
|
@ -48,8 +48,8 @@ class Search extends HookConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
decoration: const InputDecoration(hintText: "Search..."),
|
|
||||||
controller: controller,
|
controller: controller,
|
||||||
|
decoration: const InputDecoration(hintText: "Search..."),
|
||||||
onSubmitted: (value) {
|
onSubmitted: (value) {
|
||||||
ref.read(searchTermStateProvider.notifier).state =
|
ref.read(searchTermStateProvider.notifier).state =
|
||||||
controller.value.text;
|
controller.value.text;
|
||||||
@ -115,26 +115,24 @@ class Search extends HookConsumerWidget {
|
|||||||
thumbnailUrl:
|
thumbnailUrl:
|
||||||
imageToUrlString(track.value.album?.images),
|
imageToUrlString(track.value.album?.images),
|
||||||
onTrackPlayButtonPressed: (currentTrack) async {
|
onTrackPlayButtonPressed: (currentTrack) async {
|
||||||
var isPlaylistPlaying =
|
var isPlaylistPlaying = playback.playlist?.id !=
|
||||||
playback.currentPlaylist?.id != null &&
|
null &&
|
||||||
playback.currentPlaylist?.id ==
|
playback.playlist?.id == currentTrack.id;
|
||||||
currentTrack.id;
|
|
||||||
if (!isPlaylistPlaying) {
|
if (!isPlaylistPlaying) {
|
||||||
playback.setCurrentPlaylist = CurrentPlaylist(
|
playback.playPlaylist(
|
||||||
|
CurrentPlaylist(
|
||||||
tracks: [currentTrack],
|
tracks: [currentTrack],
|
||||||
id: currentTrack.id!,
|
id: currentTrack.id!,
|
||||||
name: currentTrack.name!,
|
name: currentTrack.name!,
|
||||||
thumbnail: imageToUrlString(
|
thumbnail: imageToUrlString(
|
||||||
currentTrack.album?.images),
|
currentTrack.album?.images),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
playback.setCurrentTrack = currentTrack;
|
|
||||||
} else if (isPlaylistPlaying &&
|
} else if (isPlaylistPlaying &&
|
||||||
currentTrack.id != null &&
|
currentTrack.id != null &&
|
||||||
currentTrack.id !=
|
currentTrack.id != playback.track?.id) {
|
||||||
playback.currentTrack?.id) {
|
playback.play(currentTrack);
|
||||||
playback.setCurrentTrack = currentTrack;
|
|
||||||
}
|
}
|
||||||
await playback.startPlaying();
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
@ -77,14 +77,16 @@ class Login extends HookConsumerWidget {
|
|||||||
hintText: "Spotify Client ID",
|
hintText: "Spotify Client ID",
|
||||||
label: Text("ClientID"),
|
label: Text("ClientID"),
|
||||||
),
|
),
|
||||||
|
keyboardType: TextInputType.visiblePassword,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
TextField(
|
TextField(
|
||||||
|
controller: clientSecretController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
hintText: "Spotify Client Secret",
|
hintText: "Spotify Client Secret",
|
||||||
label: Text("Client Secret"),
|
label: Text("Client Secret"),
|
||||||
),
|
),
|
||||||
controller: clientSecretController,
|
keyboardType: TextInputType.visiblePassword,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
|
@ -1,19 +1,15 @@
|
|||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotube/components/Settings/About.dart';
|
import 'package:spotube/components/Settings/About.dart';
|
||||||
import 'package:spotube/components/Settings/ColorSchemePickerDialog.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/components/Shared/PageWindowTitleBar.dart';
|
||||||
import 'package:spotube/helpers/search-youtube.dart';
|
import 'package:spotube/helpers/search-youtube.dart';
|
||||||
import 'package:spotube/models/SpotifyMarkets.dart';
|
import 'package:spotube/models/SpotifyMarkets.dart';
|
||||||
import 'package:spotube/models/SpotubeTrack.dart';
|
import 'package:spotube/models/SpotubeTrack.dart';
|
||||||
import 'package:spotube/provider/Auth.dart';
|
import 'package:spotube/provider/Auth.dart';
|
||||||
import 'package:spotube/provider/UserPreferences.dart';
|
import 'package:spotube/provider/UserPreferences.dart';
|
||||||
import 'package:spotube/utils/platform.dart';
|
|
||||||
import 'package:url_launcher/url_launcher_string.dart';
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
|
|
||||||
class Settings extends HookConsumerWidget {
|
class Settings extends HookConsumerWidget {
|
||||||
@ -57,29 +53,6 @@ class Settings extends HookConsumerWidget {
|
|||||||
constraints: const BoxConstraints(maxWidth: 1366),
|
constraints: const BoxConstraints(maxWidth: 1366),
|
||||||
child: ListView(
|
child: ListView(
|
||||||
children: [
|
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(
|
ListTile(
|
||||||
title: const Text("Theme"),
|
title: const Text("Theme"),
|
||||||
horizontalTitleGap: 10,
|
horizontalTitleGap: 10,
|
||||||
|
@ -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<HotKey> 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,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -115,28 +115,6 @@ class DownloadTrackButton extends HookConsumerWidget {
|
|||||||
|
|
||||||
if (!await outputFile.exists()) await outputFile.create(recursive: true);
|
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();
|
IOSink outputFileStream = outputFile.openWrite();
|
||||||
await audioStream.pipe(outputFileStream);
|
await audioStream.pipe(outputFileStream);
|
||||||
await outputFileStream.flush();
|
await outputFileStream.flush();
|
||||||
@ -154,12 +132,31 @@ class DownloadTrackButton extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
return statusCb.cancel();
|
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,
|
track,
|
||||||
status,
|
status,
|
||||||
yt,
|
yt,
|
||||||
preferences.saveTrackLyrics,
|
preferences.saveTrackLyrics,
|
||||||
playback.currentTrack,
|
playback.track,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
|
@ -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<HotKey> onHotKeyRecorded;
|
|
||||||
|
|
||||||
const RecordHotKeyDialog({
|
|
||||||
Key? key,
|
|
||||||
required this.onHotKeyRecorded,
|
|
||||||
}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final _hotKey = useState<HotKey?>(null);
|
|
||||||
return AlertDialog(
|
|
||||||
content: SingleChildScrollView(
|
|
||||||
child: ListBody(
|
|
||||||
children: <Widget>[
|
|
||||||
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: <Widget>[
|
|
||||||
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();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -84,7 +84,7 @@ class TrackTile extends HookConsumerWidget {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
actionAddToPlaylist() async {
|
Future<void> actionAddToPlaylist() async {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
@ -196,8 +196,7 @@ class TrackTile extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
playback.currentTrack?.id != null &&
|
playback.track?.id != null && playback.track?.id == track.value.id
|
||||||
playback.currentTrack?.id == track.value.id
|
|
||||||
? Icons.pause_circle_rounded
|
? Icons.pause_circle_rounded
|
||||||
: Icons.play_circle_rounded,
|
: Icons.play_circle_rounded,
|
||||||
color: Theme.of(context).primaryColor,
|
color: Theme.of(context).primaryColor,
|
||||||
|
@ -30,3 +30,71 @@ extension VideoFromCacheTrackExtension on Video {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension ThumbnailSetJson on ThumbnailSet {
|
||||||
|
static ThumbnailSet fromJson(Map<String, dynamic> map) {
|
||||||
|
return ThumbnailSet(map["videoId"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
"videoId": videoId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EngagementJson on Engagement {
|
||||||
|
static Engagement fromJson(Map<String, dynamic> map) {
|
||||||
|
return Engagement(
|
||||||
|
map["viewCount"],
|
||||||
|
map["likeCount"],
|
||||||
|
map["dislikeCount"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
"dislikeCount": dislikeCount,
|
||||||
|
"likeCount": likeCount,
|
||||||
|
"viewCount": viewCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension VideoToJson on Video {
|
||||||
|
static Video fromJson(Map<String, dynamic> 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<dynamic, String>(map["keywords"]),
|
||||||
|
EngagementJson.fromJson(map["engagement"]),
|
||||||
|
map["isLive"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -107,10 +107,16 @@ Future<SpotubeTrack> toSpotubeTrack({
|
|||||||
"[YouTube Matched Track] ${ytVideo.title} | ${ytVideo.author} - ${ytVideo.url}",
|
"[YouTube Matched Track] ${ytVideo.title} | ${ytVideo.author} - ${ytVideo.url}",
|
||||||
);
|
);
|
||||||
|
|
||||||
final audioManifest = (Platform.isMacOS || Platform.isIOS)
|
final audioManifest = trackManifest.audioOnly.where((info) {
|
||||||
? trackManifest.audioOnly
|
final isMp4a = info.codec.mimeType == "audio/mp4";
|
||||||
.where((info) => info.codec.mimeType == "audio/mp4")
|
if (Platform.isLinux) {
|
||||||
: trackManifest.audioOnly;
|
return !isMp4a;
|
||||||
|
} else if (Platform.isMacOS || Platform.isIOS) {
|
||||||
|
return isMp4a;
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
final ytUri = (audioQuality == AudioQuality.high
|
final ytUri = (audioQuality == AudioQuality.high
|
||||||
? audioManifest.withHighestBitrate()
|
? audioManifest.withHighestBitrate()
|
||||||
|
@ -8,7 +8,7 @@ Future<void> Function() useNextTrack(Playback playback) {
|
|||||||
try {
|
try {
|
||||||
await playback.player.pause();
|
await playback.player.pause();
|
||||||
await playback.player.seek(Duration.zero);
|
await playback.player.seek(Duration.zero);
|
||||||
playback.movePlaylistPositionBy(1);
|
playback.seekForward();
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
logger.e("useNextTrack", e, stack);
|
logger.e("useNextTrack", e, stack);
|
||||||
}
|
}
|
||||||
@ -20,7 +20,7 @@ Future<void> Function() usePreviousTrack(Playback playback) {
|
|||||||
try {
|
try {
|
||||||
await playback.player.pause();
|
await playback.player.pause();
|
||||||
await playback.player.seek(Duration.zero);
|
await playback.player.seek(Duration.zero);
|
||||||
playback.movePlaylistPositionBy(-1);
|
playback.seekBackward();
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
logger.e("onPrevious", e, stack);
|
logger.e("onPrevious", e, stack);
|
||||||
}
|
}
|
||||||
@ -30,10 +30,15 @@ Future<void> Function() usePreviousTrack(Playback playback) {
|
|||||||
Future<void> Function([dynamic]) useTogglePlayPause(Playback playback) {
|
Future<void> Function([dynamic]) useTogglePlayPause(Playback playback) {
|
||||||
return ([key]) async {
|
return ([key]) async {
|
||||||
try {
|
try {
|
||||||
if (playback.currentTrack == null) return;
|
if (playback.track == null) {
|
||||||
playback.isPlaying
|
return;
|
||||||
? await playback.player.pause()
|
} else if (playback.track != null &&
|
||||||
: await playback.player.play();
|
playback.currentDuration == Duration.zero &&
|
||||||
|
await playback.player.getCurrentPosition() == Duration.zero) {
|
||||||
|
await playback.play(playback.track!);
|
||||||
|
} else {
|
||||||
|
await playback.togglePlayPause();
|
||||||
|
}
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
logger.e("useTogglePlayPause", e, stack);
|
logger.e("useTogglePlayPause", e, stack);
|
||||||
}
|
}
|
||||||
|
@ -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<GlobalKeyActions> _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)));
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
@ -6,7 +6,7 @@ useSyncedLyrics(WidgetRef ref, Map<int, String> lyricsMap) {
|
|||||||
final player = ref.watch(playbackProvider.select(
|
final player = ref.watch(playbackProvider.select(
|
||||||
(value) => (value.player),
|
(value) => (value.player),
|
||||||
));
|
));
|
||||||
final stream = player.core.positionStream;
|
final stream = player.onPositionChanged;
|
||||||
|
|
||||||
final currentTime = useState(0);
|
final currentTime = useState(0);
|
||||||
|
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:audio_service/audio_service.dart';
|
import 'package:audio_service/audio_service.dart';
|
||||||
import 'package:bitsdojo_window/bitsdojo_window.dart';
|
import 'package:bitsdojo_window/bitsdojo_window.dart';
|
||||||
import 'package:flutter/material.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:go_router/go_router.dart';
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:hotkey_manager/hotkey_manager.dart';
|
|
||||||
import 'package:spotube/entities/CacheTrack.dart';
|
import 'package:spotube/entities/CacheTrack.dart';
|
||||||
import 'package:spotube/models/GoRouteDeclarations.dart';
|
import 'package:spotube/models/GoRouteDeclarations.dart';
|
||||||
import 'package:spotube/models/Logger.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/Playback.dart';
|
||||||
import 'package:spotube/provider/UserPreferences.dart';
|
import 'package:spotube/provider/UserPreferences.dart';
|
||||||
import 'package:spotube/provider/YouTube.dart';
|
import 'package:spotube/provider/YouTube.dart';
|
||||||
|
import 'package:spotube/services/MobileAudioService.dart';
|
||||||
import 'package:spotube/themes/dark-theme.dart';
|
import 'package:spotube/themes/dark-theme.dart';
|
||||||
import 'package:spotube/themes/light-theme.dart';
|
import 'package:spotube/themes/light-theme.dart';
|
||||||
import 'package:spotube/utils/AudioPlayerHandler.dart';
|
|
||||||
import 'package:spotube/utils/platform.dart';
|
import 'package:spotube/utils/platform.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
await Hive.initFlutter();
|
await Hive.initFlutter();
|
||||||
Hive.registerAdapter(CacheTrackAdapter());
|
Hive.registerAdapter(CacheTrackAdapter());
|
||||||
Hive.registerAdapter(CacheTrackEngagementAdapter());
|
Hive.registerAdapter(CacheTrackEngagementAdapter());
|
||||||
AudioPlayerHandler audioPlayerHandler = await AudioService.init(
|
|
||||||
builder: () => AudioPlayerHandler(),
|
|
||||||
config: const AudioServiceConfig(
|
|
||||||
androidNotificationChannelId: 'com.krtirtho.Spotube',
|
|
||||||
androidNotificationChannelName: 'Spotube',
|
|
||||||
androidNotificationOngoing: true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (kIsDesktop) {
|
if (kIsDesktop) {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await hotKeyManager.unregisterAll();
|
// final client = DBusClient.session();
|
||||||
|
// await client.registerObject(Media_Player());
|
||||||
doWhenWindowReady(() {
|
doWhenWindowReady(() {
|
||||||
appWindow.minSize = const Size(359, 700);
|
appWindow.minSize = const Size(359, 700);
|
||||||
appWindow.alignment = Alignment.center;
|
appWindow.alignment = Alignment.center;
|
||||||
@ -43,17 +33,38 @@ void main() async {
|
|||||||
appWindow.show();
|
appWindow.show();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
MobileAudioService? audioServiceHandler;
|
||||||
runApp(ProviderScope(
|
runApp(ProviderScope(
|
||||||
child: Spotube(),
|
child: Spotube(),
|
||||||
overrides: [
|
overrides: [
|
||||||
playbackProvider.overrideWithProvider(ChangeNotifierProvider(
|
playbackProvider.overrideWithProvider(ChangeNotifierProvider(
|
||||||
(ref) {
|
(ref) {
|
||||||
final youtube = ref.watch(youtubeProvider);
|
final youtube = ref.watch(youtubeProvider);
|
||||||
return Playback(
|
final player = ref.watch(audioPlayerProvider);
|
||||||
player: audioPlayerHandler,
|
|
||||||
|
final playback = Playback(
|
||||||
|
player: player,
|
||||||
youtube: youtube,
|
youtube: youtube,
|
||||||
ref: ref,
|
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;
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
],
|
],
|
||||||
|
@ -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);
|
|
||||||
}
|
|
@ -24,8 +24,8 @@ GoRouter createGoRouter() => GoRouter(
|
|||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: "/settings",
|
path: "/settings",
|
||||||
pageBuilder: (context, state) => SpotubePage(
|
pageBuilder: (context, state) => const SpotubePage(
|
||||||
child: const Settings(),
|
child: Settings(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
import 'package:spotify/spotify.dart';
|
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';
|
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||||
|
|
||||||
enum SpotubeTrackMatchAlgorithm {
|
enum SpotubeTrackMatchAlgorithm {
|
||||||
@ -14,11 +16,16 @@ class SpotubeTrack extends Track {
|
|||||||
Video ytTrack;
|
Video ytTrack;
|
||||||
String ytUri;
|
String ytUri;
|
||||||
|
|
||||||
|
SpotubeTrack(
|
||||||
|
this.ytTrack,
|
||||||
|
this.ytUri,
|
||||||
|
) : super();
|
||||||
|
|
||||||
SpotubeTrack.fromTrack({
|
SpotubeTrack.fromTrack({
|
||||||
required Track track,
|
required Track track,
|
||||||
required this.ytTrack,
|
required this.ytTrack,
|
||||||
required this.ytUri,
|
required this.ytUri,
|
||||||
}) {
|
}) : super() {
|
||||||
album = track.album;
|
album = track.album;
|
||||||
artists = track.artists;
|
artists = track.artists;
|
||||||
availableMarkets = track.availableMarkets;
|
availableMarkets = track.availableMarkets;
|
||||||
@ -38,4 +45,38 @@ class SpotubeTrack extends Track {
|
|||||||
type = track.type;
|
type = track.type;
|
||||||
uri = track.uri;
|
uri = track.uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static SpotubeTrack fromJson(Map<String, dynamic> map) {
|
||||||
|
return SpotubeTrack.fromTrack(
|
||||||
|
track: Track.fromJson(map),
|
||||||
|
ytTrack: VideoToJson.fromJson(map["ytTrack"]),
|
||||||
|
ytUri: map["ytUri"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
|
import 'package:audioplayers/audioplayers.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:just_audio/just_audio.dart';
|
|
||||||
|
|
||||||
final audioPlayerProvider = Provider<AudioPlayer>((ref) {
|
final audioPlayerProvider = Provider<AudioPlayer>((ref) {
|
||||||
return AudioPlayer();
|
return AudioPlayer();
|
||||||
|
12
lib/provider/DBus.dart
Normal file
12
lib/provider/DBus.dart
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:dbus/dbus.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
final Provider<DBusClient?> dbusClientProvider = Provider<DBusClient?>((ref) {
|
||||||
|
if (Platform.isLinux) {
|
||||||
|
return DBusClient.session();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
final dbus = DBusClient.session();
|
@ -1,205 +1,136 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:audio_service/audio_service.dart';
|
import 'package:audio_service/audio_service.dart';
|
||||||
|
import 'package:audioplayers/audioplayers.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:hive/hive.dart';
|
import 'package:hive/hive.dart';
|
||||||
import 'package:just_audio/just_audio.dart';
|
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/entities/CacheTrack.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/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/image-to-url-string.dart';
|
||||||
import 'package:spotube/helpers/search-youtube.dart';
|
import 'package:spotube/helpers/search-youtube.dart';
|
||||||
import 'package:spotube/models/CurrentPlaylist.dart';
|
import 'package:spotube/models/CurrentPlaylist.dart';
|
||||||
import 'package:spotube/models/Logger.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/UserPreferences.dart';
|
||||||
import 'package:spotube/provider/YouTube.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: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 {
|
class Playback extends PersistedChangeNotifier {
|
||||||
AudioSource? _currentAudioSource;
|
// player properties
|
||||||
final _logger = getLogger(Playback);
|
bool isShuffled;
|
||||||
CurrentPlaylist? _currentPlaylist;
|
bool isPlaying;
|
||||||
Track? _currentTrack;
|
Duration currentDuration;
|
||||||
|
double volume;
|
||||||
|
|
||||||
// states
|
// class dependencies
|
||||||
bool _isPlaying = false;
|
LinuxAudioService? _linuxAudioService;
|
||||||
Duration? duration;
|
MobileAudioService? mobileAudioService;
|
||||||
|
|
||||||
Duration _prevPosition = Duration.zero;
|
// foreign/passed properties
|
||||||
bool _shuffled = false;
|
AudioPlayer player;
|
||||||
|
|
||||||
AudioPlayerHandler player;
|
|
||||||
YoutubeExplode youtube;
|
YoutubeExplode youtube;
|
||||||
Ref ref;
|
Ref ref;
|
||||||
|
UserPreferences get preferences => ref.read(userPreferencesProvider);
|
||||||
|
|
||||||
LazyBox<CacheTrack>? cacheTrackBox;
|
// playlist & track list properties
|
||||||
|
late LazyBox<CacheTrack> cache;
|
||||||
|
CurrentPlaylist? playlist;
|
||||||
|
SpotubeTrack? track;
|
||||||
|
|
||||||
|
// internal stuff
|
||||||
|
final List<StreamSubscription> _subscriptions;
|
||||||
|
final _logger = getLogger(Playback);
|
||||||
|
|
||||||
Playback({
|
Playback({
|
||||||
required this.player,
|
required this.player,
|
||||||
required this.youtube,
|
required this.youtube,
|
||||||
required this.ref,
|
required this.ref,
|
||||||
CurrentPlaylist? currentPlaylist,
|
this.mobileAudioService,
|
||||||
Track? currentTrack,
|
}) : volume = 0,
|
||||||
}) : _currentPlaylist = currentPlaylist,
|
isShuffled = false,
|
||||||
_currentTrack = currentTrack,
|
isPlaying = false,
|
||||||
|
currentDuration = Duration.zero,
|
||||||
|
_subscriptions = [],
|
||||||
super() {
|
super() {
|
||||||
player.onNextRequest = () {
|
if (Platform.isLinux) {
|
||||||
movePlaylistPositionBy(1);
|
_linuxAudioService = LinuxAudioService(this);
|
||||||
};
|
|
||||||
player.onPreviousRequest = () {
|
|
||||||
movePlaylistPositionBy(-1);
|
|
||||||
};
|
|
||||||
|
|
||||||
_init();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
StreamSubscription<Duration?>? _durationStream;
|
(() async {
|
||||||
StreamSubscription<Duration>? _positionStream;
|
cache = await Hive.openLazyBox<CacheTrack>("track-cache");
|
||||||
StreamSubscription<bool>? _playingStream;
|
_subscriptions.addAll([
|
||||||
|
player.onPlayerStateChanged.listen(
|
||||||
void _init() async {
|
(state) async {
|
||||||
cacheTrackBox = await Hive.openLazyBox<CacheTrack>("track-cache");
|
isPlaying = state == PlayerState.playing;
|
||||||
|
|
||||||
_playingStream = player.core.playingStream.listen(
|
|
||||||
(playing) {
|
|
||||||
_isPlaying = playing;
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
},
|
},
|
||||||
);
|
),
|
||||||
|
player.onPlayerComplete.listen((_) {
|
||||||
_durationStream = player.core.durationStream.listen((event) async {
|
if (track?.id != null) {
|
||||||
if (event != null) {
|
seekForward();
|
||||||
// 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 {
|
} else {
|
||||||
_isPlaying = false;
|
isPlaying = false;
|
||||||
duration = null;
|
currentDuration = Duration.zero;
|
||||||
notifyListeners();
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_positionStream?.cancel();
|
_linuxAudioService?.dispose();
|
||||||
_playingStream?.cancel();
|
for (var subscription in _subscriptions) {
|
||||||
_durationStream?.cancel();
|
subscription.cancel();
|
||||||
cacheTrackBox?.close();
|
}
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get shuffled => _shuffled;
|
Future<void> playPlaylist(CurrentPlaylist playlist, [int index = 0]) async {
|
||||||
CurrentPlaylist? get currentPlaylist => _currentPlaylist;
|
if (index < 0 || index > playlist.tracks.length - 1) return;
|
||||||
Track? get currentTrack => _currentTrack;
|
this.playlist = playlist;
|
||||||
bool get isPlaying => _isPlaying;
|
final played = this.playlist!.tracks[index];
|
||||||
|
await play(played).then((_) {
|
||||||
set setCurrentTrack(Track track) {
|
int i = this
|
||||||
_logger.v("[Setting Current Track] ${track.name} - ${track.id}");
|
.playlist!
|
||||||
_currentTrack = track;
|
.tracks
|
||||||
notifyListeners();
|
.indexWhere((element) => element.id == played.id);
|
||||||
updatePersistence();
|
if (index == -1) return;
|
||||||
|
this.playlist!.tracks[i] = track!;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
set setCurrentPlaylist(CurrentPlaylist playlist) {
|
// player methods
|
||||||
_logger.v("[Current Playlist Changed] ${playlist.name} - ${playlist.id}");
|
Future<void> play(Track track) async {
|
||||||
_currentPlaylist = playlist;
|
_logger.v("[Track Playing] ${track.name} - ${track.id}");
|
||||||
notifyListeners();
|
|
||||||
updatePersistence();
|
|
||||||
}
|
|
||||||
|
|
||||||
void reset() {
|
|
||||||
_logger.v("Playback Reset");
|
|
||||||
_isPlaying = false;
|
|
||||||
_shuffled = false;
|
|
||||||
duration = null;
|
|
||||||
_currentPlaylist = null;
|
|
||||||
_currentTrack = null;
|
|
||||||
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 movePlaylistPositionBy(int pos) {
|
|
||||||
_logger.v("[Playlist Position Move] $pos");
|
|
||||||
if (_currentTrack != null && _currentPlaylist != null) {
|
|
||||||
int index = _currentPlaylist!.trackIds.indexOf(_currentTrack!.id!) + pos;
|
|
||||||
|
|
||||||
var safeIndex = index > _currentPlaylist!.trackIds.length - 1
|
|
||||||
? 0
|
|
||||||
: index < 0
|
|
||||||
? _currentPlaylist!.trackIds.length
|
|
||||||
: index;
|
|
||||||
Track? track = _currentPlaylist!.tracks.asMap().containsKey(safeIndex)
|
|
||||||
? _currentPlaylist!.tracks.elementAt(safeIndex)
|
|
||||||
: null;
|
|
||||||
if (track != null) {
|
|
||||||
duration = null;
|
|
||||||
_currentTrack = track;
|
|
||||||
notifyListeners();
|
|
||||||
updatePersistence();
|
|
||||||
// starts to play the newly entered next/prev track
|
|
||||||
startPlaying();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> startPlaying([Track? track]) async {
|
|
||||||
_logger.v("[Track Playing] ${track?.name} - ${track?.id}");
|
|
||||||
try {
|
try {
|
||||||
// the track is already playing so no need to change that
|
// the track is already playing so no need to change that
|
||||||
if (track != null && track.id == _currentTrack?.id) return;
|
if (track.id == this.track?.id) return;
|
||||||
track ??= _currentTrack;
|
|
||||||
if (track != null) {
|
|
||||||
Uri? parsedUri = Uri.tryParse(track.uri ?? "");
|
|
||||||
final tag = MediaItem(
|
final tag = MediaItem(
|
||||||
id: track.id!,
|
id: track.id!,
|
||||||
title: track.name!,
|
title: track.name!,
|
||||||
@ -207,98 +138,246 @@ class Playback extends PersistedChangeNotifier {
|
|||||||
artist: artistsToString(track.artists ?? <ArtistSimple>[]),
|
artist: artistsToString(track.artists ?? <ArtistSimple>[]),
|
||||||
artUri: Uri.parse(imageToUrlString(track.album?.images)),
|
artUri: Uri.parse(imageToUrlString(track.album?.images)),
|
||||||
);
|
);
|
||||||
player.addItem(tag);
|
mobileAudioService?.addItem(tag);
|
||||||
if (parsedUri != null && parsedUri.hasAbsolutePath) {
|
|
||||||
_currentAudioSource = AudioSource.uri(parsedUri);
|
// the track is not a SpotubeTrack so turning it to one
|
||||||
await player.core
|
if (track is! SpotubeTrack) {
|
||||||
.setAudioSource(
|
track = await toSpotubeTrack(track);
|
||||||
_currentAudioSource!,
|
}
|
||||||
preload: true,
|
_logger.v("[Track Direct Source] - ${(track).ytUri}");
|
||||||
)
|
this.track = track;
|
||||||
.then((value) async {
|
|
||||||
_currentTrack = track;
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
updatePersistence();
|
updatePersistence();
|
||||||
});
|
await player.play(UrlSource(track.ytUri));
|
||||||
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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
_logger.e("startPlaying", e, stack);
|
_logger.e("play", e, stack);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void shuffle() {
|
Future<void> resume() async {
|
||||||
if (currentPlaylist?.shuffle() == true) {
|
if (isPlaying || (playlist == null && track == null)) return;
|
||||||
_shuffled = true;
|
await player.resume();
|
||||||
|
isPlaying = true;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pause() async {
|
||||||
|
if (!isPlaying || (playlist == null && track == null)) return;
|
||||||
|
await player.pause();
|
||||||
|
isPlaying = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> togglePlayPause() async {
|
||||||
|
isPlaying ? await pause() : await resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleShuffle() {
|
||||||
|
final result = isShuffled ? playlist?.unshuffle() : playlist?.shuffle();
|
||||||
|
if (result == true) {
|
||||||
|
isShuffled = !isShuffled;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void unshuffle() {
|
Future<void> seekPosition(Duration position) {
|
||||||
if (currentPlaylist?.unshuffle() == true) {
|
return player.seek(position);
|
||||||
_shuffled = false;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setVolume(double newVolume) async {
|
||||||
|
await player.setVolume(volume);
|
||||||
|
volume = newVolume;
|
||||||
|
notifyListeners();
|
||||||
|
updatePersistence();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> stop() async {
|
||||||
|
await player.stop();
|
||||||
|
await player.release();
|
||||||
|
isPlaying = false;
|
||||||
|
isShuffled = false;
|
||||||
|
playlist = null;
|
||||||
|
track = null;
|
||||||
|
currentDuration = Duration.zero;
|
||||||
|
notifyListeners();
|
||||||
|
updatePersistence(clearNullEntries: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void destroy() {
|
||||||
|
stop();
|
||||||
|
player.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// playlist & track list methods
|
||||||
|
Future<SpotubeTrack> toSpotubeTrack(Track track) async {
|
||||||
|
final format = preferences.ytSearchFormat;
|
||||||
|
final matchAlgorithm = preferences.trackMatchAlgorithm;
|
||||||
|
final artistsName =
|
||||||
|
track.artists?.map((ar) => ar.name).toList().whereNotNull().toList() ??
|
||||||
|
[];
|
||||||
|
final audioQuality = preferences.audioQuality;
|
||||||
|
_logger.v("[Track Search Artists] $artistsName");
|
||||||
|
final mainArtist = artistsName.first;
|
||||||
|
final featuredArtists = artistsName.length > 1
|
||||||
|
? "feat. " + artistsName.sublist(1).join(" ")
|
||||||
|
: "";
|
||||||
|
final title = getTitle(
|
||||||
|
track.name!,
|
||||||
|
artists: artistsName,
|
||||||
|
onlyCleanArtist: true,
|
||||||
|
).trim();
|
||||||
|
_logger.v("[Track Search Title] $title");
|
||||||
|
final queryString = format
|
||||||
|
.replaceAll("\$MAIN_ARTIST", mainArtist)
|
||||||
|
.replaceAll("\$TITLE", title)
|
||||||
|
.replaceAll("\$FEATURED_ARTISTS", featuredArtists);
|
||||||
|
_logger.v("[Youtube Search Term] $queryString");
|
||||||
|
|
||||||
|
Video ytVideo;
|
||||||
|
final cachedTrack = await cache.get(track.id);
|
||||||
|
if (cachedTrack != null && cachedTrack.mode == matchAlgorithm.name) {
|
||||||
|
_logger.v(
|
||||||
|
"[Playing track from cache] youtubeId: ${cachedTrack.id} mode: ${cachedTrack.mode}",
|
||||||
|
);
|
||||||
|
ytVideo = VideoFromCacheTrackExtension.fromCacheTrack(cachedTrack);
|
||||||
|
} else {
|
||||||
|
VideoSearchList videos = await youtube.search.search(queryString);
|
||||||
|
if (matchAlgorithm != SpotubeTrackMatchAlgorithm.youtube) {
|
||||||
|
List<Map> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setPlaylistPosition(int position) async {
|
||||||
|
if (playlist == null) return;
|
||||||
|
await playPlaylist(playlist!, position);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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<void> 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
|
@override
|
||||||
FutureOr<void> loadFromLocal(Map<String, dynamic> map) {
|
FutureOr<void> loadFromLocal(Map<String, dynamic> map) async {
|
||||||
if (map["currentPlaylist"] != null) {
|
if (map["playlist"] != null) {
|
||||||
_currentPlaylist =
|
playlist = CurrentPlaylist.fromJson(jsonDecode(map["playlist"]));
|
||||||
CurrentPlaylist.fromJson(jsonDecode(map["currentPlaylist"]));
|
|
||||||
}
|
}
|
||||||
if (map["currentTrack"] != null) {
|
if (map["track"] != null) {
|
||||||
_currentTrack = Track.fromJson(jsonDecode(map["currentTrack"]));
|
track = SpotubeTrack.fromJson(jsonDecode(map["track"]));
|
||||||
startPlaying().then((_) {
|
|
||||||
Timer.periodic(const Duration(milliseconds: 100), (timer) {
|
|
||||||
if (player.core.playing) {
|
|
||||||
player.pause();
|
|
||||||
timer.cancel();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
volume = map["volume"] ?? volume;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
FutureOr<Map<String, dynamic>> toMap() {
|
FutureOr<Map<String, dynamic>> toMap() {
|
||||||
return {
|
return {
|
||||||
"currentPlaylist": currentPlaylist != null
|
"playlist": playlist != null ? jsonEncode(playlist?.toJson()) : null,
|
||||||
? jsonEncode(currentPlaylist?.toJson())
|
"track": track != null ? jsonEncode(track?.toJson()) : null,
|
||||||
: null,
|
"volume": volume,
|
||||||
"currentTrack":
|
|
||||||
currentTrack != null ? jsonEncode(currentTrack?.toJson()) : null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final playbackProvider = ChangeNotifierProvider<Playback>((ref) {
|
final playbackProvider = ChangeNotifierProvider<Playback>((ref) {
|
||||||
final player = AudioPlayerHandler();
|
|
||||||
final youtube = ref.watch(youtubeProvider);
|
final youtube = ref.watch(youtubeProvider);
|
||||||
|
final player = ref.watch(audioPlayerProvider);
|
||||||
return Playback(
|
return Playback(
|
||||||
player: player,
|
player: player,
|
||||||
youtube: youtube,
|
youtube: youtube,
|
||||||
|
@ -166,8 +166,7 @@ final searchQuery = FutureProvider.family<List<Page>, String>((ref, term) {
|
|||||||
|
|
||||||
final geniusLyricsQuery = FutureProvider<String?>(
|
final geniusLyricsQuery = FutureProvider<String?>(
|
||||||
(ref) {
|
(ref) {
|
||||||
final currentTrack =
|
final currentTrack = ref.watch(playbackProvider.select((s) => s.track));
|
||||||
ref.watch(playbackProvider.select((s) => s.currentTrack));
|
|
||||||
final geniusAccessToken =
|
final geniusAccessToken =
|
||||||
ref.watch(userPreferencesProvider.select((s) => s.geniusAccessToken));
|
ref.watch(userPreferencesProvider.select((s) => s.geniusAccessToken));
|
||||||
if (currentTrack == null) {
|
if (currentTrack == null) {
|
||||||
@ -184,8 +183,7 @@ final geniusLyricsQuery = FutureProvider<String?>(
|
|||||||
|
|
||||||
final rentanadviserLyricsQuery = FutureProvider<SubtitleSimple?>(
|
final rentanadviserLyricsQuery = FutureProvider<SubtitleSimple?>(
|
||||||
(ref) {
|
(ref) {
|
||||||
final currentTrack =
|
final currentTrack = ref.watch(playbackProvider.select((s) => s.track));
|
||||||
ref.watch(playbackProvider.select((s) => s.currentTrack));
|
|
||||||
if (currentTrack == null) return null;
|
if (currentTrack == null) return null;
|
||||||
return getTimedLyrics(currentTrack as SpotubeTrack);
|
return getTimedLyrics(currentTrack as SpotubeTrack);
|
||||||
},
|
},
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/components/Settings/ColorSchemePickerDialog.dart';
|
||||||
import 'package:spotube/helpers/get-random-element.dart';
|
import 'package:spotube/helpers/get-random-element.dart';
|
||||||
import 'package:spotube/helpers/search-youtube.dart';
|
import 'package:spotube/helpers/search-youtube.dart';
|
||||||
@ -18,9 +16,6 @@ class UserPreferences extends PersistedChangeNotifier {
|
|||||||
String recommendationMarket;
|
String recommendationMarket;
|
||||||
bool saveTrackLyrics;
|
bool saveTrackLyrics;
|
||||||
String geniusAccessToken;
|
String geniusAccessToken;
|
||||||
HotKey? nextTrackHotKey;
|
|
||||||
HotKey? prevTrackHotKey;
|
|
||||||
HotKey? playPauseHotKey;
|
|
||||||
bool checkUpdate;
|
bool checkUpdate;
|
||||||
SpotubeTrackMatchAlgorithm trackMatchAlgorithm;
|
SpotubeTrackMatchAlgorithm trackMatchAlgorithm;
|
||||||
AudioQuality audioQuality;
|
AudioQuality audioQuality;
|
||||||
@ -35,9 +30,6 @@ class UserPreferences extends PersistedChangeNotifier {
|
|||||||
this.saveTrackLyrics = false,
|
this.saveTrackLyrics = false,
|
||||||
this.accentColorScheme = Colors.green,
|
this.accentColorScheme = Colors.green,
|
||||||
this.backgroundColorScheme = Colors.grey,
|
this.backgroundColorScheme = Colors.grey,
|
||||||
this.nextTrackHotKey,
|
|
||||||
this.prevTrackHotKey,
|
|
||||||
this.playPauseHotKey,
|
|
||||||
this.checkUpdate = true,
|
this.checkUpdate = true,
|
||||||
this.trackMatchAlgorithm = SpotubeTrackMatchAlgorithm.authenticPopular,
|
this.trackMatchAlgorithm = SpotubeTrackMatchAlgorithm.authenticPopular,
|
||||||
this.audioQuality = AudioQuality.high,
|
this.audioQuality = AudioQuality.high,
|
||||||
@ -67,24 +59,6 @@ class UserPreferences extends PersistedChangeNotifier {
|
|||||||
updatePersistence();
|
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) {
|
void setYtSearchFormat(String format) {
|
||||||
ytSearchFormat = format;
|
ytSearchFormat = format;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@ -128,15 +102,7 @@ class UserPreferences extends PersistedChangeNotifier {
|
|||||||
checkUpdate = map["checkUpdate"] ?? checkUpdate;
|
checkUpdate = map["checkUpdate"] ?? checkUpdate;
|
||||||
geniusAccessToken =
|
geniusAccessToken =
|
||||||
map["geniusAccessToken"] ?? getRandomElement(lyricsSecrets);
|
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;
|
ytSearchFormat = map["ytSearchFormat"] ?? ytSearchFormat;
|
||||||
themeMode = ThemeMode.values[map["themeMode"] ?? 0];
|
themeMode = ThemeMode.values[map["themeMode"] ?? 0];
|
||||||
backgroundColorScheme = colorsMap.values
|
backgroundColorScheme = colorsMap.values
|
||||||
@ -159,15 +125,6 @@ class UserPreferences extends PersistedChangeNotifier {
|
|||||||
"saveTrackLyrics": saveTrackLyrics,
|
"saveTrackLyrics": saveTrackLyrics,
|
||||||
"recommendationMarket": recommendationMarket,
|
"recommendationMarket": recommendationMarket,
|
||||||
"geniusAccessToken": geniusAccessToken,
|
"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,
|
"ytSearchFormat": ytSearchFormat,
|
||||||
"themeMode": themeMode.index,
|
"themeMode": themeMode.index,
|
||||||
"backgroundColorScheme": backgroundColorScheme.value,
|
"backgroundColorScheme": backgroundColorScheme.value,
|
||||||
|
697
lib/services/LinuxAudioService.dart
Normal file
697
lib/services/LinuxAudioService.dart
Normal file
@ -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<DBusMethodResponse> getCanQuit() async {
|
||||||
|
return DBusMethodSuccessResponse([const DBusBoolean(true)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets value of property org.mpris.MediaPlayer2.Fullscreen
|
||||||
|
Future<DBusMethodResponse> getFullscreen() async {
|
||||||
|
return DBusMethodSuccessResponse([const DBusBoolean(false)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets property org.mpris.MediaPlayer2.Fullscreen
|
||||||
|
Future<DBusMethodResponse> setFullscreen(bool value) async {
|
||||||
|
return DBusMethodSuccessResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets value of property org.mpris.MediaPlayer2.CanSetFullscreen
|
||||||
|
Future<DBusMethodResponse> getCanSetFullscreen() async {
|
||||||
|
return DBusMethodSuccessResponse([const DBusBoolean(false)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets value of property org.mpris.MediaPlayer2.CanRaise
|
||||||
|
Future<DBusMethodResponse> getCanRaise() async {
|
||||||
|
return DBusMethodSuccessResponse([const DBusBoolean(false)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets value of property org.mpris.MediaPlayer2.HasTrackList
|
||||||
|
Future<DBusMethodResponse> getHasTrackList() async {
|
||||||
|
return DBusMethodSuccessResponse([const DBusBoolean(false)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets value of property org.mpris.MediaPlayer2.Identity
|
||||||
|
Future<DBusMethodResponse> getIdentity() async {
|
||||||
|
return DBusMethodSuccessResponse([const DBusString("Spotube")]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets value of property org.mpris.MediaPlayer2.DesktopEntry
|
||||||
|
Future<DBusMethodResponse> getDesktopEntry() async {
|
||||||
|
return DBusMethodSuccessResponse([const DBusString("spotube")]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets value of property org.mpris.MediaPlayer2.SupportedUriSchemes
|
||||||
|
Future<DBusMethodResponse> getSupportedUriSchemes() async {
|
||||||
|
return DBusMethodSuccessResponse([
|
||||||
|
DBusArray.string(["http"])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets value of property org.mpris.MediaPlayer2.SupportedMimeTypes
|
||||||
|
Future<DBusMethodResponse> getSupportedMimeTypes() async {
|
||||||
|
return DBusMethodSuccessResponse([
|
||||||
|
DBusArray.string(["audio/mpeg"])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implementation of org.mpris.MediaPlayer2.Raise()
|
||||||
|
Future<DBusMethodResponse> doRaise() async {
|
||||||
|
return DBusMethodSuccessResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implementation of org.mpris.MediaPlayer2.Quit()
|
||||||
|
Future<DBusMethodResponse> doQuit() async {
|
||||||
|
appWindow.close();
|
||||||
|
return DBusMethodSuccessResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<DBusIntrospectInterface> 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<DBusMethodResponse> 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<DBusMethodResponse> 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<DBusMethodResponse> 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<DBusMethodResponse> getAllProperties(String interface) async {
|
||||||
|
var properties = <String, DBusValue>{};
|
||||||
|
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<DBusMethodResponse> 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<DBusMethodResponse> getLoopStatus() async {
|
||||||
|
return DBusMethodSuccessResponse([const DBusString("Playlist")]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets property org.mpris.MediaPlayer2.Player.LoopStatus
|
||||||
|
Future<DBusMethodResponse> 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<DBusMethodResponse> getRate() async {
|
||||||
|
return DBusMethodSuccessResponse([DBusDouble(1)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets property org.mpris.MediaPlayer2.Player.Rate
|
||||||
|
Future<DBusMethodResponse> setRate(double value) async {
|
||||||
|
return DBusMethodSuccessResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets value of property org.mpris.MediaPlayer2.Player.Shuffle
|
||||||
|
Future<DBusMethodResponse> getShuffle() async {
|
||||||
|
return DBusMethodSuccessResponse([DBusBoolean(playback.isShuffled)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets property org.mpris.MediaPlayer2.Player.Shuffle
|
||||||
|
Future<DBusMethodResponse> setShuffle(bool value) async {
|
||||||
|
playback.toggleShuffle();
|
||||||
|
return DBusMethodSuccessResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets value of property org.mpris.MediaPlayer2.Player.Metadata
|
||||||
|
Future<DBusMethodResponse> 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<DBusMethodResponse> getVolume() async {
|
||||||
|
return DBusMethodSuccessResponse([DBusDouble(playback.volume)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets property org.mpris.MediaPlayer2.Player.Volume
|
||||||
|
Future<DBusMethodResponse> setVolume(double value) async {
|
||||||
|
playback.setVolume(value);
|
||||||
|
return DBusMethodSuccessResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets value of property org.mpris.MediaPlayer2.Player.Position
|
||||||
|
Future<DBusMethodResponse> getPosition() async {
|
||||||
|
return DBusMethodSuccessResponse([
|
||||||
|
DBusInt64((await playback.player.getDuration())?.inMicroseconds ?? 0),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets value of property org.mpris.MediaPlayer2.Player.MinimumRate
|
||||||
|
Future<DBusMethodResponse> getMinimumRate() async {
|
||||||
|
return DBusMethodSuccessResponse([const DBusDouble(1)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets value of property org.mpris.MediaPlayer2.Player.MaximumRate
|
||||||
|
Future<DBusMethodResponse> getMaximumRate() async {
|
||||||
|
return DBusMethodSuccessResponse([const DBusDouble(1)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets value of property org.mpris.MediaPlayer2.Player.CanGoNext
|
||||||
|
Future<DBusMethodResponse> getCanGoNext() async {
|
||||||
|
return DBusMethodSuccessResponse([
|
||||||
|
DBusBoolean(
|
||||||
|
playback.playlist?.tracks.isNotEmpty == true,
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets value of property org.mpris.MediaPlayer2.Player.CanGoPrevious
|
||||||
|
Future<DBusMethodResponse> getCanGoPrevious() async {
|
||||||
|
return DBusMethodSuccessResponse([
|
||||||
|
DBusBoolean(
|
||||||
|
playback.playlist?.tracks.isNotEmpty == true,
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets value of property org.mpris.MediaPlayer2.Player.CanPlay
|
||||||
|
Future<DBusMethodResponse> getCanPlay() async {
|
||||||
|
return DBusMethodSuccessResponse([const DBusBoolean(true)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets value of property org.mpris.MediaPlayer2.Player.CanPause
|
||||||
|
Future<DBusMethodResponse> getCanPause() async {
|
||||||
|
return DBusMethodSuccessResponse([const DBusBoolean(true)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets value of property org.mpris.MediaPlayer2.Player.CanSeek
|
||||||
|
Future<DBusMethodResponse> getCanSeek() async {
|
||||||
|
return DBusMethodSuccessResponse([const DBusBoolean(true)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets value of property org.mpris.MediaPlayer2.Player.CanControl
|
||||||
|
Future<DBusMethodResponse> getCanControl() async {
|
||||||
|
return DBusMethodSuccessResponse([const DBusBoolean(true)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implementation of org.mpris.MediaPlayer2.Player.Next()
|
||||||
|
Future<DBusMethodResponse> doNext() async {
|
||||||
|
playback.seekForward();
|
||||||
|
return DBusMethodSuccessResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implementation of org.mpris.MediaPlayer2.Player.Previous()
|
||||||
|
Future<DBusMethodResponse> doPrevious() async {
|
||||||
|
playback.seekBackward();
|
||||||
|
return DBusMethodSuccessResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implementation of org.mpris.MediaPlayer2.Player.Pause()
|
||||||
|
Future<DBusMethodResponse> doPause() async {
|
||||||
|
playback.pause();
|
||||||
|
return DBusMethodSuccessResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implementation of org.mpris.MediaPlayer2.Player.PlayPause()
|
||||||
|
Future<DBusMethodResponse> doPlayPause() async {
|
||||||
|
playback.isPlaying ? playback.pause() : playback.resume();
|
||||||
|
return DBusMethodSuccessResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implementation of org.mpris.MediaPlayer2.Player.Stop()
|
||||||
|
Future<DBusMethodResponse> doStop() async {
|
||||||
|
playback.stop();
|
||||||
|
return DBusMethodSuccessResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implementation of org.mpris.MediaPlayer2.Player.Play()
|
||||||
|
Future<DBusMethodResponse> doPlay() async {
|
||||||
|
playback.resume();
|
||||||
|
return DBusMethodSuccessResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implementation of org.mpris.MediaPlayer2.Player.Seek()
|
||||||
|
Future<DBusMethodResponse> doSeek(int offset) async {
|
||||||
|
playback.seekPosition(Duration(microseconds: offset));
|
||||||
|
return DBusMethodSuccessResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implementation of org.mpris.MediaPlayer2.Player.SetPosition()
|
||||||
|
Future<DBusMethodResponse> doSetPosition(String TrackId, int Position) async {
|
||||||
|
return DBusMethodSuccessResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implementation of org.mpris.MediaPlayer2.Player.OpenUri()
|
||||||
|
Future<DBusMethodResponse> doOpenUri(String Uri) async {
|
||||||
|
return DBusMethodSuccessResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emits signal org.mpris.MediaPlayer2.Player.Seeked
|
||||||
|
Future<void> emitSeeked(int Position) async {
|
||||||
|
await emitSignal(
|
||||||
|
'org.mpris.MediaPlayer2.Player', 'Seeked', [DBusInt64(Position)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<DBusIntrospectInterface> 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<DBusMethodResponse> 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<DBusMethodResponse> 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<DBusMethodResponse> 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<DBusMethodResponse> getAllProperties(String interface) async {
|
||||||
|
var properties = <String, DBusValue>{};
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
84
lib/services/MobileAudioService.dart
Normal file
84
lib/services/MobileAudioService.dart
Normal file
@ -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<void> play() => playback.resume();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> pause() => playback.pause();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> seek(Duration position) => playback.seekPosition(position);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> stop() => playback.stop();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> skipToNext() async {
|
||||||
|
playback.seekForward();
|
||||||
|
await super.skipToNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> skipToPrevious() async {
|
||||||
|
playback.seekBackward();
|
||||||
|
await super.skipToPrevious();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onTaskRemoved() {
|
||||||
|
playback.destroy();
|
||||||
|
return super.onTaskRemoved();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<PlaybackState> _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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -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<void> Function()? onNextRequest;
|
|
||||||
FutureOr<void> 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<void> play() => _player.play();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> pause() => _player.pause();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> seek(Duration position) => _player.seek(position);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> stop() => _player.stop();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> skipToNext() async {
|
|
||||||
await onNextRequest?.call();
|
|
||||||
await super.skipToNext();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> 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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -6,21 +6,17 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <audioplayers_linux/audioplayers_linux_plugin.h>
|
||||||
#include <bitsdojo_window_linux/bitsdojo_window_plugin.h>
|
#include <bitsdojo_window_linux/bitsdojo_window_plugin.h>
|
||||||
#include <hotkey_manager/hotkey_manager_plugin.h>
|
|
||||||
#include <libwinmedia/libwinmedia_plugin.h>
|
|
||||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
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 =
|
g_autoptr(FlPluginRegistrar) bitsdojo_window_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "BitsdojoWindowPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "BitsdojoWindowPlugin");
|
||||||
bitsdojo_window_plugin_register_with_registrar(bitsdojo_window_linux_registrar);
|
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 =
|
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||||
|
@ -3,9 +3,8 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
audioplayers_linux
|
||||||
bitsdojo_window_linux
|
bitsdojo_window_linux
|
||||||
hotkey_manager
|
|
||||||
libwinmedia
|
|
||||||
url_launcher_linux
|
url_launcher_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -7,9 +7,8 @@ import Foundation
|
|||||||
|
|
||||||
import audio_service
|
import audio_service
|
||||||
import audio_session
|
import audio_session
|
||||||
|
import audioplayers_darwin
|
||||||
import bitsdojo_window_macos
|
import bitsdojo_window_macos
|
||||||
import hotkey_manager
|
|
||||||
import just_audio
|
|
||||||
import package_info_plus_macos
|
import package_info_plus_macos
|
||||||
import path_provider_macos
|
import path_provider_macos
|
||||||
import shared_preferences_macos
|
import shared_preferences_macos
|
||||||
@ -19,9 +18,8 @@ import url_launcher_macos
|
|||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin"))
|
AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin"))
|
||||||
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
|
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
|
||||||
|
AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
|
||||||
BitsdojoWindowPlugin.register(with: registry.registrar(forPlugin: "BitsdojoWindowPlugin"))
|
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"))
|
FLTPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlusPlugin"))
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
|
98
pubspec.lock
98
pubspec.lock
@ -71,6 +71,55 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.1.6+1"
|
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:
|
bitsdojo_window:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -267,6 +316,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.3"
|
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:
|
fading_edge_scrollview:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -429,13 +485,6 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.4"
|
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:
|
html:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -499,41 +548,6 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.5.0"
|
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:
|
lints:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -42,9 +42,6 @@ dependencies:
|
|||||||
url_launcher: ^6.0.17
|
url_launcher: ^6.0.17
|
||||||
youtube_explode_dart: ^1.10.8
|
youtube_explode_dart: ^1.10.8
|
||||||
bitsdojo_window: ^0.1.2
|
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: ^1.8.0
|
||||||
path_provider: ^2.0.8
|
path_provider: ^2.0.8
|
||||||
collection: ^1.15.0
|
collection: ^1.15.0
|
||||||
@ -64,6 +61,8 @@ dependencies:
|
|||||||
skeleton_text: ^3.0.0
|
skeleton_text: ^3.0.0
|
||||||
hive: ^2.2.2
|
hive: ^2.2.2
|
||||||
hive_flutter: ^1.1.0
|
hive_flutter: ^1.1.0
|
||||||
|
dbus: ^0.7.3
|
||||||
|
audioplayers: ^1.0.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
@ -6,19 +6,16 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <audioplayers_windows/audioplayers_windows_plugin.h>
|
||||||
#include <bitsdojo_window_windows/bitsdojo_window_plugin.h>
|
#include <bitsdojo_window_windows/bitsdojo_window_plugin.h>
|
||||||
#include <hotkey_manager/hotkey_manager_plugin.h>
|
|
||||||
#include <libwinmedia/libwinmedia_plugin.h>
|
|
||||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||||
#include <url_launcher_windows/url_launcher_windows.h>
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
|
AudioplayersWindowsPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin"));
|
||||||
BitsdojoWindowPluginRegisterWithRegistrar(
|
BitsdojoWindowPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("BitsdojoWindowPlugin"));
|
registry->GetRegistrarForPlugin("BitsdojoWindowPlugin"));
|
||||||
HotkeyManagerPluginRegisterWithRegistrar(
|
|
||||||
registry->GetRegistrarForPlugin("HotkeyManagerPlugin"));
|
|
||||||
LibwinmediaPluginRegisterWithRegistrar(
|
|
||||||
registry->GetRegistrarForPlugin("LibwinmediaPlugin"));
|
|
||||||
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||||
UrlLauncherWindowsRegisterWithRegistrar(
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
|
@ -3,9 +3,8 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
audioplayers_windows
|
||||||
bitsdojo_window_windows
|
bitsdojo_window_windows
|
||||||
hotkey_manager
|
|
||||||
libwinmedia
|
|
||||||
permission_handler_windows
|
permission_handler_windows
|
||||||
url_launcher_windows
|
url_launcher_windows
|
||||||
)
|
)
|
||||||
|
Loading…
Reference in New Issue
Block a user