Merge pull request #1576 from KRTirtho/dev

Release v3.7.1
This commit is contained in:
Kingkor Roy Tirtho 2024-06-06 14:57:14 +06:00 committed by GitHub
commit 491c1b9857
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
218 changed files with 8115 additions and 2917 deletions

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
build
dist
.dart_tool
.idea
.github
.git

View File

@ -9,3 +9,6 @@ ENABLE_UPDATE_CHECK=
LASTFM_API_KEY= LASTFM_API_KEY=
LASTFM_API_SECRET= LASTFM_API_SECRET=
# Release channel. Can be: nightly, stable
RELEASE_CHANNEL=

View File

@ -1,4 +1,4 @@
{ {
"flutterSdkVersion": "3.19.1", "flutterSdkVersion": "3.19.6",
"flavors": {} "flavors": {}
} }

23
.github/Dockerfile vendored Normal file
View File

@ -0,0 +1,23 @@
ARG FLUTTER_VERSION
FROM --platform=linux/arm64 krtirtho/flutter_distributor_arm64:${FLUTTER_VERSION}
ARG BUILD_VERSION
WORKDIR /app
COPY . .
RUN chown -R $(whoami) /app
RUN flutter pub get
RUN alias dpkg-deb="dpkg-deb --Zxz" &&\
flutter_distributor package --platform=linux --targets=deb --skip-clean
RUN make tar VERSION=${BUILD_VERSION} ARCH=arm64 PKG_ARCH=aarch64
RUN mv build/spotube-linux-*-aarch64.tar.xz dist/ &&\
mv dist/**/spotube-*-linux.deb dist/Spotube-linux-aarch64.deb
CMD [ "sleep", "5000000" ]

23
.github/Dockerfile.flutter_distributor vendored Normal file
View File

@ -0,0 +1,23 @@
FROM --platform=linux/arm64 ubuntu:22.04
ARG FLUTTER_VERSION
RUN apt-get clean &&\
apt-get update &&\
apt-get install -y bash curl file git unzip xz-utils zip libglu1-mesa cmake tar clang ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse libunwind-dev locate patchelf gir1.2-appindicator3-0.1 libappindicator3-1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev mpv libmpv-dev rpm && \
rm -rf /var/lib/apt/lists/*
WORKDIR /home/flutter
RUN git clone https://github.com/flutter/flutter.git -b ${FLUTTER_VERSION} --single-branch flutter-sdk
RUN flutter-sdk/bin/flutter precache
RUN flutter-sdk/bin/flutter config --no-analytics
ENV PATH="$PATH:/home/flutter/flutter-sdk/bin"
ENV PATH="$PATH:/home/flutter/flutter-sdk/bin/cache/dart-sdk/bin"
ENV PATH="$PATH:/home/flutter/.pub-cache/bin"
ENV PUB_CACHE="/home/flutter/.pub-cache"
RUN dart pub global activate flutter_distributor

View File

@ -4,7 +4,7 @@ on:
pull_request: pull_request:
env: env:
FLUTTER_VERSION: '3.19.5' FLUTTER_VERSION: '3.19.6'
jobs: jobs:
lint: lint:

View File

@ -4,7 +4,7 @@ on:
inputs: inputs:
version: version:
description: Version to publish (x.x.x) description: Version to publish (x.x.x)
default: 3.1.0 default: 3.7.1
required: true required: true
dry_run: dry_run:
description: Dry run description: Dry run

View File

@ -2,279 +2,109 @@ name: Spotube Release Binary
on: on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version:
description: Version to release (x.x.x)
default: 3.6.0
required: true
channel: channel:
type: choice type: choice
description: Release Channel
required: true
options: options:
- stable - stable
- nightly - nightly
default: nightly default: nightly
description: The release channel
debug: debug:
description: Debug on failed when channel is nightly
required: true
type: boolean type: boolean
default: false default: false
description: Debug with SSH toggle
required: false
dry_run: dry_run:
description: Dry run
required: true
type: boolean type: boolean
default: true default: false
description: Dry run without uploading to release
env: env:
FLUTTER_VERSION: '3.19.1' FLUTTER_VERSION: 3.19.6
permissions:
contents: write
jobs: jobs:
windows: build_platform:
runs-on: windows-latest strategy:
matrix:
include:
- os: ubuntu-latest
platform: linux
files: |
dist/Spotube-linux-x86_64.deb
dist/Spotube-linux-x86_64.rpm
dist/spotube-linux-*-x86_64.tar.xz
- os: ubuntu-latest
platform: linux_arm
files: |
dist/Spotube-linux-aarch64.deb
dist/spotube-linux-*-aarch64.tar.xz
- os: ubuntu-latest
platform: android
files: |
build/Spotube-android-all-arch.apk
build/Spotube-playstore-all-arch.aab
- os: windows-latest
platform: windows
files: |
dist/Spotube-windows-x86_64.nupkg
dist/Spotube-windows-x86_64-setup.exe
- os: macos-latest
platform: ios
files: |
Spotube-iOS.ipa
- os: macos-14
platform: macos
files: |
build/Spotube-macos-universal.dmg
build/Spotube-macos-universal.pkg
runs-on: ${{matrix.os}}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: subosito/flutter-action@v2.12.0 - uses: subosito/flutter-action@v2.12.0
with: with:
cache: true cache: true
cache-key: ${{ runner.os }}-flutter-${{ hashFiles('**/pubspec.yaml') }}
flutter-version: ${{ env.FLUTTER_VERSION }} flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Setup Java
- name: Replace pubspec version and BUILD_VERSION Env (nightly) if: ${{matrix.platform == 'android'}}
if: ${{ inputs.channel == 'nightly' }} uses: actions/setup-java@v4
run: |
choco install sed make yq -y
yq -i '.version |= sub("\+\d+", "+${{ inputs.channel }}.")' pubspec.yaml
yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml
"BUILD_VERSION=${{ inputs.version }}+${{ inputs.channel }}.${{ github.run_number }}" >> $env:GITHUB_ENV
- name: BUILD_VERSION Env (stable)
if: ${{ inputs.channel == 'stable' }}
run: |
"BUILD_VERSION=${{ inputs.version }}" >> $env:GITHUB_ENV
- name: Replace version in files
run: |
choco install sed make -y
sed -i "s/%{{SPOTUBE_VERSION}}%/${{ env.BUILD_VERSION }}/" windows/runner/Runner.rc
sed -i "s/%{{SPOTUBE_VERSION}}%/${{ env.BUILD_VERSION }}/" choco-struct/tools/VERIFICATION.txt
sed -i "s/%{{SPOTUBE_VERSION}}%/${{ env.BUILD_VERSION }}/" choco-struct/spotube.nuspec
- name: Create Stable .env
if: ${{ inputs.channel == 'stable' }}
run: echo '${{ secrets.DOTENV_RELEASE }}' > .env
- name: Create Nightly .env
if: ${{ inputs.channel == 'nightly' }}
run: echo '${{ secrets.DOTENV_NIGHTLY }}' > .env
- name: Generating Secrets
run: |
flutter config --enable-windows-desktop
flutter pub get
dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns
- name: Build Windows Executable
run: |
dart pub global activate flutter_distributor
make innoinstall
flutter_distributor package --platform=windows --targets=exe --skip-clean
mv dist/**/spotube-*-windows-setup.exe dist/Spotube-windows-x86_64-setup.exe
- name: Create Chocolatey Package and set hash
if: ${{ inputs.channel == 'stable' }}
run: |
Set-Variable -Name HASH -Value (Get-FileHash dist\Spotube-windows-x86_64-setup.exe).Hash
sed -i "s/%{{WIN_SHA256}}%/$HASH/" choco-struct/tools/VERIFICATION.txt
make choco
mv dist/spotube.*.nupkg dist/Spotube-windows-x86_64.nupkg
- name: Upload Artifact
uses: actions/upload-artifact@v3
with: with:
if-no-files-found: error distribution: 'zulu'
name: Spotube-Release-Binaries java-version: '17'
path: | cache: 'gradle'
dist/Spotube-windows-x86_64.nupkg check-latest: true
dist/Spotube-windows-x86_64-setup.exe - name: Set up QEMU
if: ${{matrix.platform == 'linux_arm'}}
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
if: ${{matrix.platform == 'linux_arm'}}
uses: docker/setup-buildx-action@v3
- name: Debug With SSH When fails - name: Install ${{matrix.platform}} dependencies
if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }}
uses: mxschmitt/action-tmate@v3
with:
limit-access-to-actor: true
linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2.12.0
with:
cache: true
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Get current date
id: date
run: echo "::set-output name=date::$(date +'%Y-%m-%d')"
- name: Install Dependencies
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 desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse libunwind-dev locate patchelf gir1.2-appindicator3-0.1 libappindicator3-1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev mpv libmpv-dev
- name: Install AppImage Tool
run: |
wget -O appimagetool "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage"
chmod +x appimagetool
mv appimagetool /usr/local/bin/
- name: Replace pubspec version and BUILD_VERSION Env (nightly)
if: ${{ inputs.channel == 'nightly' }}
run: |
curl -sS https://webi.sh/yq | sh
yq -i '.version |= sub("\+\d+", "+${{ inputs.channel }}.")' pubspec.yaml
yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml
echo "BUILD_VERSION=${{ inputs.version }}+${{ inputs.channel }}.${{ github.run_number }}" >> $GITHUB_ENV
- name: BUILD_VERSION Env (stable)
if: ${{ inputs.channel == 'stable' }}
run: |
echo "BUILD_VERSION=${{ inputs.version }}" >> $GITHUB_ENV
- name: Create Stable .env
if: ${{ inputs.channel == 'stable' }}
run: echo '${{ secrets.DOTENV_RELEASE }}' > .env
- name: Create Nightly .env
if: ${{ inputs.channel == 'nightly' }}
run: echo '${{ secrets.DOTENV_NIGHTLY }}' > .env
- name: Replace Version in files
run: |
sed -i 's|%{{APPDATA_RELEASE}}%|<release version="${{ env.BUILD_VERSION }}" date="${{ steps.date.outputs.date }}" />|' linux/com.github.KRTirtho.Spotube.appdata.xml
- name: Generate Secrets
run: |
flutter config --enable-linux-desktop
flutter pub get
dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns
- name: Build Linux Packages
run: |
dart pub global activate flutter_distributor
alias dpkg-deb="dpkg-deb --Zxz"
flutter_distributor package --platform=linux --targets=deb
flutter_distributor package --platform=linux --targets=rpm
- name: Create tar.xz (stable)
if: ${{ inputs.channel == 'stable' }}
run: make tar VERSION=${{ env.BUILD_VERSION }} ARCH=x64 PKG_ARCH=x86_64
- name: Create tar.xz (nightly)
if: ${{ inputs.channel == 'nightly' }}
run: make tar VERSION=nightly ARCH=x64 PKG_ARCH=x86_64
- name: Move Files to dist
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.rpm dist/Spotube-linux-x86_64.rpm
- uses: actions/upload-artifact@v3
if: ${{ inputs.channel == 'stable' }}
with:
if-no-files-found: error
name: Spotube-Release-Binaries
path: |
dist/Spotube-linux-x86_64.deb
dist/Spotube-linux-x86_64.rpm
dist/spotube-linux-${{ env.BUILD_VERSION }}-x86_64.tar.xz
- uses: actions/upload-artifact@v3
if: ${{ inputs.channel == 'nightly' }}
with:
if-no-files-found: error
name: Spotube-Release-Binaries
path: |
dist/Spotube-linux-x86_64.deb
dist/Spotube-linux-x86_64.rpm
dist/spotube-linux-nightly-x86_64.tar.xz
- name: Debug With SSH When fails
if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }}
uses: mxschmitt/action-tmate@v3
with:
limit-access-to-actor: true
android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2.12.0
with:
cache: true
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Install Dependencies
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 xmlstarlet
- name: Replace pubspec version and BUILD_VERSION Env (nightly)
if: ${{ inputs.channel == 'nightly' }}
run: |
curl -sS https://webi.sh/yq | sh
yq -i '.version |= sub("\+\d+", "+${{ inputs.channel }}.")' pubspec.yaml
yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml
echo "BUILD_VERSION=${{ inputs.version }}+${{ inputs.channel }}.${{ github.run_number }}" >> $GITHUB_ENV
- name: BUILD_VERSION Env (stable)
if: ${{ inputs.channel == 'stable' }}
run: |
echo "BUILD_VERSION=${{ inputs.version }}" >> $GITHUB_ENV
- name: Create Stable .env
if: ${{ inputs.channel == 'stable' }}
run: echo '${{ secrets.DOTENV_RELEASE }}' > .env
- name: Create Nightly .env
if: ${{ inputs.channel == 'nightly' }}
run: echo '${{ secrets.DOTENV_NIGHTLY }}' > .env
- name: Generate Secrets
run: | run: |
flutter pub get flutter pub get
dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns dart cli/cli.dart install-dependencies --platform=${{matrix.platform}}
- name: Sign Apk - name: Sign Apk
if: ${{matrix.platform == 'android'}}
run: | run: |
echo '${{ secrets.KEYSTORE }}' | base64 --decode > android/app/upload-keystore.jks echo '${{ secrets.KEYSTORE }}' | base64 --decode > android/app/upload-keystore.jks
echo '${{ secrets.KEY_PROPERTIES }}' > android/key.properties echo '${{ secrets.KEY_PROPERTIES }}' > android/key.properties
- name: Build Apk - name: Build ${{matrix.platform}} binaries
run: | run: dart cli/cli.dart build ${{matrix.platform}}
flutter build apk --flavor ${{ inputs.channel }} env:
mv build/app/outputs/flutter-apk/app-${{ inputs.channel }}-release.apk build/Spotube-android-all-arch.apk CHANNEL: ${{inputs.channel}}
DOTENV: ${{secrets.DOTENV_RELEASE}}
- name: Build Playstore AppBundle
run: |
echo 'ENABLE_UPDATE_CHECK=0' >> .env
dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns
export MANIFEST=android/app/src/main/AndroidManifest.xml
xmlstarlet ed -d '//meta-data[@android:name="com.google.android.gms.car.application"]' $MANIFEST > $MANIFEST.tmp
mv $MANIFEST.tmp $MANIFEST
flutter build appbundle --flavor ${{ inputs.channel }}
mv build/app/outputs/bundle/${{ inputs.channel }}Release/app-${{ inputs.channel }}-release.aab build/Spotube-playstore-all-arch.aab
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v3
with: with:
if-no-files-found: error if-no-files-found: error
name: Spotube-Release-Binaries name: Spotube-Release-Binaries
path: | path: ${{matrix.files}}
build/Spotube-android-all-arch.apk
build/Spotube-playstore-all-arch.aab
- name: Debug With SSH When fails - name: Debug With SSH When fails
if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }}
@ -282,136 +112,12 @@ jobs:
with: with:
limit-access-to-actor: true limit-access-to-actor: true
macos:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2.12.0
with:
cache: true
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Replace pubspec version and BUILD_VERSION Env (nightly)
if: ${{ inputs.channel == 'nightly' }}
run: |
brew install yq
yq -i '.version |= sub("\+\d+", "+${{ inputs.channel }}.")' pubspec.yaml
yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml
echo "BUILD_VERSION=${{ inputs.version }}+${{ inputs.channel }}.${{ github.run_number }}" >> $GITHUB_ENV
- name: BUILD_VERSION Env (stable)
if: ${{ inputs.channel == 'stable' }}
run: |
echo "BUILD_VERSION=${{ inputs.version }}" >> $GITHUB_ENV
- name: Create Stable .env
if: ${{ inputs.channel == 'stable' }}
run: echo '${{ secrets.DOTENV_RELEASE }}' > .env
- name: Create Nightly .env
if: ${{ inputs.channel == 'nightly' }}
run: echo '${{ secrets.DOTENV_NIGHTLY }}' > .env
- name: Generate Secrets
run: |
dart pub global activate flutter_distributor
flutter pub get
dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns
- name: Build Macos App
run: |
flutter config --enable-macos-desktop
flutter build macos
du -sh build/macos/Build/Products/Release/spotube.app
- name: Package Macos App
run: |
brew install python-setuptools
npm install -g appdmg
mkdir -p build/${{ env.BUILD_VERSION }}
appdmg appdmg.json build/Spotube-macos-universal.dmg
flutter_distributor package --platform=macos --targets pkg --skip-clean
mv dist/**/spotube-*-macos.pkg build/Spotube-macos-universal.pkg
- uses: actions/upload-artifact@v3
with:
if-no-files-found: error
name: Spotube-Release-Binaries
path: |
build/Spotube-macos-universal.dmg
build/Spotube-macos-universal.pkg
- name: Debug With SSH When fails
if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }}
uses: mxschmitt/action-tmate@v3
with:
limit-access-to-actor: true
iOS:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2.10.0
with:
cache: true
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Replace pubspec version and BUILD_VERSION Env (nightly)
if: ${{ inputs.channel == 'nightly' }}
run: |
brew install yq
yq -i '.version |= sub("\+\d+", "+${{ inputs.channel }}.")' pubspec.yaml
yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml
echo "BUILD_VERSION=${{ inputs.version }}+${{ inputs.channel }}.${{ github.run_number }}" >> $GITHUB_ENV
- name: BUILD_VERSION Env (stable)
if: ${{ inputs.channel == 'stable' }}
run: |
echo "BUILD_VERSION=${{ inputs.version }}" >> $GITHUB_ENV
- name: Create Stable .env
if: ${{ inputs.channel == 'stable' }}
run: echo '${{ secrets.DOTENV_RELEASE }}' > .env
- name: Create Nightly .env
if: ${{ inputs.channel == 'nightly' }}
run: echo '${{ secrets.DOTENV_NIGHTLY }}' > .env
- name: Generate Secrets
run: |
flutter pub get
dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns
- name: Build iOS iPA
run: |
flutter build ios --release --no-codesign --flavor ${{ inputs.channel }}
ln -sf ./build/ios/iphoneos Payload
zip -r9 Spotube-iOS.ipa Payload/${{ inputs.channel }}.app
- uses: actions/upload-artifact@v3
with:
if-no-files-found: error
name: Spotube-Release-Binaries
path: |
Spotube-iOS.ipa
- name: Debug With SSH When fails
if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }}
uses: mxschmitt/action-tmate@v3
with:
limit-access-to-actor: true
upload: upload:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: needs:
- windows - build_platform
- linux
- android
- macos
- iOS
steps: steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3 - uses: actions/download-artifact@v3
with: with:
name: Spotube-Release-Binaries name: Spotube-Release-Binaries
@ -426,6 +132,10 @@ jobs:
md5sum Spotube-Release-Binaries/* >> RELEASE.md5sum md5sum Spotube-Release-Binaries/* >> RELEASE.md5sum
sha256sum Spotube-Release-Binaries/* >> RELEASE.sha256sum sha256sum Spotube-Release-Binaries/* >> RELEASE.sha256sum
sed -i 's|Spotube-Release-Binaries/||' RELEASE.sha256sum RELEASE.md5sum sed -i 's|Spotube-Release-Binaries/||' RELEASE.sha256sum RELEASE.md5sum
- name: Extract pubspec version
run: |
echo "PUBSPEC_VERSION=$(grep -oP 'version:\s*\K[^+]+(?=\+)' pubspec.yaml)" >> $GITHUB_ENV
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v3
with: with:
@ -440,7 +150,7 @@ jobs:
uses: ncipollo/release-action@v1 uses: ncipollo/release-action@v1
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
tag: v${{ inputs.version }} # mind the "v" prefix tag: v${{ env.PUBSPEC_VERSION }} # mind the "v" prefix
omitBodyDuringUpdate: true omitBodyDuringUpdate: true
omitNameDuringUpdate: true omitNameDuringUpdate: true
omitPrereleaseDuringUpdate: true omitPrereleaseDuringUpdate: true
@ -458,3 +168,8 @@ jobs:
omitPrereleaseDuringUpdate: true omitPrereleaseDuringUpdate: true
allowUpdates: true allowUpdates: true
artifacts: Spotube-Release-Binaries/*,RELEASE.sha256sum,RELEASE.md5sum artifacts: Spotube-Release-Binaries/*,RELEASE.sha256sum,RELEASE.md5sum
body: |
Build Number: ${{github.run_number}}
Nightly release includes newest features but may contain bugs
It is preferred to use the stable version unless you know what you're doing

View File

@ -1,11 +1,11 @@
# This file tracks properties of this Flutter project. # This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc. # Used by Flutter tool to assess capabilities and perform upgrades etc.
# #
# This file should be version controlled. # This file should be version controlled and should not be manually edited.
version: version:
revision: eb6d86ee27deecba4a83536aa20f366a6044895c revision: "300451adae589accbece3490f4396f10bdf15e6e"
channel: stable channel: "stable"
project_type: app project_type: app
@ -13,11 +13,11 @@ project_type: app
migration: migration:
platforms: platforms:
- platform: root - platform: root
create_revision: eb6d86ee27deecba4a83536aa20f366a6044895c create_revision: 300451adae589accbece3490f4396f10bdf15e6e
base_revision: eb6d86ee27deecba4a83536aa20f366a6044895c base_revision: 300451adae589accbece3490f4396f10bdf15e6e
- platform: macos - platform: windows
create_revision: eb6d86ee27deecba4a83536aa20f366a6044895c create_revision: 300451adae589accbece3490f4396f10bdf15e6e
base_revision: eb6d86ee27deecba4a83536aa20f366a6044895c base_revision: 300451adae589accbece3490f4396f10bdf15e6e
# User provided section # User provided section

View File

@ -24,5 +24,6 @@
"explorer.fileNesting.patterns": { "explorer.fileNesting.patterns": {
"pubspec.yaml": "pubspec.lock,analysis_options.yaml,.packages,.flutter-plugins,.flutter-plugins-dependencies,flutter_launcher_icons*.yaml,flutter_native_splash*.yaml", "pubspec.yaml": "pubspec.lock,analysis_options.yaml,.packages,.flutter-plugins,.flutter-plugins-dependencies,flutter_launcher_icons*.yaml,flutter_native_splash*.yaml",
"README.md": "LICENSE,CODE_OF_CONDUCT.md,CONTRIBUTING.md,SECURITY.md,CONTRIBUTION.md,CHANGELOG.md,PRIVACY_POLICY.md", "README.md": "LICENSE,CODE_OF_CONDUCT.md,CONTRIBUTING.md,SECURITY.md,CONTRIBUTION.md,CHANGELOG.md,PRIVACY_POLICY.md",
"*.dart": "${capture}.g.dart,${capture}.freezed.dart",
} }
} }

View File

@ -2,7 +2,56 @@
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
## [3.6.0-0](https://github.com/krtirtho/spotube/compare/v3.5.0...v3.6.0-0) (2024-04-15) ## [3.7.1](https://github.com/krtirtho/spotube/compare/v3.7.1...v3.7.1) (2024-06-06)
### Bug Fixes
* alternative sources not showing up for SongLink matched results ([37d002d](https://github.com/krtirtho/spotube/commit/37d002d133cacb3a34884713ac8f6637694af57c))
* **android:** Media Controls not working above Android 14 [#1561](https://github.com/krtirtho/spotube/issues/1561) ([3394c1b](https://github.com/krtirtho/spotube/commit/3394c1b0574e44dc624b2b2f0bf32f343ae9f049))
* browse anonymously button takes to wrong route ([73c5b30](https://github.com/krtirtho/spotube/commit/73c5b30b63a4c82bb7ad5b52bc10c5594566a800))
* **desktop:** titlebar drag to move not working ([5f280a1](https://github.com/krtirtho/spotube/commit/5f280a19f4d5f8882ae2ff60c6cc595ded7a5a1d))
* **desktop:** window is not centered ([47f98b9](https://github.com/krtirtho/spotube/commit/47f98b98aafab9b426733ed44cab2be8a646a98e))
* **ios:** download not working [#1575](https://github.com/krtirtho/spotube/issues/1575) ([6591ec0](https://github.com/krtirtho/spotube/commit/6591ec0e1b441dd8fd535eace19a58c7749389ca))
* **linux:** application window not visible after launch ([8fc44ed](https://github.com/krtirtho/spotube/commit/8fc44ed6550e8b2b804991ff82df08afb1c94ca8))
* local track not showing up in queue ([d82261c](https://github.com/krtirtho/spotube/commit/d82261cb25ece63f85af0e40216cf32dccdc9dd5))
* use weak match for Jiosaavn fallback to improve matching ([6cb2986](https://github.com/krtirtho/spotube/commit/6cb29868d2030b5e9312863c17e8f24889942e24))
* **windows:** media controls not showing up [#1542](https://github.com/krtirtho/spotube/issues/1542) ([d7d864f](https://github.com/krtirtho/spotube/commit/d7d864ff2bc937675a544a7edf645c5148ec836a))
* **windows:** revert Flutter version to 3.19.6 to avoid distortion [#1553](https://github.com/krtirtho/spotube/issues/1553) ([982cf0b](https://github.com/krtirtho/spotube/commit/982cf0bd435638fa20f17ef527fe21d031b5ffaf))
## [3.7.0](https://github.com/krtirtho/spotube/compare/v3.6.0...v3.7.0) (2024-06-03)
### Features
* local library folder cards ([fc5bfa0](https://github.com/krtirtho/spotube/commit/fc5bfa089ce2f46ab786565d6750564d704ee7e0))
* Local music library ([#1479](https://github.com/krtirtho/spotube/issues/1479)) ([22caa81](https://github.com/krtirtho/spotube/commit/22caa818f4ac31626aaff6952e43512b42237d00))
* personalized stats based on local music history ([#1522](https://github.com/krtirtho/spotube/issues/1522)) ([82307bc](https://github.com/krtirtho/spotube/commit/82307bc030035b03ab1b8d8ec7b24da19a866b12))
* play initially available tracks of playlist/album immediately and fetch rest in background [#670](https://github.com/krtirtho/spotube/issues/670) ([02acbd9](https://github.com/krtirtho/spotube/commit/02acbd93271145dde365f6c547e0d9d902be65f1))
* **player:** add volume slider floating label showing percentage ([#1445](https://github.com/krtirtho/spotube/issues/1445)) ([8fad225](https://github.com/krtirtho/spotube/commit/8fad2251b3536e9468e0fb193939ead98bad3bc6)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082)
* **translations:** add Basque translation ([#1493](https://github.com/krtirtho/spotube/issues/1493)) ([dbc1c45](https://github.com/krtirtho/spotube/commit/dbc1c452dd53153c61589f956ea9836cea7bf2bb))
* **translations:** add Finnish translations ([#1449](https://github.com/krtirtho/spotube/issues/1449)) ([edc997e](https://github.com/krtirtho/spotube/commit/edc997e7470ce17f60c96b8198dc8851cbf21f18)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082)
* **translations:** add georgian language ([#1450](https://github.com/krtirtho/spotube/issues/1450)) ([1e7f0e1](https://github.com/krtirtho/spotube/commit/1e7f0e1fe71e0a8d86614fc884861f8791469112))
* **translations:** add Indonesian translation ([#1426](https://github.com/krtirtho/spotube/issues/1426)) ([0280654](https://github.com/krtirtho/spotube/commit/0280654bb6bad373aee521f5a866228d2d38f038)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082)
* **translations:** Improve tr locales ([#1419](https://github.com/krtirtho/spotube/issues/1419)) ([bf45681](https://github.com/krtirtho/spotube/commit/bf45681deb951c772bf6ca05e213c949c04bded1)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082)
* upgrade to Flutter 3.22.0 ([71341ec](https://github.com/krtirtho/spotube/commit/71341ec0bda6ed985b43836712075b97a2cf8bac))
### Bug Fixes
* fallback to LRCLIB when lyrics line less than 6 lines [#1461](https://github.com/krtirtho/spotube/issues/1461) ([9aea354](https://github.com/krtirtho/spotube/commit/9aea35468fa7cd176ddc8810b37b90c2d8246931))
* **linux:** tray icon not showing [#541](https://github.com/krtirtho/spotube/issues/541) ([7ac7917](https://github.com/krtirtho/spotube/commit/7ac791757abb30f40374c169c4211916287bb3f3))
* local track not showing up in queue ([d82261c](https://github.com/krtirtho/spotube/commit/d82261cb25ece63f85af0e40216cf32dccdc9dd5))
* **macos:** Logs directory not created by default [#1353](https://github.com/krtirtho/spotube/issues/1353) ([4ca8939](https://github.com/krtirtho/spotube/commit/4ca893950b07f678acf7db690112c47d21e54782))
* **playback:** skipping tracks with unplayable sources instead of falling back [#1492](https://github.com/krtirtho/spotube/issues/1492) ([c607a33](https://github.com/krtirtho/spotube/commit/c607a330ed279dfbebe8d4bd325745ac6301a58f))
* **search:** load more button not working [#1417](https://github.com/krtirtho/spotube/issues/1417) ([7e07c2e](https://github.com/krtirtho/spotube/commit/7e07c2e1985da7ccb96b1fac2ecd703720068d26))
* some text are garbled in different parts of the app [#1463](https://github.com/krtirtho/spotube/issues/1463) [#1505](https://github.com/krtirtho/spotube/issues/1505) ([d2683c5](https://github.com/krtirtho/spotube/commit/d2683c52d81d807be6ff72f15b8e9eb18181e211))
* spotify friends and user profile icon (mobile) showing when not authenticated [#1410](https://github.com/krtirtho/spotube/issues/1410) ([9bccbc9](https://github.com/krtirtho/spotube/commit/9bccbc93c63dd34f6e15ff68c276976ecd1d9a33))
* **updater:** dead link ([#1408](https://github.com/krtirtho/spotube/issues/1408)) ([6907f9c](https://github.com/krtirtho/spotube/commit/6907f9c756d8f49aadb1b23a2a1dc8bf7d658dc0)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082)
* windows SSL Certificate error breaking login [#905](https://github.com/krtirtho/spotube/issues/905) ([#1474](https://github.com/krtirtho/spotube/issues/1474)) ([937a706](https://github.com/krtirtho/spotube/commit/937a706ac9c0e59943b2609e5cc398dcdbed2344)), closes [#1468](https://github.com/krtirtho/spotube/issues/1468)
* **windows:** installer tries to install in current directory ([c3c9fc5](https://github.com/krtirtho/spotube/commit/c3c9fc544c68b3d897dd7241a61cab7a199b4539))
## [3.6.0](https://github.com/krtirtho/spotube/compare/v3.5.0...v3.6.0) (2024-04-15)
### Features ### Features

View File

@ -318,7 +318,6 @@ If you are concerned, you can [read the reason of choosing this license](https:/
1. [wikipedia_api](https://github.com/KRTirtho/wikipedia_api) - Wikipedia API for dart and flutter 1. [wikipedia_api](https://github.com/KRTirtho/wikipedia_api) - Wikipedia API for dart and flutter
1. [win32_registry](https://pub.dev/packages/win32_registry) - A package that provides a friendly Dart API for accessing the Windows Registry. 1. [win32_registry](https://pub.dev/packages/win32_registry) - A package that provides a friendly Dart API for accessing the Windows Registry.
1. [window_manager](https://github.com/leanflutter/window_manager) - This plugin allows Flutter desktop apps to resizing and repositioning the window. 1. [window_manager](https://github.com/leanflutter/window_manager) - This plugin allows Flutter desktop apps to resizing and repositioning the window.
1. [window_size](https://github.com/google/flutter-desktop-embedding.git) - Allows resizing and repositioning the window containing Flutter.
1. [xml](https://github.com/renggli/dart-xml) - A lightweight library for parsing, traversing, querying, transforming and building XML documents. 1. [xml](https://github.com/renggli/dart-xml) - A lightweight library for parsing, traversing, querying, transforming and building XML documents.
1. [youtube_explode_dart](https://github.com/Hexer10/youtube_explode_dart) - A port in dart of the youtube explode library. Supports several API functions without the need of Youtube API Key. 1. [youtube_explode_dart](https://github.com/Hexer10/youtube_explode_dart) - A port in dart of the youtube explode library. Supports several API functions without the need of Youtube API Key.
</details> </details>

View File

@ -1,3 +1,9 @@
plugins {
id "com.android.application"
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
}
def localProperties = new Properties() def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties') def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) { if (localPropertiesFile.exists()) {
@ -6,11 +12,6 @@ if (localPropertiesFile.exists()) {
} }
} }
def flutterRoot = localProperties.getProperty('flutter.sdk')
if (flutterRoot == null) {
throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode') def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) { if (flutterVersionCode == null) {
flutterVersionCode = '1' flutterVersionCode = '1'
@ -21,10 +22,6 @@ if (flutterVersionName == null) {
flutterVersionName = '1.0' flutterVersionName = '1.0'
} }
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
def keystoreProperties = new Properties() def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties') def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) { if (keystorePropertiesFile.exists()) {
@ -71,6 +68,9 @@ android {
release { release {
signingConfig signingConfigs.release signingConfig signingConfigs.release
} }
debug {
signingConfig signingConfigs.release
}
} }
flavorDimensions "default" flavorDimensions "default"
@ -81,16 +81,19 @@ android {
resValue "string", "app_name_en", "Spotube Nightly" resValue "string", "app_name_en", "Spotube Nightly"
applicationIdSuffix ".nightly" applicationIdSuffix ".nightly"
versionNameSuffix "-nightly" versionNameSuffix "-nightly"
signingConfig signingConfigs.release
} }
dev { dev {
dimension "default" dimension "default"
resValue "string", "app_name_en", "Spotube Dev" resValue "string", "app_name_en", "Spotube Dev"
applicationIdSuffix ".dev" applicationIdSuffix ".dev"
versionNameSuffix "-dev" versionNameSuffix "-dev"
signingConfig signingConfigs.release
} }
stable { stable {
dimension "default" dimension "default"
resValue "string", "app_name_en", "Spotube" resValue "string", "app_name_en", "Spotube"
signingConfig signingConfigs.release
} }
} }
@ -101,15 +104,6 @@ flutter {
} }
dependencies { dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
constraints {
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version") {
because("kotlin-stdlib-jdk7 is now a part of kotlin-stdlib")
}
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version") {
because("kotlin-stdlib-jdk8 is now a part of kotlin-stdlib")
}
}
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1'
// other deps so just ignore // other deps so just ignore

View File

@ -3,6 +3,7 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" /> <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
@ -24,6 +25,11 @@
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:requestLegacyExternalStorage="true" android:requestLegacyExternalStorage="true"
> >
<!-- Enable Impeller -->
<!-- <meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="true" /> -->
<activity <activity
android:name="com.ryanheise.audioservice.AudioServiceActivity" android:name="com.ryanheise.audioservice.AudioServiceActivity"
android:exported="true" android:exported="true"

View File

@ -1,16 +1,3 @@
buildscript {
ext.kotlin_version = '1.8.22'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.2.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
allprojects { allprojects {
repositories { repositories {
google() google()

View File

@ -1,11 +1,25 @@
include ':app' pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}()
def localPropertiesFile = new File(rootProject.projectDir, "local.properties") includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
def properties = new Properties()
assert localPropertiesFile.exists() repositories {
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } google()
mavenCentral()
gradlePluginPortal()
}
}
def flutterSdkPath = properties.getProperty("flutter.sdk") plugins {
assert flutterSdkPath != null, "flutter.sdk not set in local.properties" id "dev.flutter.flutter-plugin-loader" version "1.0.0"
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" id "com.android.application" version "7.2.1" apply false
id "org.jetbrains.kotlin.android" version "1.8.22" apply false
}
include ":app"

View File

@ -1,103 +0,0 @@
import 'dart:developer';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:http/http.dart';
import 'package:html/parser.dart';
import 'package:pub_api_client/pub_api_client.dart';
import 'package:pubspec_parse/pubspec_parse.dart';
void main() async {
final client = PubClient();
final pubspec = Pubspec.parse(File('pubspec.yaml').readAsStringSync());
final allDeps = [
...pubspec.dependencies.entries,
...pubspec.devDependencies.entries,
];
final dependencies = allDeps
.where((d) => d.value is HostedDependency)
.map((d) => d.key)
.toSet();
final packageInfo = await Future.wait(dependencies.map(client.packageInfo));
final gitDepsList = List.castFrom<MapEntry<String, Dependency>,
MapEntry<String, GitDependency>>(
allDeps
.where((d) => d.value is GitDependency)
.map((d) => MapEntry(d.key, d.value as GitDependency))
.toList(),
);
final gitDeps = gitDepsList.map(
(d) {
final uri = Uri.parse(
d.value.url.toString().replaceAll('.git', ''),
);
return MapEntry(
d.key,
uri.replace(
pathSegments: [
...uri.pathSegments,
'raw',
d.value.ref ?? 'main',
d.value.path ?? '',
'pubspec.yaml',
],
).toString(),
);
},
).toList();
final gitPubspecs = await Future.wait(
gitDeps.map(
(d) {
Pubspec parser(res) {
try {
return Pubspec.parse(res.body);
} catch (e) {
final document = parse(res.body);
final pre = document.querySelector('pre');
if (pre == null) {
log(d.toString());
rethrow;
}
return Pubspec.parse(pre.text);
}
}
return get(Uri.parse(d.value)).then(parser).catchError(
(_) => get(Uri.parse(d.value.replaceFirst('/main', '/master')))
.then(parser),
);
},
),
);
// ignore: avoid_print
print(
packageInfo
.map(
(package) =>
'1. [${package.name}](${package.latestPubspec.homepage ?? package.url}) - ${package.description.replaceAll('\n', '')}',
)
.join('\n'),
);
// ignore: avoid_print
print(
gitPubspecs.map(
(package) {
final packageUrl = package.homepage ??
gitDepsList
.firstWhereOrNull((dep) => dep.key == package.name)
?.value
.url
.toString();
return '1. [${package.name}]($packageUrl) - ${package.description?.replaceAll('\n', '')}';
},
).join('\n'),
);
exit(0);
}

View File

@ -1,28 +0,0 @@
// ignore_for_file: avoid_print
import 'dart:convert';
import 'dart:io';
void main(List<String> args) async {
final translatedFile =
jsonDecode(await File('tm.json').readAsString()) as Map<String, dynamic>;
for (final MapEntry(:key, :value) in translatedFile.entries) {
print('Updating locale: $key');
final file = File('lib/l10n/app_$key.arb');
final fileContent =
jsonDecode(await file.readAsString()) as Map<String, dynamic>;
final newContent = {
...fileContent,
...value,
};
await file.writeAsString(
const JsonEncoder.withIndent(' ').convert(newContent),
);
print('✅ Updated locale: $key');
}
}

View File

@ -1,50 +0,0 @@
// ignore_for_file: avoid_print
import 'dart:convert';
import 'dart:io';
/// Generate JSON output for untranslated messages with English values
/// for quick translation in ChatGPT
///
/// Usage: dart bin/untranslated_messages.dart [locale?]
///
/// Example: dart bin/untranslated_messages.dart
///
/// or with specific locale (e.g. bn (Bengali))
///
/// Example: dart bin/untranslated_messages.dart bn
void main(List<String> args) {
final file = jsonDecode(
File('untranslated_messages.json').readAsStringSync(),
) as Map<String, dynamic>;
final englishMessages =
jsonDecode(File('lib/l10n/app_en.arb').readAsStringSync())
as Map<String, dynamic>;
final messagesWithValues = <String, dynamic>{};
for (final MapEntry(key: locale, value: messages) in file.entries) {
messagesWithValues[locale] = Map.fromEntries(
messages
.map(
(message) =>
MapEntry<String, dynamic>(message, englishMessages[message]),
)
.toList()
.cast<MapEntry<String, dynamic>>(),
);
}
print(
"Prompt:\n"
"Translate following to their appropriate locale for flutter arb translations files."
" Put the respective new translations in a map of their corresponding locale.",
);
print(
const JsonEncoder.withIndent(' ').convert(
args.isNotEmpty ? messagesWithValues[args.first] : messagesWithValues,
),
);
}

View File

@ -1,22 +0,0 @@
import 'dart:convert';
import 'dart:io';
void main() {
Process.run("sh", ["-c", '"./scripts/pkgbuild2json.sh aur-struct/PKGBUILD"'])
.then((result) {
try {
final pkgbuild = jsonDecode(result.stdout);
if (pkgbuild["version"] !=
Platform.environment["RELEASE_VERSION"]?.substring(1)) {
throw Exception(
"PKGBUILD version doesn't match current RELEASE_VERSION");
}
if (pkgbuild["release"] != "1") {
throw Exception("In new releases pkgrel should be 1");
}
} catch (e) {
// ignore: avoid_print
print("[Failed to parse PKGBUILD] $e");
}
});
}

View File

@ -2,4 +2,9 @@ targets:
$default: $default:
sources: sources:
exclude: exclude:
- bin/*.dart - bin/*.dart
builders:
json_serializable:
options:
any_map: true
explicit_to_json: true

4
cli/README.md Normal file
View File

@ -0,0 +1,4 @@
## Spotube Configuration CLI
This is used for building the project for multiple platforms and having utilities specific for the project.
Written in Dart

22
cli/cli.dart Normal file
View File

@ -0,0 +1,22 @@
import 'package:args/command_runner.dart';
import 'commands/build.dart';
import 'commands/credits.dart';
import 'commands/install-dependencies.dart';
import 'commands/translated.dart';
import 'commands/untranslated.dart';
void main(List<String> args) {
final commandRunner = CommandRunner(
"cli",
"Configuration CLI for Spotube",
);
commandRunner.addCommand(InstallDependenciesCommand());
commandRunner.addCommand(BuildCommand());
commandRunner.addCommand(CreditsCommand());
commandRunner.addCommand(TranslatedCommand());
commandRunner.addCommand(UntranslatedCommand());
commandRunner.run(args);
}

25
cli/commands/build.dart Normal file
View File

@ -0,0 +1,25 @@
import 'package:args/command_runner.dart';
import 'build/android.dart';
import 'build/ios.dart';
import 'build/linux.dart';
import 'build/linux_arm.dart';
import 'build/macos.dart';
import 'build/windows.dart';
class BuildCommand extends Command {
@override
String get description => "Build for different platforms";
@override
String get name => "build";
BuildCommand() {
addSubcommand(AndroidBuildCommand());
addSubcommand(IosBuildCommand());
addSubcommand(LinuxBuildCommand());
addSubcommand(LinuxArmBuildCommand());
addSubcommand(MacosBuildCommand());
addSubcommand(WindowsBuildCommand());
}
}

View File

@ -0,0 +1,90 @@
import 'dart:async';
import 'dart:io';
import 'package:args/command_runner.dart';
import 'package:collection/collection.dart';
import 'package:path/path.dart';
import 'package:xml/xml.dart';
import '../../core/env.dart';
import 'common.dart';
class AndroidBuildCommand extends Command with BuildCommandCommonSteps {
@override
String get description => "Build for android";
@override
String get name => "android";
@override
FutureOr? run() async {
await bootstrap();
await shell.run(
"flutter build apk --flavor ${CliEnv.channel.name}",
);
await dotEnvFile.writeAsString(
"\nENABLE_UPDATE_CHECK=0",
mode: FileMode.append,
);
final androidManifestFile = File(
join(cwd.path, "android", "app", "src", "main", "AndroidManifest.xml"));
final androidManifestXml =
XmlDocument.parse(await androidManifestFile.readAsString());
final deletingElement =
androidManifestXml.findAllElements("meta-data").firstWhereOrNull(
(el) =>
el.getAttribute("android:name") ==
"com.google.android.gms.car.application",
);
deletingElement?.parent?.children.remove(deletingElement);
await androidManifestFile.writeAsString(
androidManifestXml.toXmlString(pretty: true),
);
await shell.run(
"""
dart run build_runner build --delete-conflicting-outputs
flutter build appbundle --flavor ${CliEnv.channel.name}
""",
);
final ogApkFile = File(
join(
"build",
"app",
"outputs",
"flutter-apk",
"app-${CliEnv.channel.name}-release.apk",
),
);
await ogApkFile.copy(
join(cwd.path, "build", "Spotube-android-all-arch.apk"),
);
final ogAppbundleFile = File(
join(
cwd.path,
"build",
"app",
"outputs",
"bundle",
"${CliEnv.channel.name}Release",
"app-${CliEnv.channel.name}-release.aab",
),
);
await ogAppbundleFile.copy(
join(cwd.path, "build", "Spotube-playstore-all-arch.aab"),
);
stdout.writeln("✅ Built Android Apk and Appbundle");
}
}

View File

@ -0,0 +1,66 @@
import 'dart:io';
import 'package:args/command_runner.dart';
import 'package:path/path.dart';
import 'package:process_run/shell_run.dart';
import 'package:pubspec_parse/pubspec_parse.dart';
import '../../core/env.dart';
mixin BuildCommandCommonSteps on Command {
final shell = Shell();
Directory get cwd => Directory.current;
Pubspec? _pubspec;
Pubspec get pubspec {
if (_pubspec != null) {
return _pubspec!;
}
final pubspecFile = File(join(cwd.path, "pubspec.yaml"));
_pubspec = Pubspec.parse(pubspecFile.readAsStringSync());
return _pubspec!;
}
String get versionWithoutBuildNumber {
return "${pubspec.version!.major}.${pubspec.version!.minor}.${pubspec.version!.patch}";
}
RegExp get versionVarRegExp =>
RegExp(r"\%\{\{SPOTUBE_VERSION\}\}\%", multiLine: true);
File get dotEnvFile => File(join(cwd.path, ".env"));
Future<void> bootstrap() async {
await dotEnvFile.create(recursive: true);
await dotEnvFile.writeAsString(
"${CliEnv.dotenv}\n"
"RELEASE_CHANNEL=${CliEnv.channel.name}\n",
);
if (CliEnv.channel == BuildChannel.nightly) {
final pubspecFile = File(join(cwd.path, "pubspec.yaml"));
pubspecFile.writeAsStringSync(
pubspecFile.readAsStringSync().replaceAll(
"version: ${pubspec.version!.canonicalizedVersion}",
"version: $versionWithoutBuildNumber+${CliEnv.ghRunNumber}",
),
);
_pubspec = null;
pubspec;
}
await shell.run(
"""
flutter pub get
dart run build_runner build --delete-conflicting-outputs
dart pub global activate flutter_distributor
""",
);
}
}

View File

@ -0,0 +1,29 @@
import 'dart:async';
import 'package:args/command_runner.dart';
import 'package:path/path.dart';
import '../../core/env.dart';
import 'common.dart';
class IosBuildCommand extends Command with BuildCommandCommonSteps {
@override
String get description => "iOS build command";
@override
String get name => "ios";
@override
FutureOr? run() async {
await bootstrap();
final buildDirPath = join(cwd.path, "build", "ios", "iphoneos");
await shell.run(
"""
flutter build ios --release --no-codesign --flavor ${CliEnv.channel.name}
ln -sf $buildDirPath Payload
zip -r9 Spotube-iOS.ipa ${join("Payload", "${CliEnv.channel.name}.app")}
""",
);
}
}

View File

@ -0,0 +1,106 @@
import 'dart:async';
import 'dart:io';
import 'package:io/io.dart';
import 'package:args/command_runner.dart';
import 'package:intl/intl.dart';
import 'package:path/path.dart';
import '../../core/env.dart';
import 'common.dart';
class LinuxBuildCommand extends Command with BuildCommandCommonSteps {
@override
String get description => "Linux build command";
@override
String get name => "linux";
@override
FutureOr? run() async {
stdout.writeln("Replacing versions");
final appDataFile = File(
join(cwd.path, "linux", "com.github.KRTirtho.Spotube.appdata.xml"),
);
appDataFile.writeAsStringSync(
appDataFile.readAsStringSync().replaceAll(
versionVarRegExp,
'<release'
' version="$versionWithoutBuildNumber"'
' date="${DateFormat("yyyy-MM-dd").format(DateTime.now())}"'
'/>',
),
);
await bootstrap();
await shell.run(
"""
flutter_distributor package --platform=linux --targets=deb
flutter_distributor package --platform=linux --targets=rpm
""",
);
final tempDir = join(Directory.systemTemp.path, "spotube-tar");
final bundleDirPath =
join(cwd.path, "build", "linux", "x64", "release", "bundle");
final tarFile = File(join(
cwd.path,
"dist",
"spotube-linux-"
"${CliEnv.channel == BuildChannel.nightly ? "nightly" : versionWithoutBuildNumber}"
"-x86_64.tar.xz",
));
await copyPath(bundleDirPath, tempDir);
await File(join(cwd.path, "linux", "spotube.desktop")).copy(
join(tempDir, "spotube.desktop"),
);
await File(
join(cwd.path, "linux", "com.github.KRTirtho.Spotube.appdata.xml"),
).copy(
join(tempDir, "com.github.KRTirtho.Spotube.appdata.xml"),
);
await File(join(cwd.path, "assets", "spotube-logo.png")).copy(
join(tempDir, "spotube-logo.png"),
);
await shell.run(
"tar -cJf ${tarFile.path} -C $tempDir .",
);
final ogDeb = File(
join(
cwd.path,
"dist",
pubspec.version.toString(),
"spotube-${pubspec.version}-linux.deb",
),
);
final ogRpm = File(
join(
cwd.path,
"dist",
pubspec.version.toString(),
"spotube-${pubspec.version}-linux.rpm",
),
);
await ogDeb.copy(
join(cwd.path, "dist", "Spotube-linux-x86_64.deb"),
);
await ogRpm.copy(
join(cwd.path, "dist", "Spotube-linux-x86_64.rpm"),
);
await ogDeb.delete();
await ogRpm.delete();
stdout.writeln("✅ Linux building done");
}
}

View File

@ -0,0 +1,37 @@
import 'dart:async';
import 'package:args/command_runner.dart';
import 'package:path/path.dart';
import '../../core/env.dart';
import 'common.dart';
class LinuxArmBuildCommand extends Command with BuildCommandCommonSteps {
@override
String get description => "Build Linux Arm";
@override
String get name => "linux_arm";
@override
FutureOr? run() async {
await bootstrap();
await shell.run(
"docker buildx build --platform=linux/arm64 "
"-f ${join(cwd.path, ".github", "Dockerfile")} ${cwd.path} "
"--build-arg FLUTTER_VERSION=${CliEnv.flutterVersion} "
"--build-arg BUILD_VERSION=${CliEnv.channel == BuildChannel.nightly ? "nightly" : versionWithoutBuildNumber} "
"-t krtirtho/spotube_linux_arm:latest "
"--load",
);
await shell.run(
"""
docker images ls
docker create --name spotube_linux_arm krtirtho/spotube_linux_arm:latest
docker cp spotube_linux_arm:/app/dist/ dist/
""",
);
}
}

View File

@ -0,0 +1,42 @@
import 'dart:async';
import 'dart:io';
import 'package:args/command_runner.dart';
import 'package:path/path.dart';
import 'common.dart';
class MacosBuildCommand extends Command with BuildCommandCommonSteps {
@override
String get description => "Macos Build command";
@override
String get name => "macos";
@override
FutureOr? run() async {
await bootstrap();
await shell.run(
"""
flutter build macos
appdmg appdmg.json ${join(cwd.path, "build", "Spotube-macos-universal.dmg")}
flutter_distributor package --platform=macos --targets pkg --skip-clean
""",
);
final ogPkg = File(
join(
cwd.path,
"dist",
pubspec.version.toString(),
"spotube-${pubspec.version}-macos.pkg",
),
);
await ogPkg.copy(
join(cwd.path, "build", "Spotube-macos-universal.pkg"),
);
await ogPkg.delete();
}
}

View File

@ -0,0 +1,100 @@
import 'dart:io';
import 'package:args/command_runner.dart';
import 'package:path/path.dart';
import 'package:crypto/crypto.dart';
import 'common.dart';
class WindowsBuildCommand extends Command with BuildCommandCommonSteps {
@override
String get description => "Build Windows exe";
@override
String get name => "windows";
Future<void> innoDependInstall() async {
final innoDependencyPath = join(cwd.path, "build", "inno-depend");
await shell.run(
"git clone https://github.com/DomGries/InnoDependencyInstaller.git $innoDependencyPath",
);
}
@override
void run() async {
stdout.writeln("Replace versions");
final chocoFiles = [
join(cwd.path, "choco-struct", "tools", "VERIFICATION.txt"),
join(cwd.path, "choco-struct", "spotube.nuspec"),
];
for (final filePath in chocoFiles) {
final file = File(filePath);
final content = file.readAsStringSync();
final newContent =
content.replaceAll(versionVarRegExp, versionWithoutBuildNumber);
file.writeAsStringSync(newContent);
}
await bootstrap();
await innoDependInstall();
await shell.run(
"flutter_distributor package --platform=windows --targets=exe --skip-clean",
);
final ogExe = File(
join(
cwd.path,
"dist",
pubspec.version.toString(),
"spotube-${pubspec.version}-windows-setup.exe",
),
);
final exePath = join(cwd.path, "dist", "Spotube-windows-x86_64-setup.exe");
await ogExe.copy(exePath);
await ogExe.delete();
stdout.writeln("✅ Windows exe built at $exePath");
final exeFile = File(exePath);
final hash = sha256.convert(await exeFile.readAsBytes()).toString();
final chocoVerificationFile = File(chocoFiles.first);
chocoVerificationFile.writeAsStringSync(
chocoVerificationFile.readAsStringSync().replaceAll(
RegExp(r"\%\{\{WIN_SHA256\}\}\%"),
hash,
),
);
await exeFile.copy(
join(cwd.path, "choco-struct", "tools", basename(exeFile.path)),
);
await shell.run(
"choco pack ${chocoFiles[1]} --outputdirectory ${join(cwd.path, "dist")}",
);
final chocoNupkg = File(
join(cwd.path, "dist", "spotube.$versionWithoutBuildNumber.nupkg"),
);
final distNupkgPath = join(
cwd.path,
"dist",
"Spotube-windows-x86_64.nupkg",
);
await chocoNupkg.copy(distNupkgPath);
await chocoNupkg.delete();
stdout.writeln("✅ Windows nupkg built at $distNupkgPath");
}
}

121
cli/commands/credits.dart Normal file
View File

@ -0,0 +1,121 @@
import 'dart:io';
import 'package:args/command_runner.dart';
import 'package:collection/collection.dart';
import 'package:dio/dio.dart';
import 'package:html/parser.dart';
import 'package:path/path.dart';
import 'package:pub_api_client/pub_api_client.dart';
import 'package:pubspec_parse/pubspec_parse.dart';
class CreditsCommand extends Command {
final dio = Dio(
BaseOptions(
responseType: ResponseType.plain,
),
);
@override
String get description => "Generate credits for used Library's authors";
@override
String get name => "credits";
@override
run() async {
final client = PubClient();
final cwd = Directory.current;
final pubspec = Pubspec.parse(
File(join(cwd.path, 'pubspec.yaml')).readAsStringSync(),
);
final allDeps = [
...pubspec.dependencies.entries,
...pubspec.devDependencies.entries,
];
final dependencies = allDeps
.where((d) => d.value is HostedDependency)
.map((d) => d.key)
.toSet();
final packageInfo = await Future.wait(dependencies.map(client.packageInfo));
final gitDepsList = List.castFrom<MapEntry<String, Dependency>,
MapEntry<String, GitDependency>>(
allDeps
.where((d) => d.value is GitDependency)
.map((d) => MapEntry(d.key, d.value as GitDependency))
.toList(),
);
final gitDeps = gitDepsList.map(
(d) {
final uri = Uri.parse(
d.value.url.toString().replaceAll('.git', ''),
);
return MapEntry(
d.key,
uri.replace(
pathSegments: [
...uri.pathSegments,
'raw',
d.value.ref ?? 'main',
d.value.path ?? '',
'pubspec.yaml',
],
).toString(),
);
},
).toList();
final gitPubspecs = await Future.wait(
gitDeps.map(
(d) {
Pubspec parser(Response res) {
try {
return Pubspec.parse(res.data);
} catch (e) {
final document = parse(res.data);
final pre = document.querySelector('pre');
if (pre == null) {
stdout.writeln(d.toString());
rethrow;
}
return Pubspec.parse(pre.text);
}
}
return dio.get(d.value).then(parser).catchError(
(_) => dio
.get(d.value.replaceFirst('/main', '/master'))
.then(parser),
);
},
),
);
stdout.writeln(
packageInfo
.map(
(package) =>
'1. [${package.name}](${package.latestPubspec.homepage ?? package.url}) - ${package.description.replaceAll('\n', '')}',
)
.join('\n'),
);
stdout.writeln(
gitPubspecs.map(
(package) {
final packageUrl = package.homepage ??
gitDepsList
.firstWhereOrNull((dep) => dep.key == package.name)
?.value
.url
.toString();
return '1. [${package.name}]($packageUrl) - ${package.description?.replaceAll('\n', '')}';
},
).join('\n'),
);
}
}

View File

@ -0,0 +1,74 @@
import 'dart:async';
import 'package:args/command_runner.dart';
import 'package:process_run/shell_run.dart';
class InstallDependenciesCommand extends Command {
@override
String get description => "Install platform dependencies";
@override
String get name => "install-dependencies";
InstallDependenciesCommand() {
argParser.addOption(
"platform",
abbr: "p",
allowed: [
"windows",
"linux",
"linux_arm",
"macos",
"ios",
"android",
],
mandatory: true,
);
}
@override
FutureOr? run() async {
final shell = Shell();
switch (argResults!.option("platform")) {
case "windows":
break;
case "linux":
await shell.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 desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse libunwind-dev locate patchelf gir1.2-appindicator3-0.1 libappindicator3-1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev mpv libmpv-dev
""",
);
break;
case "linux_arm":
await shell.run(
"""
sudo apt-get update -y
sudo apt-get install -y pkg-config make python3-pip python3-setuptools
""",
);
break;
case "macos":
await shell.run(
"""
brew install python-setuptools
npm install -g appdmg
""",
);
break;
case "ios":
break;
case "android":
await shell.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
""",
);
break;
default:
break;
}
}
}

View File

@ -0,0 +1,39 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:args/command_runner.dart';
import 'package:path/path.dart';
class TranslatedCommand extends Command {
@override
String get description =>
"Update translation based on generated translated messages";
@override
String get name => "translated";
@override
FutureOr? run() async {
final cwd = Directory.current;
final translatedFile = jsonDecode(
await File(join(cwd.path, 'tm.json')).readAsString(),
) as Map<String, dynamic>;
for (final MapEntry(:key, :value) in translatedFile.entries) {
stdout.writeln('Updating locale: $key');
final file = File(join(cwd.path, 'lib', 'l10n', 'app_$key.arb'));
final fileContent =
jsonDecode(await file.readAsString()) as Map<String, dynamic>;
final newContent = {...fileContent, ...value};
await file.writeAsString(
const JsonEncoder.withIndent(' ').convert(newContent),
);
stdout.writeln('✅ Updated locale: $key');
}
}
}

View File

@ -0,0 +1,48 @@
import 'package:args/command_runner.dart';
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart';
class UntranslatedCommand extends Command {
@override
get name => "untranslated";
@override
get description =>
"Generate Untranslated Messages for ChatGPT based Translation";
@override
run() async {
final cwd = Directory.current;
final file = jsonDecode(
File(join(cwd.path, 'untranslated_messages.json')).readAsStringSync(),
) as Map<String, dynamic>;
final englishMessages = jsonDecode(
File(join(cwd.path, 'lib', 'l10n', 'app_en.arb')).readAsStringSync(),
) as Map<String, dynamic>;
final messagesWithValues = <String, dynamic>{};
for (final MapEntry(key: locale, value: messages) in file.entries) {
messagesWithValues[locale] = Map.fromEntries(
messages
.map(
(message) =>
MapEntry<String, dynamic>(message, englishMessages[message]),
)
.toList()
.cast<MapEntry<String, dynamic>>(),
);
}
stdout.writeln(
"Prompt:\n"
"Translate following to their appropriate locale for flutter arb translations files."
" Put the respective new translations in a map of their corresponding locale.",
);
stdout.writeln(
const JsonEncoder.withIndent(' ').convert(messagesWithValues),
);
}
}

24
cli/core/env.dart Normal file
View File

@ -0,0 +1,24 @@
import 'dart:io';
enum BuildChannel {
stable,
nightly;
factory BuildChannel.fromEnvironment(String name) {
final channel = Platform.environment[name]!;
if (channel == "stable") {
return BuildChannel.stable;
} else if (channel == "nightly") {
return BuildChannel.nightly;
} else {
throw Exception("Invalid channel: $channel");
}
}
}
class CliEnv {
static final channel = BuildChannel.fromEnvironment("CHANNEL");
static final dotenv = Platform.environment["DOTENV"]!;
static final ghRunNumber = Platform.environment["GITHUB_RUN_NUMBER"];
static final flutterVersion = Platform.environment["FLUTTER_VERSION"]!;
}

1
devtools_options.yaml Normal file
View File

@ -0,0 +1 @@
extensions:

View File

@ -69,9 +69,6 @@ PODS:
- fluttertoast (0.0.2): - fluttertoast (0.0.2):
- Flutter - Flutter
- Toast - Toast
- FMDB (2.7.5):
- FMDB/standard (= 2.7.5)
- FMDB/standard (2.7.5)
- image_picker_ios (0.0.1): - image_picker_ios (0.0.1):
- Flutter - Flutter
- integration_test (0.0.1): - integration_test (0.0.1):
@ -87,7 +84,7 @@ PODS:
- path_provider_foundation (0.0.1): - path_provider_foundation (0.0.1):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- permission_handler_apple (9.1.1): - permission_handler_apple (9.3.0):
- Flutter - Flutter
- SDWebImage (5.18.8): - SDWebImage (5.18.8):
- SDWebImage/Core (= 5.18.8) - SDWebImage/Core (= 5.18.8)
@ -97,7 +94,7 @@ PODS:
- FlutterMacOS - FlutterMacOS
- sqflite (0.0.3): - sqflite (0.0.3):
- Flutter - Flutter
- FMDB (>= 2.7.5) - FlutterMacOS
- SwiftyGif (5.4.4) - SwiftyGif (5.4.4)
- Toast (4.0.0) - Toast (4.0.0)
- url_launcher_ios (0.0.1): - url_launcher_ios (0.0.1):
@ -129,14 +126,13 @@ DEPENDENCIES:
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite (from `.symlinks/plugins/sqflite/ios`) - sqflite (from `.symlinks/plugins/sqflite/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
SPEC REPOS: SPEC REPOS:
trunk: trunk:
- DKImagePickerController - DKImagePickerController
- DKPhotoGallery - DKPhotoGallery
- FMDB
- OrderedSet - OrderedSet
- SDWebImage - SDWebImage
- SwiftyGif - SwiftyGif
@ -194,45 +190,44 @@ EXTERNAL SOURCES:
shared_preferences_foundation: shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin" :path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite: sqflite:
:path: ".symlinks/plugins/sqflite/ios" :path: ".symlinks/plugins/sqflite/darwin"
url_launcher_ios: url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios" :path: ".symlinks/plugins/url_launcher_ios/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
app_links: 5ef33d0d295a89d9d16bb81b0e3b0d5f70d6c875 app_links: e70ca16b4b0f88253b3b3660200d4a10b4ea9795
audio_service: f509d65da41b9521a61f1c404dd58651f265a567 audio_service: f509d65da41b9521a61f1c404dd58651f265a567
audio_session: 4f3e461722055d21515cf3261b64c973c062f345 audio_session: 088d2483ebd1dc43f51d253d4a1c517d9a2e7207
bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842 bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842
device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6 device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
file_selector_ios: 8c25d700d625e1dcdd6599f2d927072f2254647b file_selector_ios: 78baf21d03f1e37a7df97bb2494f9cd86de8fa5d
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_broadcasts: 3ece15b27d8ccbe2132c3df303e7c3401feab882 flutter_broadcasts: 3ece15b27d8ccbe2132c3df303e7c3401feab882
flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0 flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0
flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069
flutter_mailer: 2ef5a67087bc8c6c4cefd04a178bf1ae2c94cd83 flutter_mailer: 2ef5a67087bc8c6c4cefd04a178bf1ae2c94cd83
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be
flutter_sharing_intent: e35380d0e1501d7111dbb7e46d5ac6339da6da98 flutter_sharing_intent: e35380d0e1501d7111dbb7e46d5ac6339da6da98
fluttertoast: 31b00dabfa7fb7bacd9e7dbee580d7a2ff4bf265 fluttertoast: 9f2f8e81bb5ce18facb9748d7855bf5a756fe3db
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a image_picker_ios: b545a5f16c0fa88e3ecbbce3ed4de45567a8ec18
image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5 integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4
integration_test: 13825b8a9334a850581300559b8839134b124670
media_kit_libs_ios_audio: 8f39d96a9c630685dfb844c289bd1d114c486fb3 media_kit_libs_ios_audio: 8f39d96a9c630685dfb844c289bd1d114c486fb3
media_kit_native_event_loop: 99111eded5acbdc9c2738021ea6550dd36ca8837 media_kit_native_event_loop: 99111eded5acbdc9c2738021ea6550dd36ca8837
metadata_god: eceae399d0020475069a5cebc35943ce8562b5d7 metadata_god: eceae399d0020475069a5cebc35943ce8562b5d7
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7 package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c
permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6 permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
SDWebImage: a81bbb3ba4ea5f810f4069c68727cb118467a04a SDWebImage: a81bbb3ba4ea5f810f4069c68727cb118467a04a
shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695
sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4 url_launcher_ios: 6116280ddcfe98ab8820085d8d76ae7449447586
PODFILE CHECKSUM: 0659b64ac6e9e96b61d8550decffa8bff51a957e PODFILE CHECKSUM: 0659b64ac6e9e96b61d8550decffa8bff51a957e

View File

@ -324,6 +324,7 @@
9705A1C41CF9048500538489 /* Embed Frameworks */, 9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
6E9FEF583EA597C8B76255B2 /* [CP] Embed Pods Frameworks */, 6E9FEF583EA597C8B76255B2 /* [CP] Embed Pods Frameworks */,
46F6EB27C31C41D86428A28B /* [CP] Copy Pods Resources */,
); );
buildRules = ( buildRules = (
); );
@ -346,6 +347,7 @@
B536BD992B405DB1009B3CE4 /* Embed Frameworks */, B536BD992B405DB1009B3CE4 /* Embed Frameworks */,
B536BD9A2B405DB1009B3CE4 /* Thin Binary */, B536BD9A2B405DB1009B3CE4 /* Thin Binary */,
A6D446F111DE4C4A202BE7F7 /* [CP] Embed Pods Frameworks */, A6D446F111DE4C4A202BE7F7 /* [CP] Embed Pods Frameworks */,
2DEF3CF18D30E819C0FF4BCE /* [CP] Copy Pods Resources */,
); );
buildRules = ( buildRules = (
); );
@ -368,6 +370,7 @@
B536BDB62B405FDE009B3CE4 /* Embed Frameworks */, B536BDB62B405FDE009B3CE4 /* Embed Frameworks */,
B536BDB72B405FDE009B3CE4 /* Thin Binary */, B536BDB72B405FDE009B3CE4 /* Thin Binary */,
244D41CE80E4BC0FFD63F8C6 /* [CP] Embed Pods Frameworks */, 244D41CE80E4BC0FFD63F8C6 /* [CP] Embed Pods Frameworks */,
4DD66E9E53D92195290872BE /* [CP] Copy Pods Resources */,
); );
buildRules = ( buildRules = (
); );
@ -390,6 +393,7 @@
B536BDD82B4060B3009B3CE4 /* Embed Frameworks */, B536BDD82B4060B3009B3CE4 /* Embed Frameworks */,
B536BDD92B4060B3009B3CE4 /* Thin Binary */, B536BDD92B4060B3009B3CE4 /* Thin Binary */,
D566C841A84D807A607F6DE5 /* [CP] Embed Pods Frameworks */, D566C841A84D807A607F6DE5 /* [CP] Embed Pods Frameworks */,
5C9D945D6569D9C3AC420285 /* [CP] Copy Pods Resources */,
); );
buildRules = ( buildRules = (
); );
@ -523,6 +527,23 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0; showEnvVarsInLog = 0;
}; };
2DEF3CF18D30E819C0FF4BCE /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-stable/Pods-stable-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-stable/Pods-stable-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-stable/Pods-stable-resources.sh\"\n";
showEnvVarsInLog = 0;
};
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1; alwaysOutOfDate = 1;
@ -539,6 +560,57 @@
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
}; };
46F6EB27C31C41D86428A28B /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
4DD66E9E53D92195290872BE /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-dev/Pods-dev-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-dev/Pods-dev-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-dev/Pods-dev-resources.sh\"\n";
showEnvVarsInLog = 0;
};
5C9D945D6569D9C3AC420285 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-nightly/Pods-nightly-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-nightly/Pods-nightly-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-nightly/Pods-nightly-resources.sh\"\n";
showEnvVarsInLog = 0;
};
5CD4405E93760FBD048E36E2 /* [CP] Check Pods Manifest.lock */ = { 5CD4405E93760FBD048E36E2 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;

View File

@ -1,8 +1,13 @@
import 'package:envied/envied.dart'; import 'package:envied/envied.dart';
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:spotube/utils/platform.dart';
part 'env.g.dart'; part 'env.g.dart';
enum ReleaseChannel {
nightly,
stable,
}
@Envied(obfuscate: true, requireEnvFile: true, path: ".env") @Envied(obfuscate: true, requireEnvFile: true, path: ".env")
abstract class Env { abstract class Env {
@EnviedField(varName: 'SPOTIFY_SECRETS') @EnviedField(varName: 'SPOTIFY_SECRETS')
@ -25,8 +30,15 @@ abstract class Env {
@EnviedField(varName: 'ENABLE_UPDATE_CHECK', defaultValue: "1") @EnviedField(varName: 'ENABLE_UPDATE_CHECK', defaultValue: "1")
static final String _enableUpdateChecker = _Env._enableUpdateChecker; static final String _enableUpdateChecker = _Env._enableUpdateChecker;
@EnviedField(varName: "RELEASE_CHANNEL", defaultValue: "nightly")
static final String _releaseChannel = _Env._releaseChannel;
static ReleaseChannel get releaseChannel => _releaseChannel == "stable"
? ReleaseChannel.stable
: ReleaseChannel.nightly;
static bool get enableUpdateChecker => static bool get enableUpdateChecker =>
DesktopTools.platform.isFlatpak || _enableUpdateChecker == "1"; kIsFlatpak || _enableUpdateChecker == "1";
static String discordAppId = "1176718791388975124"; static String discordAppId = "1176718791388975124";
} }

View File

@ -1,5 +1,4 @@
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/track.dart';
import 'package:spotube/models/spotify/home_feed.dart'; import 'package:spotube/models/spotify/home_feed.dart';
import 'package:spotube/models/spotify_friends.dart'; import 'package:spotube/models/spotify_friends.dart';

View File

@ -0,0 +1,8 @@
import 'package:intl/intl.dart';
final compactNumberFormatter = NumberFormat.compact();
final usdFormatter = NumberFormat.compactCurrency(
locale: 'en-US',
symbol: r"$",
decimalDigits: 2,
);

View File

@ -1,9 +1,10 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
import 'package:spotube/utils/platform.dart';
import 'package:win32_registry/win32_registry.dart'; import 'package:win32_registry/win32_registry.dart';
Future<void> registerWindowsScheme(String scheme) async { Future<void> registerWindowsScheme(String scheme) async {
if (!DesktopTools.platform.isWindows) return; if (!kIsWindows) return;
String appPath = Platform.resolvedExecutable; String appPath = Platform.resolvedExecutable;
String protocolRegKey = 'Software\\Classes\\$scheme'; String protocolRegKey = 'Software\\Classes\\$scheme';

View File

@ -7,6 +7,10 @@ import 'package:go_router/go_router.dart';
import 'package:spotube/collections/routes.dart'; import 'package:spotube/collections/routes.dart';
import 'package:spotube/components/player/player_controls.dart'; import 'package:spotube/components/player/player_controls.dart';
import 'package:spotube/models/logger.dart'; import 'package:spotube/models/logger.dart';
import 'package:spotube/pages/home/home.dart';
import 'package:spotube/pages/library/library.dart';
import 'package:spotube/pages/lyrics/lyrics.dart';
import 'package:spotube/pages/search/search.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
@ -67,16 +71,16 @@ class HomeTabAction extends Action<HomeTabIntent> {
final router = intent.ref.read(routerProvider); final router = intent.ref.read(routerProvider);
switch (intent.tab) { switch (intent.tab) {
case HomeTabs.browse: case HomeTabs.browse:
router.go("/"); router.goNamed(HomePage.name);
break; break;
case HomeTabs.search: case HomeTabs.search:
router.go("/search"); router.goNamed(SearchPage.name);
break; break;
case HomeTabs.library: case HomeTabs.library:
router.go("/library"); router.goNamed(LibraryPage.name);
break; break;
case HomeTabs.lyrics: case HomeTabs.lyrics:
router.go("/lyrics"); router.goNamed(LyricsPage.name);
break; break;
} }
return null; return null;

View File

@ -81,10 +81,10 @@ abstract class LanguageLocals {
// name: "Bashkir", // name: "Bashkir",
// nativeName: "башҡорт теле", // nativeName: "башҡорт теле",
// ), // ),
// "eu": const ISOLanguageName( "eu": const ISOLanguageName(
// name: "Basque", name: "Basque",
// nativeName: "euskara,", nativeName: "euskara",
// ), ),
// "be": const ISOLanguageName( // "be": const ISOLanguageName(
// name: "Belarusian", // name: "Belarusian",
// nativeName: "Беларуская", // nativeName: "Беларуская",
@ -197,10 +197,10 @@ abstract class LanguageLocals {
// name: "Fijian", // name: "Fijian",
// nativeName: "vosa Vakaviti", // nativeName: "vosa Vakaviti",
// ), // ),
// "fi": const ISOLanguageName( "fi": const ISOLanguageName(
// name: "Finnish", name: "Finnish",
// nativeName: "suomi", nativeName: "suomi",
// ), ),
"fr": const ISOLanguageName( "fr": const ISOLanguageName(
name: "French", name: "French",
nativeName: "français", nativeName: "français",
@ -213,10 +213,10 @@ abstract class LanguageLocals {
// name: "Galician", // name: "Galician",
// nativeName: "Galego", // nativeName: "Galego",
// ), // ),
// "ka": const ISOLanguageName( "ka": const ISOLanguageName(
// name: "Georgian", name: "Georgian",
// nativeName: "ქართული", nativeName: "ქართული",
// ), ),
"de": const ISOLanguageName( "de": const ISOLanguageName(
name: "German", name: "German",
nativeName: "Deutsch", nativeName: "Deutsch",
@ -265,10 +265,10 @@ abstract class LanguageLocals {
// name: "Interlingua", // name: "Interlingua",
// nativeName: "Interlingua", // nativeName: "Interlingua",
// ), // ),
// "id": const ISOLanguageName( "id": const ISOLanguageName(
// name: "Indonesian", name: "Indonesian",
// nativeName: "Bahasa Indonesia", nativeName: "Bahasa Indonesia",
// ), ),
// "ie": const ISOLanguageName( // "ie": const ISOLanguageName(
// name: "Interlingue", // name: "Interlingue",
// nativeName: "Occidental", // nativeName: "Occidental",

View File

@ -14,6 +14,7 @@ import 'package:spotube/pages/home/genres/genre_playlists.dart';
import 'package:spotube/pages/home/genres/genres.dart'; import 'package:spotube/pages/home/genres/genres.dart';
import 'package:spotube/pages/home/home.dart'; import 'package:spotube/pages/home/home.dart';
import 'package:spotube/pages/lastfm_login/lastfm_login.dart'; import 'package:spotube/pages/lastfm_login/lastfm_login.dart';
import 'package:spotube/pages/library/local_folder.dart';
import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart';
import 'package:spotube/pages/library/playlist_generate/playlist_generate_result.dart'; import 'package:spotube/pages/library/playlist_generate/playlist_generate_result.dart';
import 'package:spotube/pages/lyrics/mini_lyrics.dart'; import 'package:spotube/pages/lyrics/mini_lyrics.dart';
@ -24,6 +25,13 @@ import 'package:spotube/pages/search/search.dart';
import 'package:spotube/pages/settings/blacklist.dart'; import 'package:spotube/pages/settings/blacklist.dart';
import 'package:spotube/pages/settings/about.dart'; import 'package:spotube/pages/settings/about.dart';
import 'package:spotube/pages/settings/logs.dart'; import 'package:spotube/pages/settings/logs.dart';
import 'package:spotube/pages/stats/albums/albums.dart';
import 'package:spotube/pages/stats/artists/artists.dart';
import 'package:spotube/pages/stats/fees/fees.dart';
import 'package:spotube/pages/stats/minutes/minutes.dart';
import 'package:spotube/pages/stats/playlists/playlists.dart';
import 'package:spotube/pages/stats/stats.dart';
import 'package:spotube/pages/stats/streams/streams.dart';
import 'package:spotube/pages/track/track.dart'; import 'package:spotube/pages/track/track.dart';
import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/services/kv_store/kv_store.dart';
@ -50,6 +58,7 @@ final routerProvider = Provider((ref) {
routes: [ routes: [
GoRoute( GoRoute(
path: "/", path: "/",
name: HomePage.name,
redirect: (context, state) async { redirect: (context, state) async {
final authNotifier = ref.read(authenticationProvider.notifier); final authNotifier = ref.read(authenticationProvider.notifier);
final json = await authNotifier.box.get(authNotifier.cacheKey); final json = await authNotifier.box.get(authNotifier.cacheKey);
@ -66,11 +75,13 @@ final routerProvider = Provider((ref) {
routes: [ routes: [
GoRoute( GoRoute(
path: "genres", path: "genres",
name: GenrePage.name,
pageBuilder: (context, state) => pageBuilder: (context, state) =>
const SpotubePage(child: GenrePage()), const SpotubePage(child: GenrePage()),
), ),
GoRoute( GoRoute(
path: "genre/:categoryId", path: "genre/:categoryId",
name: GenrePlaylistsPage.name,
pageBuilder: (context, state) => SpotubePage( pageBuilder: (context, state) => SpotubePage(
child: GenrePlaylistsPage( child: GenrePlaylistsPage(
category: state.extra as Category, category: state.extra as Category,
@ -79,6 +90,7 @@ final routerProvider = Provider((ref) {
), ),
GoRoute( GoRoute(
path: "feeds/:feedId", path: "feeds/:feedId",
name: HomeFeedSectionPage.name,
pageBuilder: (context, state) => SpotubePage( pageBuilder: (context, state) => SpotubePage(
child: HomeFeedSectionPage( child: HomeFeedSectionPage(
sectionUri: state.pathParameters["feedId"] as String, sectionUri: state.pathParameters["feedId"] as String,
@ -89,45 +101,62 @@ final routerProvider = Provider((ref) {
), ),
GoRoute( GoRoute(
path: "/search", path: "/search",
name: "Search", name: SearchPage.name,
pageBuilder: (context, state) => pageBuilder: (context, state) =>
const SpotubePage(child: SearchPage()), const SpotubePage(child: SearchPage()),
), ),
GoRoute( GoRoute(
path: "/library", path: "/library",
name: "Library", name: LibraryPage.name,
pageBuilder: (context, state) => pageBuilder: (context, state) =>
const SpotubePage(child: LibraryPage()), const SpotubePage(child: LibraryPage()),
routes: [ routes: [
GoRoute( GoRoute(
path: "generate", path: "generate",
pageBuilder: (context, state) => name: PlaylistGeneratorPage.name,
const SpotubePage(child: PlaylistGeneratorPage()), pageBuilder: (context, state) =>
routes: [ const SpotubePage(child: PlaylistGeneratorPage()),
GoRoute( routes: [
path: "result", GoRoute(
pageBuilder: (context, state) => SpotubePage( path: "result",
child: PlaylistGenerateResultPage( name: PlaylistGenerateResultPage.name,
state: state.extra as GeneratePlaylistProviderInput, pageBuilder: (context, state) => SpotubePage(
), child: PlaylistGenerateResultPage(
state: state.extra as GeneratePlaylistProviderInput,
), ),
), ),
]), )
],
),
GoRoute(
path: "local",
name: LocalLibraryPage.name,
pageBuilder: (context, state) {
assert(state.extra is String);
return SpotubePage(
child: LocalLibraryPage(state.extra as String,
isDownloads:
state.uri.queryParameters["downloads"] != null),
);
},
),
]), ]),
GoRoute( GoRoute(
path: "/lyrics", path: "/lyrics",
name: "Lyrics", name: LyricsPage.name,
pageBuilder: (context, state) => pageBuilder: (context, state) =>
const SpotubePage(child: LyricsPage()), const SpotubePage(child: LyricsPage()),
), ),
GoRoute( GoRoute(
path: "/settings", path: "/settings",
name: SettingsPage.name,
pageBuilder: (context, state) => const SpotubePage( pageBuilder: (context, state) => const SpotubePage(
child: SettingsPage(), child: SettingsPage(),
), ),
routes: [ routes: [
GoRoute( GoRoute(
path: "blacklist", path: "blacklist",
name: BlackListPage.name,
pageBuilder: (context, state) => SpotubeSlidePage( pageBuilder: (context, state) => SpotubeSlidePage(
child: const BlackListPage(), child: const BlackListPage(),
), ),
@ -135,12 +164,14 @@ final routerProvider = Provider((ref) {
if (!kIsWeb) if (!kIsWeb)
GoRoute( GoRoute(
path: "logs", path: "logs",
name: LogsPage.name,
pageBuilder: (context, state) => SpotubeSlidePage( pageBuilder: (context, state) => SpotubeSlidePage(
child: const LogsPage(), child: const LogsPage(),
), ),
), ),
GoRoute( GoRoute(
path: "about", path: "about",
name: AboutSpotube.name,
pageBuilder: (context, state) => SpotubeSlidePage( pageBuilder: (context, state) => SpotubeSlidePage(
child: const AboutSpotube(), child: const AboutSpotube(),
), ),
@ -149,6 +180,7 @@ final routerProvider = Provider((ref) {
), ),
GoRoute( GoRoute(
path: "/album/:id", path: "/album/:id",
name: AlbumPage.name,
pageBuilder: (context, state) { pageBuilder: (context, state) {
assert(state.extra is AlbumSimple); assert(state.extra is AlbumSimple);
return SpotubePage( return SpotubePage(
@ -158,6 +190,7 @@ final routerProvider = Provider((ref) {
), ),
GoRoute( GoRoute(
path: "/artist/:id", path: "/artist/:id",
name: ArtistPage.name,
pageBuilder: (context, state) { pageBuilder: (context, state) {
assert(state.pathParameters["id"] != null); assert(state.pathParameters["id"] != null);
return SpotubePage( return SpotubePage(
@ -166,6 +199,7 @@ final routerProvider = Provider((ref) {
), ),
GoRoute( GoRoute(
path: "/playlist/:id", path: "/playlist/:id",
name: PlaylistPage.name,
pageBuilder: (context, state) { pageBuilder: (context, state) {
assert(state.extra is PlaylistSimple); assert(state.extra is PlaylistSimple);
return SpotubePage( return SpotubePage(
@ -177,6 +211,7 @@ final routerProvider = Provider((ref) {
), ),
GoRoute( GoRoute(
path: "/track/:id", path: "/track/:id",
name: TrackPage.name,
pageBuilder: (context, state) { pageBuilder: (context, state) {
final id = state.pathParameters["id"]!; final id = state.pathParameters["id"]!;
return SpotubePage( return SpotubePage(
@ -186,12 +221,14 @@ final routerProvider = Provider((ref) {
), ),
GoRoute( GoRoute(
path: "/connect", path: "/connect",
name: ConnectPage.name,
pageBuilder: (context, state) => const SpotubePage( pageBuilder: (context, state) => const SpotubePage(
child: ConnectPage(), child: ConnectPage(),
), ),
routes: [ routes: [
GoRoute( GoRoute(
path: "control", path: "control",
name: ConnectControlPage.name,
pageBuilder: (context, state) { pageBuilder: (context, state) {
return const SpotubePage( return const SpotubePage(
child: ConnectControlPage(), child: ConnectControlPage(),
@ -202,13 +239,66 @@ final routerProvider = Provider((ref) {
), ),
GoRoute( GoRoute(
path: "/profile", path: "/profile",
name: ProfilePage.name,
pageBuilder: (context, state) => pageBuilder: (context, state) =>
const SpotubePage(child: ProfilePage()), const SpotubePage(child: ProfilePage()),
),
GoRoute(
path: "/stats",
name: StatsPage.name,
pageBuilder: (context, state) => const SpotubePage(
child: StatsPage(),
),
routes: [
GoRoute(
path: "minutes",
name: StatsMinutesPage.name,
pageBuilder: (context, state) => const SpotubePage(
child: StatsMinutesPage(),
),
),
GoRoute(
path: "streams",
name: StatsStreamsPage.name,
pageBuilder: (context, state) => const SpotubePage(
child: StatsStreamsPage(),
),
),
GoRoute(
path: "fees",
name: StatsStreamFeesPage.name,
pageBuilder: (context, state) => const SpotubePage(
child: StatsStreamFeesPage(),
),
),
GoRoute(
path: "artists",
name: StatsArtistsPage.name,
pageBuilder: (context, state) => const SpotubePage(
child: StatsArtistsPage(),
),
),
GoRoute(
path: "albums",
name: StatsAlbumsPage.name,
pageBuilder: (context, state) => const SpotubePage(
child: StatsAlbumsPage(),
),
),
GoRoute(
path: "playlists",
name: StatsPlaylistsPage.name,
pageBuilder: (context, state) => const SpotubePage(
child: StatsPlaylistsPage(),
),
),
],
) )
], ],
), ),
GoRoute( GoRoute(
path: "/mini-player", path: "/mini-player",
name: MiniLyricsPage.name,
parentNavigatorKey: rootNavigatorKey, parentNavigatorKey: rootNavigatorKey,
pageBuilder: (context, state) => SpotubePage( pageBuilder: (context, state) => SpotubePage(
child: MiniLyricsPage(prevSize: state.extra as Size), child: MiniLyricsPage(prevSize: state.extra as Size),
@ -216,6 +306,7 @@ final routerProvider = Provider((ref) {
), ),
GoRoute( GoRoute(
path: "/getting-started", path: "/getting-started",
name: GettingStarting.name,
parentNavigatorKey: rootNavigatorKey, parentNavigatorKey: rootNavigatorKey,
pageBuilder: (context, state) => const SpotubePage( pageBuilder: (context, state) => const SpotubePage(
child: GettingStarting(), child: GettingStarting(),
@ -223,6 +314,7 @@ final routerProvider = Provider((ref) {
), ),
GoRoute( GoRoute(
path: "/login", path: "/login",
name: WebViewLogin.name,
parentNavigatorKey: rootNavigatorKey, parentNavigatorKey: rootNavigatorKey,
pageBuilder: (context, state) => SpotubePage( pageBuilder: (context, state) => SpotubePage(
child: kIsMobile ? const WebViewLogin() : const DesktopLoginPage(), child: kIsMobile ? const WebViewLogin() : const DesktopLoginPage(),
@ -230,6 +322,7 @@ final routerProvider = Provider((ref) {
), ),
GoRoute( GoRoute(
path: "/login-tutorial", path: "/login-tutorial",
name: LoginTutorial.name,
parentNavigatorKey: rootNavigatorKey, parentNavigatorKey: rootNavigatorKey,
pageBuilder: (context, state) => const SpotubePage( pageBuilder: (context, state) => const SpotubePage(
child: LoginTutorial(), child: LoginTutorial(),
@ -237,6 +330,7 @@ final routerProvider = Provider((ref) {
), ),
GoRoute( GoRoute(
path: "/lastfm-login", path: "/lastfm-login",
name: LastFMLoginPage.name,
parentNavigatorKey: rootNavigatorKey, parentNavigatorKey: rootNavigatorKey,
pageBuilder: (context, state) => pageBuilder: (context, state) =>
const SpotubePage(child: LastFMLoginPage()), const SpotubePage(child: LastFMLoginPage()),

View File

@ -1,33 +1,82 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:spotube/pages/home/home.dart';
import 'package:spotube/pages/library/library.dart';
import 'package:spotube/pages/lyrics/lyrics.dart';
import 'package:spotube/pages/search/search.dart';
import 'package:spotube/pages/stats/stats.dart';
class SideBarTiles { class SideBarTiles {
final IconData icon; final IconData icon;
final String title; final String title;
final String id; final String id;
SideBarTiles({required this.icon, required this.title, required this.id}); final String name;
SideBarTiles({
required this.icon,
required this.title,
required this.id,
required this.name,
});
} }
List<SideBarTiles> getSidebarTileList(AppLocalizations l10n) => [ List<SideBarTiles> getSidebarTileList(AppLocalizations l10n) => [
SideBarTiles(id: "browse", icon: SpotubeIcons.home, title: l10n.browse),
SideBarTiles(id: "search", icon: SpotubeIcons.search, title: l10n.search),
SideBarTiles( SideBarTiles(
id: "library", icon: SpotubeIcons.library, title: l10n.library), id: "browse",
SideBarTiles(id: "lyrics", icon: SpotubeIcons.music, title: l10n.lyrics), name: HomePage.name,
]; icon: SpotubeIcons.home,
title: l10n.browse,
List<SideBarTiles> getNavbarTileList(AppLocalizations l10n) => [ ),
SideBarTiles(id: "browse", icon: SpotubeIcons.home, title: l10n.browse), SideBarTiles(
SideBarTiles(id: "search", icon: SpotubeIcons.search, title: l10n.search), id: "search",
name: SearchPage.name,
icon: SpotubeIcons.search,
title: l10n.search,
),
SideBarTiles( SideBarTiles(
id: "library", id: "library",
name: LibraryPage.name,
icon: SpotubeIcons.library, icon: SpotubeIcons.library,
title: l10n.library, title: l10n.library,
), ),
SideBarTiles( SideBarTiles(
id: "settings", id: "lyrics",
icon: SpotubeIcons.settings, name: LyricsPage.name,
title: l10n.settings, icon: SpotubeIcons.music,
) title: l10n.lyrics,
),
SideBarTiles(
id: "stats",
name: StatsPage.name,
icon: SpotubeIcons.chart,
title: l10n.stats,
),
];
List<SideBarTiles> getNavbarTileList(AppLocalizations l10n) => [
SideBarTiles(
id: "browse",
name: HomePage.name,
icon: SpotubeIcons.home,
title: l10n.browse,
),
SideBarTiles(
id: "search",
name: SearchPage.name,
icon: SpotubeIcons.search,
title: l10n.search,
),
SideBarTiles(
id: "library",
name: LibraryPage.name,
icon: SpotubeIcons.library,
title: l10n.library,
),
SideBarTiles(
id: "stats",
name: StatsPage.name,
icon: SpotubeIcons.chart,
title: l10n.stats,
),
]; ];

View File

@ -121,4 +121,7 @@ abstract class SpotubeIcons {
static const monitor = FeatherIcons.monitor; static const monitor = FeatherIcons.monitor;
static const power = FeatherIcons.power; static const power = FeatherIcons.power;
static const bluetooth = FeatherIcons.bluetooth; static const bluetooth = FeatherIcons.bluetooth;
static const chart = FeatherIcons.barChart2;
static const folderAdd = FeatherIcons.folderPlus;
static const folderRemove = FeatherIcons.folderMinus;
} }

View File

@ -9,7 +9,9 @@ import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/image.dart';
import 'package:spotube/extensions/track.dart'; import 'package:spotube/extensions/track.dart';
import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/pages/album/album.dart';
import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/connect/connect.dart';
import 'package:spotube/provider/history/history.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
@ -32,6 +34,7 @@ class AlbumCard extends HookConsumerWidget {
final playing = final playing =
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
final historyNotifier = ref.read(playbackHistoryProvider.notifier);
bool isPlaylistPlaying = useMemoized( bool isPlaylistPlaying = useMemoized(
() => playlist.containsCollection(album.id!), () => playlist.containsCollection(album.id!),
@ -62,7 +65,14 @@ class AlbumCard extends HookConsumerWidget {
description: description:
"${album.albumType?.formatted}${album.artists?.asString() ?? ""}", "${album.albumType?.formatted}${album.artists?.asString() ?? ""}",
onTap: () { onTap: () {
ServiceUtils.push(context, "/album/${album.id}", extra: album); ServiceUtils.pushNamed(
context,
AlbumPage.name,
pathParameters: {
"id": album.id!,
},
extra: album,
);
}, },
onPlaybuttonPressed: () async { onPlaybuttonPressed: () async {
updating.value = true; updating.value = true;
@ -79,14 +89,15 @@ class AlbumCard extends HookConsumerWidget {
if (isRemoteDevice) { if (isRemoteDevice) {
final remotePlayback = ref.read(connectProvider.notifier); final remotePlayback = ref.read(connectProvider.notifier);
await remotePlayback.load( await remotePlayback.load(
WebSocketLoadEventData( WebSocketLoadEventData.album(
tracks: fetchedTracks, tracks: fetchedTracks,
collectionId: album.id!, collection: album,
), ),
); );
} else { } else {
await playlistNotifier.load(fetchedTracks, autoPlay: true); await playlistNotifier.load(fetchedTracks, autoPlay: true);
playlistNotifier.addCollection(album.id!); playlistNotifier.addCollection(album.id!);
historyNotifier.addAlbums([album]);
} }
} finally { } finally {
updating.value = false; updating.value = false;
@ -104,6 +115,7 @@ class AlbumCard extends HookConsumerWidget {
if (fetchedTracks.isEmpty) return; if (fetchedTracks.isEmpty) return;
playlistNotifier.addTracks(fetchedTracks); playlistNotifier.addTracks(fetchedTracks);
playlistNotifier.addCollection(album.id!); playlistNotifier.addCollection(album.id!);
historyNotifier.addAlbums([album]);
if (context.mounted) { if (context.mounted) {
final snackbar = SnackBar( final snackbar = SnackBar(
content: Text( content: Text(

View File

@ -9,6 +9,7 @@ import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/image.dart';
import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart';
import 'package:spotube/pages/artist/artist.dart';
import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
@ -63,7 +64,13 @@ class ArtistCard extends HookConsumerWidget {
), ),
child: InkWell( child: InkWell(
onTap: () { onTap: () {
ServiceUtils.push(context, "/artist/${artist.id}"); ServiceUtils.pushNamed(
context,
ArtistPage.name,
pathParameters: {
"id": artist.id!,
},
);
}, },
borderRadius: radius, borderRadius: radius,
child: Padding( child: Padding(

View File

@ -3,6 +3,7 @@ import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/pages/connect/connect.dart';
import 'package:spotube/provider/connect/clients.dart'; import 'package:spotube/provider/connect/clients.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
@ -22,7 +23,7 @@ class ConnectDeviceButton extends HookConsumerWidget {
width: double.infinity, width: double.infinity,
child: TextButton( child: TextButton(
onPressed: () { onPressed: () {
ServiceUtils.push(context, "/connect"); ServiceUtils.pushNamed(context, ConnectPage.name);
}, },
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@ -59,7 +60,7 @@ class ConnectDeviceButton extends HookConsumerWidget {
clipBehavior: Clip.hardEdge, clipBehavior: Clip.hardEdge,
child: InkWell( child: InkWell(
onTap: () { onTap: () {
ServiceUtils.push(context, "/connect"); ServiceUtils.pushNamed(context, ConnectPage.name);
}, },
borderRadius: BorderRadius.circular(50), borderRadius: BorderRadius.circular(50),
child: Ink( child: Ink(
@ -111,7 +112,7 @@ class ConnectDeviceButton extends HookConsumerWidget {
foregroundColor: colorScheme.onPrimary, foregroundColor: colorScheme.onPrimary,
), ),
onPressed: () { onPressed: () {
ServiceUtils.push(context, "/connect"); ServiceUtils.pushNamed(context, ConnectPage.name);
}, },
), ),
), ),

View File

@ -16,7 +16,6 @@ class TokenLoginForm extends HookConsumerWidget {
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final authenticationNotifier = ref.watch(authenticationProvider.notifier); final authenticationNotifier = ref.watch(authenticationProvider.notifier);
final directCodeController = useTextEditingController(); final directCodeController = useTextEditingController();
final mounted = useIsMounted();
final isLoading = useState(false); final isLoading = useState(false);
@ -57,7 +56,7 @@ class TokenLoginForm extends HookConsumerWidget {
await AuthenticationCredentials.fromCookie( await AuthenticationCredentials.fromCookie(
cookieHeader), cookieHeader),
); );
if (mounted()) { if (context.mounted) {
onDone?.call(); onDone?.call();
} }
} finally { } finally {

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart';
import 'package:spotube/pages/home/feed/feed_section.dart';
import 'package:spotube/provider/spotify/views/home.dart'; import 'package:spotube/provider/spotify/views/home.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
@ -41,8 +42,13 @@ class HomePageFeedSection extends HookConsumerWidget {
child: TextButton.icon( child: TextButton.icon(
label: const Text("Browse More"), label: const Text("Browse More"),
icon: const Icon(SpotubeIcons.angleRight), icon: const Icon(SpotubeIcons.angleRight),
onPressed: () => onPressed: () => ServiceUtils.pushNamed(
ServiceUtils.push(context, "/feeds/${section.uri}"), context,
HomeFeedSectionPage.name,
pathParameters: {
"feedId": section.uri,
},
),
), ),
), ),
); );

View File

@ -1,12 +1,14 @@
import 'dart:ui'; import 'dart:ui';
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:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/fake.dart';
import 'package:spotube/components/home/sections/friends/friend_item.dart'; import 'package:spotube/components/home/sections/friends/friend_item.dart';
import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
import 'package:spotube/models/spotify_friends.dart'; import 'package:spotube/models/spotify_friends.dart';
import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify/spotify.dart';
class HomePageFriendsSection extends HookConsumerWidget { class HomePageFriendsSection extends HookConsumerWidget {
@ -14,6 +16,7 @@ class HomePageFriendsSection extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final auth = ref.watch(authenticationProvider);
final friendsQuery = ref.watch(friendsProvider); final friendsQuery = ref.watch(friendsProvider);
final friends = final friends =
friendsQuery.asData?.value.friends ?? FakeData.friends.friends; friendsQuery.asData?.value.friends ?? FakeData.friends.friends;
@ -27,32 +30,36 @@ class HomePageFriendsSection extends HookConsumerWidget {
xxl: 7, xxl: 7,
); );
final friendGroup = friends.fold<List<List<SpotifyFriendActivity>>>( final friendGroup = useMemoized(
[], () => friends.fold<List<List<SpotifyFriendActivity>>>(
(previousValue, element) { [],
if (previousValue.isEmpty) { (previousValue, element) {
if (previousValue.isEmpty) {
return [
[element]
];
}
final lastGroup = previousValue.last;
if (lastGroup.length < groupCount) {
return [
...previousValue.sublist(0, previousValue.length - 1),
[...lastGroup, element]
];
}
return [ return [
...previousValue,
[element] [element]
]; ];
} },
),
final lastGroup = previousValue.last; [friends, groupCount],
if (lastGroup.length < groupCount) {
return [
...previousValue.sublist(0, previousValue.length - 1),
[...lastGroup, element]
];
}
return [
...previousValue,
[element]
];
},
); );
if (friendsQuery.isLoading || if (friendsQuery.isLoading ||
friendsQuery.asData?.value.friends.isEmpty == true) { friendsQuery.asData?.value.friends.isEmpty == true ||
auth == null) {
return const SliverToBoxAdapter( return const SliverToBoxAdapter(
child: SizedBox.shrink(), child: SizedBox.shrink(),
); );

View File

@ -6,6 +6,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/models/spotify_friends.dart'; import 'package:spotube/models/spotify_friends.dart';
import 'package:spotube/pages/album/album.dart';
import 'package:spotube/pages/artist/artist.dart';
import 'package:spotube/pages/track/track.dart';
import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/spotify_provider.dart';
class FriendItem extends HookConsumerWidget { class FriendItem extends HookConsumerWidget {
@ -57,7 +60,9 @@ class FriendItem extends HookConsumerWidget {
text: friend.track.name, text: friend.track.name,
recognizer: TapGestureRecognizer() recognizer: TapGestureRecognizer()
..onTap = () { ..onTap = () {
context.push("/track/${friend.track.id}"); context.pushNamed(TrackPage.name, pathParameters: {
"id": friend.track.id,
});
}, },
), ),
const TextSpan(text: ""), const TextSpan(text: ""),
@ -71,8 +76,12 @@ class FriendItem extends HookConsumerWidget {
text: " ${friend.track.artist.name}", text: " ${friend.track.artist.name}",
recognizer: TapGestureRecognizer() recognizer: TapGestureRecognizer()
..onTap = () { ..onTap = () {
context.push( context.pushNamed(
"/artist/${friend.track.artist.id}", ArtistPage.name,
pathParameters: {
"id": friend.track.artist.id,
},
extra: friend.track.artist,
); );
}, },
), ),
@ -105,8 +114,11 @@ class FriendItem extends HookConsumerWidget {
final album = final album =
await spotify.albums.get(friend.track.album.id); await spotify.albums.get(friend.track.album.id);
if (context.mounted) { if (context.mounted) {
context.push( context.pushNamed(
"/album/${friend.track.album.id}", AlbumPage.name,
pathParameters: {
"id": friend.track.album.id,
},
extra: album, extra: album,
); );
} }

View File

@ -13,6 +13,8 @@ import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/pages/home/genres/genre_playlists.dart';
import 'package:spotube/pages/home/genres/genres.dart';
import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify/spotify.dart';
class HomeGenresSection extends HookConsumerWidget { class HomeGenresSection extends HookConsumerWidget {
@ -50,11 +52,11 @@ class HomeGenresSection extends HookConsumerWidget {
textDirection: TextDirection.rtl, textDirection: TextDirection.rtl,
child: TextButton.icon( child: TextButton.icon(
onPressed: () { onPressed: () {
context.push('/genres'); context.pushNamed(GenrePage.name);
}, },
icon: const Icon(SpotubeIcons.angleRight), icon: const Icon(SpotubeIcons.angleRight),
label: Text( label: Text(
"Browse All", context.l10n.browse_all,
style: textTheme.bodyMedium?.copyWith( style: textTheme.bodyMedium?.copyWith(
color: colorScheme.secondary, color: colorScheme.secondary,
), ),
@ -110,7 +112,13 @@ class HomeGenresSection extends HookConsumerWidget {
return InkWell( return InkWell(
onTap: () { onTap: () {
context.push('/genre/${category.id}', extra: category); context.pushNamed(
GenrePlaylistsPage.name,
pathParameters: {
"categoryId": category.id!,
},
extra: category,
);
}, },
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: Ink( child: Ink(

View File

@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart';
import 'package:spotube/provider/history/recent.dart';
import 'package:spotube/provider/history/state.dart';
class HomeRecentlyPlayedSection extends HookConsumerWidget {
const HomeRecentlyPlayedSection({super.key});
@override
Widget build(BuildContext context, ref) {
final history = ref.watch(recentlyPlayedItems);
if (history.isEmpty) {
return const SizedBox();
}
return HorizontalPlaybuttonCardView(
title: const Text('Recently Played'),
items: [
for (final item in history)
if (item is PlaybackHistoryPlaylist)
item.playlist
else if (item is PlaybackHistoryAlbum)
item.album
],
hasNextPage: false,
isLoadingNextPage: false,
onFetchMore: () {},
);
}
}

View File

@ -0,0 +1,199 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:path/path.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/hooks/utils/use_brightness_value.dart';
import 'package:spotube/pages/library/local_folder.dart';
import 'package:spotube/provider/local_tracks/local_tracks_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
class LocalFolderItem extends HookConsumerWidget {
final String folder;
const LocalFolderItem({super.key, required this.folder});
@override
Widget build(BuildContext context, ref) {
final ThemeData(:colorScheme) = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
final lerpValue = useBrightnessValue(.9, .7);
final downloadFolder =
ref.watch(userPreferencesProvider.select((s) => s.downloadLocation));
final isDownloadFolder = folder == downloadFolder;
final Uri(:pathSegments) = Uri.parse(
folder
.replaceFirst(RegExp(r'^/Volumes/[^/]+/Users/'), "")
.replaceFirst(r'C:\Users\', "")
.replaceFirst(r'/home/', ""),
);
// if length > 5, we ... all the middle segments after 2 and the last 2
final segments = pathSegments.length > 5
? [
...pathSegments.take(2),
"...",
...pathSegments.skip(pathSegments.length - 3).toList()
..removeLast(),
]
: pathSegments.take(pathSegments.length - 1).toList();
final trackSnapshot = ref.watch(
localTracksProvider.select(
(s) => s.whenData((tracks) => tracks[folder]?.take(4).toList()),
),
);
final tracks = trackSnapshot.value ?? [];
return InkWell(
onTap: () {
context.goNamed(
LocalLibraryPage.name,
queryParameters: {
if (isDownloadFolder) "downloads": "true",
},
extra: folder,
);
},
borderRadius: BorderRadius.circular(8),
child: Ink(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Color.lerp(
colorScheme.surfaceVariant,
colorScheme.surface,
lerpValue,
),
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (tracks.isEmpty)
Card(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Icon(
SpotubeIcons.folder,
size: mediaQuery.smAndDown
? 95
: mediaQuery.mdAndDown
? 100
: 142,
),
),
)
else
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: max((tracks.length / 2).ceil(), 2),
),
itemCount: tracks.length,
itemBuilder: (context, index) {
final track = tracks[index];
return UniversalImage(
path: (track.album?.images).asUrlString(
placeholder: ImagePlaceholder.albumArt,
),
fit: BoxFit.cover,
);
},
),
),
const Gap(8),
Stack(
children: [
Center(
child: Text(
isDownloadFolder
? context.l10n.downloads
: basename(folder),
style: const TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
),
if (!isDownloadFolder)
Align(
alignment: Alignment.topRight,
child: PopupMenuButton(
child: const Padding(
padding: EdgeInsets.all(3),
child: Icon(Icons.more_vert),
),
itemBuilder: (context) {
return [
PopupMenuItem(
child: ListTile(
leading: const Icon(SpotubeIcons.folderRemove),
iconColor: colorScheme.error,
title:
Text(context.l10n.remove_library_location),
onTap: () {
final libraryLocations = ref
.read(userPreferencesProvider)
.localLibraryLocation;
ref
.read(userPreferencesProvider.notifier)
.setLocalLibraryLocation(
libraryLocations
.where((e) => e != folder)
.toList(),
);
},
),
)
];
},
),
),
],
),
const Spacer(),
Wrap(
spacing: 2,
runSpacing: 2,
children: [
for (final MapEntry(key: index, value: segment)
in segments.asMap().entries)
Text.rich(
TextSpan(
children: [
if (index != 0)
TextSpan(
text: "/ ",
style: TextStyle(color: colorScheme.primary),
),
TextSpan(text: segment),
],
),
style: TextStyle(
fontSize: 10,
color: colorScheme.tertiary,
),
),
],
),
const Spacer(),
],
),
),
),
);
}
}

View File

@ -1,52 +1,18 @@
import 'dart:io'; import 'package:file_picker/file_picker.dart';
import 'package:file_selector/file_selector.dart';
import 'package:catcher_2/catcher_2.dart';
import 'package:flutter/foundation.dart';
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:collection/collection.dart'; import 'package:gap/gap.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:metadata_god/metadata_god.dart';
import 'package:mime/mime.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/expandable_search/expandable_search.dart'; import 'package:spotube/components/library/local_folder/local_folder_item.dart';
import 'package:spotube/components/shared/fallbacks/not_found.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/shared/sort_tracks_dropdown.dart';
import 'package:spotube/components/shared/track_tile/track_tile.dart';
import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/track.dart'; import 'package:spotube/provider/local_tracks/local_tracks_provider.dart';
import 'package:spotube/models/local_track.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/platform.dart';
// ignore: depend_on_referenced_packages // ignore: depend_on_referenced_packages
import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FfiException;
const supportedAudioTypes = [
"audio/webm",
"audio/ogg",
"audio/mpeg",
"audio/mp4",
"audio/opus",
"audio/wav",
"audio/aac",
];
const imgMimeToExt = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/webp": ".webp",
"image/gif": ".gif",
};
enum SortBy { enum SortBy {
none, none,
@ -59,273 +25,77 @@ enum SortBy {
album, album,
} }
final localTracksProvider = FutureProvider<List<LocalTrack>>((ref) async {
try {
if (kIsWeb) return [];
final downloadLocation = ref.watch(
userPreferencesProvider.select((s) => s.downloadLocation),
);
if (downloadLocation.isEmpty) return [];
final downloadDir = Directory(downloadLocation);
if (!await downloadDir.exists()) {
await downloadDir.create(recursive: true);
return [];
}
final entities = downloadDir.listSync(recursive: true);
final filesWithMetadata = (await Future.wait(
entities.map((e) => File(e.path)).where((file) {
final mimetype = lookupMimeType(file.path);
return mimetype != null && supportedAudioTypes.contains(mimetype);
}).map(
(file) async {
try {
final metadata = await MetadataGod.readMetadata(file: file.path);
final imageFile = File(join(
(await getTemporaryDirectory()).path,
"spotube",
basenameWithoutExtension(file.path) +
imgMimeToExt[metadata.picture?.mimeType ?? "image/jpeg"]!,
));
if (!await imageFile.exists() && metadata.picture != null) {
await imageFile.create(recursive: true);
await imageFile.writeAsBytes(
metadata.picture?.data ?? [],
mode: FileMode.writeOnly,
);
}
return {"metadata": metadata, "file": file, "art": imageFile.path};
} catch (e, stack) {
if (e is FfiException) {
return {"file": file};
}
Catcher2.reportCheckedError(e, stack);
return {};
}
},
),
))
.where((e) => e.isNotEmpty)
.toList();
final tracks = filesWithMetadata
.map(
(fileWithMetadata) => LocalTrack.fromTrack(
track: Track().fromFile(
fileWithMetadata["file"],
metadata: fileWithMetadata["metadata"],
art: fileWithMetadata["art"],
),
path: fileWithMetadata["file"].path,
),
)
.toList();
return tracks;
} catch (e, stack) {
Catcher2.reportCheckedError(e, stack);
return [];
}
});
class UserLocalTracks extends HookConsumerWidget { class UserLocalTracks extends HookConsumerWidget {
const UserLocalTracks({super.key}); const UserLocalTracks({super.key});
Future<void> playLocalTracks(
WidgetRef ref,
List<LocalTrack> tracks, {
LocalTrack? currentTrack,
}) async {
final playlist = ref.read(proxyPlaylistProvider);
final playback = ref.read(proxyPlaylistProvider.notifier);
currentTrack ??= tracks.first;
final isPlaylistPlaying = playlist.containsTracks(tracks);
if (!isPlaylistPlaying) {
await playback.load(
tracks,
initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id),
autoPlay: true,
);
} else if (isPlaylistPlaying &&
currentTrack.id != null &&
currentTrack.id != playlist.activeTrack?.id) {
await playback.jumpToTrack(currentTrack);
}
}
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final sortBy = useState<SortBy>(SortBy.none); final preferencesNotifier = ref.watch(userPreferencesProvider.notifier);
final playlist = ref.watch(proxyPlaylistProvider); final preferences = ref.watch(userPreferencesProvider);
final trackSnapshot = ref.watch(localTracksProvider);
final isPlaylistPlaying =
playlist.containsTracks(trackSnapshot.asData?.value ?? []);
final searchController = useTextEditingController(); final addLocalLibraryLocation = useCallback(() async {
useValueListenable(searchController); if (kIsMobile || kIsMacOS) {
final searchFocus = useFocusNode(); final dirStr = await FilePicker.platform.getDirectoryPath(
final isFiltering = useState(false); initialDirectory: preferences.downloadLocation,
);
if (dirStr == null) return;
if (preferences.localLibraryLocation.contains(dirStr)) return;
preferencesNotifier.setLocalLibraryLocation(
[...preferences.localLibraryLocation, dirStr]);
} else {
String? dirStr = await getDirectoryPath(
initialDirectory: preferences.downloadLocation,
);
if (dirStr == null) return;
if (preferences.localLibraryLocation.contains(dirStr)) return;
preferencesNotifier.setLocalLibraryLocation(
[...preferences.localLibraryLocation, dirStr]);
}
}, [preferences.localLibraryLocation]);
final controller = useScrollController(); // This is just to pre-load the tracks.
// For now, this gets all of them.
ref.watch(localTracksProvider);
return Column( return LayoutBuilder(builder: (context, constrains) {
children: [ return Padding(
Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0),
padding: const EdgeInsets.all(8.0), child: Column(
child: Row( children: [
children: [ Align(
const SizedBox(width: 5), alignment: Alignment.centerRight,
FilledButton( child: TextButton.icon(
onPressed: trackSnapshot.asData?.value != null icon: const Icon(SpotubeIcons.folderAdd),
? () async { label: Text(context.l10n.add_library_location),
if (trackSnapshot.asData?.value.isNotEmpty == true) { onPressed: addLocalLibraryLocation,
if (!isPlaylistPlaying) {
await playLocalTracks(
ref,
trackSnapshot.asData!.value,
);
}
}
}
: null,
child: Row(
children: [
Text(context.l10n.play),
Icon(
isPlaylistPlaying ? SpotubeIcons.stop : SpotubeIcons.play,
)
],
),
),
const Spacer(),
ExpandableSearchButton(
isFiltering: isFiltering.value,
onPressed: (value) => isFiltering.value = value,
searchFocus: searchFocus,
),
const SizedBox(width: 10),
SortTracksDropdown(
value: sortBy.value,
onChanged: (value) {
sortBy.value = value;
},
),
const SizedBox(width: 5),
FilledButton(
child: const Icon(SpotubeIcons.refresh),
onPressed: () {
ref.invalidate(localTracksProvider);
},
)
],
),
),
ExpandableSearchField(
searchController: searchController,
searchFocus: searchFocus,
isFiltering: isFiltering.value,
onChangeFiltering: (value) => isFiltering.value = value,
),
trackSnapshot.when(
data: (tracks) {
final sortedTracks = useMemoized(() {
return ServiceUtils.sortTracks(tracks, sortBy.value);
}, [sortBy.value, tracks]);
final filteredTracks = useMemoized(() {
if (searchController.text.isEmpty) {
return sortedTracks;
}
return sortedTracks
.map((e) => (
weightedRatio(
"${e.name} - ${e.artists?.asString() ?? ""}",
searchController.text,
),
e,
))
.toList()
.sorted(
(a, b) => b.$1.compareTo(a.$1),
)
.where((e) => e.$1 > 50)
.map((e) => e.$2)
.toList()
.toList();
}, [searchController.text, sortedTracks]);
if (!trackSnapshot.isLoading && filteredTracks.isEmpty) {
return const Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [NotFound()],
),
);
}
return Expanded(
child: RefreshIndicator(
onRefresh: () async {
ref.invalidate(localTracksProvider);
},
child: InterScrollbar(
controller: controller,
child: Skeletonizer(
enabled: trackSnapshot.isLoading,
child: ListView.builder(
controller: controller,
physics: const AlwaysScrollableScrollPhysics(),
itemCount:
trackSnapshot.isLoading ? 5 : filteredTracks.length,
itemBuilder: (context, index) {
if (trackSnapshot.isLoading) {
return TrackTile(
playlist: playlist,
track: FakeData.track,
index: index,
);
}
final track = filteredTracks[index];
return TrackTile(
index: index,
playlist: playlist,
track: track,
userPlaylist: false,
onTap: () async {
await playLocalTracks(
ref,
sortedTracks,
currentTrack: track,
);
},
);
},
),
),
),
),
);
},
loading: () => Expanded(
child: Skeletonizer(
enabled: true,
child: ListView.builder(
itemCount: 5,
itemBuilder: (context, index) => TrackTile(
track: FakeData.track,
index: index,
playlist: playlist,
),
), ),
), ),
), const Gap(8),
error: (error, stackTrace) => Expanded(
Text(error.toString() + stackTrace.toString()), child: GridView.builder(
) gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
], maxCrossAxisExtent: 200,
); mainAxisExtent: constrains.isXs
? 210
: constrains.mdAndDown
? 280
: 250,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
),
itemCount: preferences.localLibraryLocation.length + 1,
itemBuilder: (context, index) {
return LocalFolderItem(
folder: index == 0
? preferences.downloadLocation
: preferences.localLibraryLocation[index - 1],
);
},
),
),
],
),
);
});
} }
} }

View File

@ -1,6 +1,5 @@
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
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:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
@ -31,11 +30,17 @@ class VolumeSlider extends HookConsumerWidget {
} }
} }
}, },
child: Slider( child: SliderTheme(
min: 0, data: const SliderThemeData(
max: 1, showValueIndicator: ShowValueIndicator.always,
value: value, ),
onChanged: onChanged, child: Slider(
min: 0,
max: 1,
label: (value * 100).toStringAsFixed(0),
value: value,
onChanged: onChanged,
),
), ),
); );
return Row( return Row(

View File

@ -6,7 +6,9 @@ import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
import 'package:spotube/components/shared/playbutton_card.dart'; import 'package:spotube/components/shared/playbutton_card.dart';
import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/image.dart';
import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/pages/playlist/playlist.dart';
import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/connect/connect.dart';
import 'package:spotube/provider/history/history.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
@ -22,6 +24,8 @@ class PlaylistCard extends HookConsumerWidget {
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final playlistQueue = ref.watch(proxyPlaylistProvider); final playlistQueue = ref.watch(proxyPlaylistProvider);
final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
final historyNotifier = ref.read(playbackHistoryProvider.notifier);
final playing = final playing =
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
bool isPlaylistPlaying = useMemoized( bool isPlaylistPlaying = useMemoized(
@ -32,12 +36,23 @@ class PlaylistCard extends HookConsumerWidget {
final updating = useState(false); final updating = useState(false);
final me = ref.watch(meProvider); final me = ref.watch(meProvider);
Future<List<Track>> fetchAllTracks() async { Future<List<Track>> fetchInitialTracks() async {
if (playlist.id == 'user-liked-tracks') { if (playlist.id == 'user-liked-tracks') {
return await ref.read(likedTracksProvider.future); return await ref.read(likedTracksProvider.future);
} }
await ref.read(playlistTracksProvider(playlist.id!).future); final result =
await ref.read(playlistTracksProvider(playlist.id!).future);
return result.items;
}
Future<List<Track>> fetchAllTracks() async {
final initialTracks = await fetchInitialTracks();
if (playlist.id == 'user-liked-tracks') {
return initialTracks;
}
return ref.read(playlistTracksProvider(playlist.id!).notifier).fetchAll(); return ref.read(playlistTracksProvider(playlist.id!).notifier).fetchAll();
} }
@ -55,9 +70,12 @@ class PlaylistCard extends HookConsumerWidget {
isOwner: playlist.owner?.id == me.asData?.value.id && isOwner: playlist.owner?.id == me.asData?.value.id &&
me.asData?.value.id != null, me.asData?.value.id != null,
onTap: () { onTap: () {
ServiceUtils.push( ServiceUtils.pushNamed(
context, context,
"/playlist/${playlist.id}", PlaylistPage.name,
pathParameters: {
"id": playlist.id!,
},
extra: playlist, extra: playlist,
); );
}, },
@ -70,22 +88,29 @@ class PlaylistCard extends HookConsumerWidget {
return audioPlayer.resume(); return audioPlayer.resume();
} }
List<Track> fetchedTracks = await fetchAllTracks(); final fetchedInitialTracks = await fetchInitialTracks();
if (fetchedTracks.isEmpty || !context.mounted) return; if (fetchedInitialTracks.isEmpty || !context.mounted) return;
final isRemoteDevice = await showSelectDeviceDialog(context, ref); final isRemoteDevice = await showSelectDeviceDialog(context, ref);
if (isRemoteDevice) { if (isRemoteDevice) {
final remotePlayback = ref.read(connectProvider.notifier); final remotePlayback = ref.read(connectProvider.notifier);
final allTracks = await fetchAllTracks();
await remotePlayback.load( await remotePlayback.load(
WebSocketLoadEventData( WebSocketLoadEventData.playlist(
tracks: fetchedTracks, tracks: allTracks,
collectionId: playlist.id!, collection: playlist,
), ),
); );
} else { } else {
await playlistNotifier.load(fetchedTracks, autoPlay: true); await playlistNotifier.load(fetchedInitialTracks, autoPlay: true);
playlistNotifier.addCollection(playlist.id!); playlistNotifier.addCollection(playlist.id!);
historyNotifier.addPlaylists([playlist]);
final allTracks = await fetchAllTracks();
await playlistNotifier
.addTracks(allTracks.sublist(fetchedInitialTracks.length));
} }
} finally { } finally {
if (context.mounted) { if (context.mounted) {
@ -98,20 +123,22 @@ class PlaylistCard extends HookConsumerWidget {
try { try {
if (isPlaylistPlaying) return; if (isPlaylistPlaying) return;
final fetchedTracks = await fetchAllTracks(); final fetchedInitialTracks = await fetchAllTracks();
if (fetchedTracks.isEmpty) return; if (fetchedInitialTracks.isEmpty) return;
playlistNotifier.addTracks(fetchedTracks); playlistNotifier.addTracks(fetchedInitialTracks);
playlistNotifier.addCollection(playlist.id!); playlistNotifier.addCollection(playlist.id!);
historyNotifier.addPlaylists([playlist]);
if (context.mounted) { if (context.mounted) {
final snackbar = SnackBar( final snackbar = SnackBar(
content: Text("Added ${fetchedTracks.length} tracks to queue"), content:
Text("Added ${fetchedInitialTracks.length} tracks to queue"),
action: SnackBarAction( action: SnackBarAction(
label: "Undo", label: "Undo",
onPressed: () { onPressed: () {
playlistNotifier playlistNotifier
.removeTracks(fetchedTracks.map((e) => e.id!)); .removeTracks(fetchedInitialTracks.map((e) => e.id!));
}, },
), ),
); );

View File

@ -1,6 +1,5 @@
import 'dart:ui'; import 'dart:ui';
import 'package:flutter_desktop_tools/flutter_desktop_tools.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';
@ -24,6 +23,7 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart
import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
import 'package:spotube/provider/volume_provider.dart'; import 'package:spotube/provider/volume_provider.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
import 'package:window_manager/window_manager.dart';
class BottomPlayer extends HookConsumerWidget { class BottomPlayer extends HookConsumerWidget {
BottomPlayer({super.key}); BottomPlayer({super.key});
@ -95,19 +95,19 @@ class BottomPlayer extends HookConsumerWidget {
tooltip: context.l10n.mini_player, tooltip: context.l10n.mini_player,
icon: const Icon(SpotubeIcons.miniPlayer), icon: const Icon(SpotubeIcons.miniPlayer),
onPressed: () async { onPressed: () async {
final prevSize = if (!kIsDesktop) return;
await DesktopTools.window.getSize();
await DesktopTools.window.setMinimumSize( final prevSize = await windowManager.getSize();
await windowManager.setMinimumSize(
const Size(300, 300), const Size(300, 300),
); );
await DesktopTools.window.setAlwaysOnTop(true); await windowManager.setAlwaysOnTop(true);
if (!kIsLinux) { if (!kIsLinux) {
await DesktopTools.window.setHasShadow(false); await windowManager.setHasShadow(false);
} }
await DesktopTools.window await windowManager
.setAlignment(Alignment.topRight); .setAlignment(Alignment.topRight);
await DesktopTools.window await windowManager.setSize(const Size(400, 500));
.setSize(const Size(400, 500));
await Future.delayed( await Future.delayed(
const Duration(milliseconds: 100), const Duration(milliseconds: 100),
() async { () async {

View File

@ -16,6 +16,8 @@ import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/image.dart';
import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart';
import 'package:spotube/hooks/controllers/use_sidebarx_controller.dart'; import 'package:spotube/hooks/controllers/use_sidebarx_controller.dart';
import 'package:spotube/pages/profile/profile.dart';
import 'package:spotube/pages/settings/settings.dart';
import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify/spotify.dart';
@ -24,15 +26,12 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart
import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
import 'package:window_manager/window_manager.dart';
class Sidebar extends HookConsumerWidget { class Sidebar extends HookConsumerWidget {
final int? selectedIndex;
final void Function(int) onSelectedIndexChanged;
final Widget child; final Widget child;
const Sidebar({ const Sidebar({
required this.selectedIndex,
required this.onSelectedIndexChanged,
required this.child, required this.child,
super.key, super.key,
}); });
@ -47,12 +46,9 @@ class Sidebar extends HookConsumerWidget {
); );
} }
static void goToSettings(BuildContext context) {
GoRouter.of(context).go("/settings");
}
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final routerState = GoRouterState.of(context);
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
final downloadCount = ref.watch(downloadManagerProvider).$downloadCount; final downloadCount = ref.watch(downloadManagerProvider).$downloadCount;
@ -60,8 +56,17 @@ class Sidebar extends HookConsumerWidget {
final layoutMode = final layoutMode =
ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); ref.watch(userPreferencesProvider.select((s) => s.layoutMode));
final sidebarTileList = useMemoized(
() => getSidebarTileList(context.l10n),
[context.l10n],
);
final selectedIndex = sidebarTileList.indexWhere(
(e) => routerState.namedLocation(e.name) == routerState.matchedLocation,
);
final controller = useSidebarXController( final controller = useSidebarXController(
selectedIndex: selectedIndex ?? 0, selectedIndex: selectedIndex,
extended: mediaQuery.lgAndUp, extended: mediaQuery.lgAndUp,
); );
@ -73,29 +78,6 @@ class Sidebar extends HookConsumerWidget {
Color.lerp(bg, Colors.black, 0.45)!, Color.lerp(bg, Colors.black, 0.45)!,
); );
final sidebarTileList = useMemoized(
() => getSidebarTileList(context.l10n),
[context.l10n],
);
useEffect(() {
if (controller.selectedIndex != selectedIndex && selectedIndex != null) {
controller.selectIndex(selectedIndex!);
}
return null;
}, [selectedIndex]);
useEffect(() {
void listener() {
onSelectedIndexChanged(controller.selectedIndex);
}
controller.addListener(listener);
return () {
controller.removeListener(listener);
};
}, [controller]);
useEffect(() { useEffect(() {
if (!context.mounted) return; if (!context.mounted) return;
if (mediaQuery.lgAndUp && !controller.extended) { if (mediaQuery.lgAndUp && !controller.extended) {
@ -106,6 +88,13 @@ class Sidebar extends HookConsumerWidget {
return null; return null;
}, [mediaQuery, controller]); }, [mediaQuery, controller]);
useEffect(() {
if (controller.selectedIndex != selectedIndex) {
controller.selectIndex(selectedIndex);
}
return null;
}, [selectedIndex]);
if (layoutMode == LayoutMode.compact || if (layoutMode == LayoutMode.compact ||
(mediaQuery.smAndDown && layoutMode == LayoutMode.adaptive)) { (mediaQuery.smAndDown && layoutMode == LayoutMode.adaptive)) {
return Scaffold(body: child); return Scaffold(body: child);
@ -119,23 +108,28 @@ class Sidebar extends HookConsumerWidget {
items: sidebarTileList.mapIndexed( items: sidebarTileList.mapIndexed(
(index, e) { (index, e) {
return SidebarXItem( return SidebarXItem(
iconWidget: Badge( onTap: () {
backgroundColor: theme.colorScheme.primary, context.goNamed(e.name);
isLabelVisible: e.title == "Library" && downloadCount > 0, },
label: Text( iconBuilder: (selected, hovered) {
downloadCount.toString(), return Badge(
style: const TextStyle( backgroundColor: theme.colorScheme.primary,
color: Colors.white, isLabelVisible: e.title == "Library" && downloadCount > 0,
fontSize: 10, label: Text(
downloadCount.toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
), ),
), child: Icon(
child: Icon( e.icon,
e.icon, color: selected || hovered
color: selectedIndex == index ? theme.colorScheme.primary
? theme.colorScheme.primary : null,
: null, ),
), );
), },
label: e.title, label: e.title,
); );
}, },
@ -214,22 +208,24 @@ class SidebarHeader extends HookWidget {
); );
} }
return Padding( return DragToMoveArea(
padding: const EdgeInsets.all(8.0), child: Padding(
child: Column( padding: const EdgeInsets.all(8.0),
children: [ child: Column(
if (kIsMacOS) const SizedBox(height: 25), children: [
Row( if (kIsMacOS) const SizedBox(height: 25),
children: [ Row(
Sidebar.brandLogo(), children: [
const SizedBox(width: 10), Sidebar.brandLogo(),
Text( const SizedBox(width: 10),
"Spotube", Text(
style: theme.textTheme.titleLarge, "Spotube",
), style: theme.textTheme.titleLarge,
], ),
), ],
], ),
],
),
), ),
); );
} }
@ -257,7 +253,7 @@ class SidebarFooter extends HookConsumerWidget {
if (mediaQuery.mdAndDown) { if (mediaQuery.mdAndDown) {
return IconButton( return IconButton(
icon: const Icon(SpotubeIcons.settings), icon: const Icon(SpotubeIcons.settings),
onPressed: () => Sidebar.goToSettings(context), onPressed: () => ServiceUtils.navigateNamed(context, SettingsPage.name),
); );
} }
@ -278,7 +274,7 @@ class SidebarFooter extends HookConsumerWidget {
Flexible( Flexible(
child: InkWell( child: InkWell(
onTap: () { onTap: () {
ServiceUtils.push(context, "/profile"); ServiceUtils.pushNamed(context, ProfilePage.name);
}, },
borderRadius: BorderRadius.circular(30), borderRadius: BorderRadius.circular(30),
child: Row( child: Row(
@ -310,7 +306,7 @@ class SidebarFooter extends HookConsumerWidget {
IconButton( IconButton(
icon: const Icon(SpotubeIcons.settings), icon: const Icon(SpotubeIcons.settings),
onPressed: () { onPressed: () {
Sidebar.goToSettings(context); ServiceUtils.pushNamed(context, SettingsPage.name);
}, },
), ),
], ],

View File

@ -3,55 +3,54 @@ import 'dart:ui';
import 'package:curved_navigation_bar/curved_navigation_bar.dart'; import 'package:curved_navigation_bar/curved_navigation_bar.dart';
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:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/side_bar_tiles.dart'; import 'package:spotube/collections/side_bar_tiles.dart';
import 'package:spotube/components/root/sidebar.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart';
import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
import 'package:spotube/utils/service_utils.dart';
final navigationPanelHeight = StateProvider<double>((ref) => 50); final navigationPanelHeight = StateProvider<double>((ref) => 50);
class SpotubeNavigationBar extends HookConsumerWidget { class SpotubeNavigationBar extends HookConsumerWidget {
final int? selectedIndex;
final void Function(int) onSelectedIndexChanged;
const SpotubeNavigationBar({ const SpotubeNavigationBar({
required this.selectedIndex,
required this.onSelectedIndexChanged,
super.key, super.key,
}); });
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final theme = Theme.of(context); final theme = Theme.of(context);
final routerState = GoRouterState.of(context);
final downloadCount = ref.watch(downloadManagerProvider).$downloadCount; final downloadCount = ref.watch(downloadManagerProvider).$downloadCount;
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
final layoutMode = final layoutMode =
ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); ref.watch(userPreferencesProvider.select((s) => s.layoutMode));
final insideSelectedIndex = useState<int>(selectedIndex ?? 0);
final buttonColor = useBrightnessValue( final buttonColor = useBrightnessValue(
theme.colorScheme.inversePrimary, theme.colorScheme.inversePrimary,
theme.colorScheme.primary.withOpacity(0.2), theme.colorScheme.primary.withOpacity(0.2),
); );
final navbarTileList = final navbarTileList = useMemoized(
useMemoized(() => getNavbarTileList(context.l10n), [context.l10n]); () => getNavbarTileList(context.l10n),
[context.l10n],
);
final panelHeight = ref.watch(navigationPanelHeight); final panelHeight = ref.watch(navigationPanelHeight);
useEffect(() { final selectedIndex = useMemoized(() {
if (selectedIndex != null) { final index = navbarTileList.indexWhere(
insideSelectedIndex.value = selectedIndex!; (e) => routerState.namedLocation(e.name) == routerState.matchedLocation,
} );
return null;
}, [selectedIndex]); return index == -1 ? 0 : index;
}, [navbarTileList, routerState.matchedLocation]);
if (layoutMode == LayoutMode.extended || if (layoutMode == LayoutMode.extended ||
(mediaQuery.mdAndUp && layoutMode == LayoutMode.adaptive) || (mediaQuery.mdAndUp && layoutMode == LayoutMode.adaptive) ||
@ -91,14 +90,9 @@ class SpotubeNavigationBar extends HookConsumerWidget {
}); });
}, },
).toList(), ).toList(),
index: insideSelectedIndex.value, index: selectedIndex,
onTap: (i) { onTap: (i) {
insideSelectedIndex.value = i; ServiceUtils.navigateNamed(context, navbarTileList[i].name);
if (navbarTileList[i].id == "settings") {
Sidebar.goToSettings(context);
return;
}
onSelectedIndexChanged(i);
}, },
), ),
), ),

View File

@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:spotube/components/shared/links/anchor_button.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:version/version.dart';
class RootAppUpdateDialog extends StatelessWidget {
final Version? version;
final int? nightlyBuildNum;
const RootAppUpdateDialog({super.key, this.version}) : nightlyBuildNum = null;
const RootAppUpdateDialog.nightly({super.key, required this.nightlyBuildNum})
: version = null;
@override
Widget build(BuildContext context) {
const url = "https://spotube.krtirtho.dev/downloads";
const nightlyUrl = "https://spotube.krtirtho.dev/downloads/nightly";
return AlertDialog(
title: const Text("Spotube has an update"),
actions: [
FilledButton(
child: const Text("Download Now"),
onPressed: () => launchUrlString(
nightlyBuildNum != null ? nightlyUrl : url,
mode: LaunchMode.externalApplication,
),
),
],
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
nightlyBuildNum != null
? "Spotube Nightly $nightlyBuildNum has been released"
: "Spotube v$version has been released",
),
if (nightlyBuildNum == null)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text("Read the latest "),
AnchorButton(
"release notes",
style: const TextStyle(color: Colors.blue),
onTap: () => launchUrlString(
url,
mode: LaunchMode.externalApplication,
),
),
],
),
],
),
);
}
}

View File

@ -1,6 +1,7 @@
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:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/pages/settings/settings.dart';
import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
@ -25,7 +26,7 @@ class AnonymousFallback extends ConsumerWidget {
const SizedBox(height: 10), const SizedBox(height: 10),
FilledButton( FilledButton(
child: Text(context.l10n.login_with_spotify), child: Text(context.l10n.login_with_spotify),
onPressed: () => ServiceUtils.push(context, "/settings"), onPressed: () => ServiceUtils.pushNamed(context, SettingsPage.name),
) )
], ],
), ),

View File

@ -96,7 +96,7 @@ class HorizontalPlaybuttonCardView<T> extends HookWidget {
return switch (item) { return switch (item) {
PlaylistSimple() => PlaylistSimple() =>
PlaylistCard(item as PlaylistSimple), PlaylistCard(item as PlaylistSimple),
AlbumSimple() => AlbumCard(item as Album), AlbumSimple() => AlbumCard(item as AlbumSimple),
Artist() => Padding( Artist() => Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 12.0), horizontal: 12.0),

View File

@ -1,7 +1,7 @@
import 'package:draggable_scrollbar/draggable_scrollbar.dart'; import 'package:draggable_scrollbar/draggable_scrollbar.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:spotube/utils/platform.dart';
class InterScrollbar extends HookWidget { class InterScrollbar extends HookWidget {
final Widget child; final Widget child;
@ -15,7 +15,7 @@ class InterScrollbar extends HookWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (DesktopTools.platform.isDesktop) return child; if (kIsDesktop) return child;
return DraggableScrollbar.semicircle( return DraggableScrollbar.semicircle(
controller: controller, controller: controller,

View File

@ -1,6 +1,7 @@
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/links/anchor_button.dart'; import 'package:spotube/components/shared/links/anchor_button.dart';
import 'package:spotube/pages/artist/artist.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
class ArtistLink extends StatelessWidget { class ArtistLink extends StatelessWidget {
@ -40,9 +41,12 @@ class ArtistLink extends StatelessWidget {
if (onRouteChange != null) { if (onRouteChange != null) {
onRouteChange?.call("/artist/${artist.value.id}"); onRouteChange?.call("/artist/${artist.value.id}");
} else { } else {
ServiceUtils.push( ServiceUtils.pushNamed(
context, context,
"/artist/${artist.value.id}", ArtistPage.name,
pathParameters: {
"id": artist.value.id!,
},
); );
} }
}, },

View File

@ -7,7 +7,8 @@ import 'package:titlebar_buttons/titlebar_buttons.dart';
import 'dart:math'; import 'dart:math';
import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/foundation.dart' show kIsWeb;
import 'dart:io' show Platform; import 'dart:io' show Platform;
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
import 'package:window_manager/window_manager.dart';
class PageWindowTitleBar extends StatefulHookConsumerWidget class PageWindowTitleBar extends StatefulHookConsumerWidget
implements PreferredSizeWidget { implements PreferredSizeWidget {
@ -89,7 +90,7 @@ class _PageWindowTitleBarState extends ConsumerState<PageWindowTitleBar> {
final systemTitleBar = final systemTitleBar =
ref.read(userPreferencesProvider.select((s) => s.systemTitleBar)); ref.read(userPreferencesProvider.select((s) => s.systemTitleBar));
if (kIsDesktop && !systemTitleBar) { if (kIsDesktop && !systemTitleBar) {
DesktopTools.window.startDragging(); windowManager.startDragging();
} }
} }
@ -107,11 +108,7 @@ class _PageWindowTitleBarState extends ConsumerState<PageWindowTitleBar> {
return SliverPadding( return SliverPadding(
padding: EdgeInsets.only( padding: EdgeInsets.only(
left: DesktopTools.platform.isMacOS && left: kIsMacOS && hasFullscreen && hasLeadingOrCanPop ? 65 : 0,
hasFullscreen &&
hasLeadingOrCanPop
? 65
: 0,
), ),
sliver: SliverAppBar( sliver: SliverAppBar(
leading: widget.leading, leading: widget.leading,
@ -128,7 +125,10 @@ class _PageWindowTitleBarState extends ConsumerState<PageWindowTitleBar> {
leadingWidth: widget.leadingWidth, leadingWidth: widget.leadingWidth,
toolbarTextStyle: widget.toolbarTextStyle, toolbarTextStyle: widget.toolbarTextStyle,
titleTextStyle: widget.titleTextStyle, titleTextStyle: widget.titleTextStyle,
title: widget.title, title: SizedBox(
width: double.infinity, // workaround to force dragging
child: widget.title ?? const Text(""),
),
pinned: widget.pinned, pinned: widget.pinned,
floating: widget.floating, floating: widget.floating,
snap: widget.snap, snap: widget.snap,
@ -149,11 +149,7 @@ class _PageWindowTitleBarState extends ConsumerState<PageWindowTitleBar> {
onVerticalDragStart: onDrag, onVerticalDragStart: onDrag,
child: Padding( child: Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(
left: DesktopTools.platform.isMacOS && left: kIsMacOS && hasFullscreen && hasLeadingOrCanPop ? 65 : 0,
hasFullscreen &&
hasLeadingOrCanPop
? 65
: 0,
), ),
child: AppBar( child: AppBar(
leading: widget.leading, leading: widget.leading,
@ -171,7 +167,14 @@ class _PageWindowTitleBarState extends ConsumerState<PageWindowTitleBar> {
leadingWidth: widget.leadingWidth, leadingWidth: widget.leadingWidth,
toolbarTextStyle: widget.toolbarTextStyle, toolbarTextStyle: widget.toolbarTextStyle,
titleTextStyle: widget.titleTextStyle, titleTextStyle: widget.titleTextStyle,
title: widget.title, title: SizedBox(
width: double.infinity, // workaround to force dragging
child: widget.title ?? const Text(""),
),
scrolledUnderElevation: 0,
shadowColor: Colors.transparent,
forceMaterialTransparency: true,
elevation: 0,
), ),
), ),
); );
@ -193,12 +196,12 @@ class WindowTitleBarButtons extends HookConsumerWidget {
const type = ThemeType.auto; const type = ThemeType.auto;
Future<void> onClose() async { Future<void> onClose() async {
await DesktopTools.window.close(); await windowManager.close();
} }
useEffect(() { useEffect(() {
if (kIsDesktop) { if (kIsDesktop) {
DesktopTools.window.isMaximized().then((value) { windowManager.isMaximized().then((value) {
isMaximized.value = value; isMaximized.value = value;
}); });
} }
@ -235,14 +238,14 @@ class WindowTitleBarButtons extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MinimizeWindowButton( MinimizeWindowButton(
onPressed: DesktopTools.window.minimize, onPressed: windowManager.minimize,
colors: colors, colors: colors,
), ),
if (isMaximized.value != true) if (isMaximized.value != true)
MaximizeWindowButton( MaximizeWindowButton(
colors: colors, colors: colors,
onPressed: () { onPressed: () {
DesktopTools.window.maximize(); windowManager.maximize();
isMaximized.value = true; isMaximized.value = true;
}, },
) )
@ -250,7 +253,7 @@ class WindowTitleBarButtons extends HookConsumerWidget {
RestoreWindowButton( RestoreWindowButton(
colors: colors, colors: colors,
onPressed: () { onPressed: () {
DesktopTools.window.unmaximize(); windowManager.unmaximize();
isMaximized.value = false; isMaximized.value = false;
}, },
), ),
@ -270,16 +273,16 @@ class WindowTitleBarButtons extends HookConsumerWidget {
children: [ children: [
DecoratedMinimizeButton( DecoratedMinimizeButton(
type: type, type: type,
onPressed: DesktopTools.window.minimize, onPressed: windowManager.minimize,
), ),
DecoratedMaximizeButton( DecoratedMaximizeButton(
type: type, type: type,
onPressed: () async { onPressed: () async {
if (await DesktopTools.window.isMaximized()) { if (await windowManager.isMaximized()) {
await DesktopTools.window.unmaximize(); await windowManager.unmaximize();
isMaximized.value = false; isMaximized.value = false;
} else { } else {
await DesktopTools.window.maximize(); await windowManager.maximize();
isMaximized.value = true; isMaximized.value = true;
} }
}, },

View File

@ -5,7 +5,8 @@ import 'package:spotube/hooks/utils/use_brightness_value.dart';
class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget { class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget {
final List<Widget> tabs; final List<Widget> tabs;
const ThemedButtonsTabBar({super.key, required this.tabs}); final TabController? controller;
const ThemedButtonsTabBar({super.key, required this.tabs, this.controller});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -21,6 +22,7 @@ class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget {
bottom: 8, bottom: 8,
), ),
child: ButtonsTabBar( child: ButtonsTabBar(
controller: controller,
radius: 100, radius: 100,
decoration: BoxDecoration( decoration: BoxDecoration(
color: bgColor, color: bgColor,

View File

@ -8,7 +8,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/library/user_local_tracks.dart';
import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart'; import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart';
import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart'; import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart';
import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; import 'package:spotube/components/shared/dialogs/prompt_dialog.dart';
@ -23,6 +22,7 @@ import 'package:spotube/models/local_track.dart';
import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart';
import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/local_tracks/local_tracks_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/spotify_provider.dart';
@ -197,6 +197,8 @@ class TrackOptions extends HookConsumerWidget {
return downloadManager.getProgressNotifier(spotubeTrack); return downloadManager.getProgressNotifier(spotubeTrack);
}); });
final isLocalTrack = track is LocalTrack;
final adaptivePopSheetList = AdaptivePopSheetList<TrackOptionValue>( final adaptivePopSheetList = AdaptivePopSheetList<TrackOptionValue>(
onSelected: (value) async { onSelected: (value) async {
switch (value) { switch (value) {
@ -314,118 +316,120 @@ class TrackOptions extends HookConsumerWidget {
), ),
), ),
], ],
children: switch (track.runtimeType) { children: [
LocalTrack() => [ if (isLocalTrack)
PopSheetEntry( PopSheetEntry(
value: TrackOptionValue.delete, value: TrackOptionValue.delete,
leading: const Icon(SpotubeIcons.trash), leading: const Icon(SpotubeIcons.trash),
title: Text(context.l10n.delete), title: Text(context.l10n.delete),
) ),
], if (mediaQuery.smAndDown)
_ => [ PopSheetEntry(
if (mediaQuery.smAndDown) value: TrackOptionValue.album,
PopSheetEntry( leading: const Icon(SpotubeIcons.album),
value: TrackOptionValue.album, title: Text(context.l10n.go_to_album),
leading: const Icon(SpotubeIcons.album), subtitle: Text(track.album!.name!),
title: Text(context.l10n.go_to_album), ),
subtitle: Text(track.album!.name!), if (!playlist.containsTrack(track)) ...[
), PopSheetEntry(
if (!playlist.containsTrack(track)) ...[ value: TrackOptionValue.addToQueue,
PopSheetEntry( leading: const Icon(SpotubeIcons.queueAdd),
value: TrackOptionValue.addToQueue, title: Text(context.l10n.add_to_queue),
leading: const Icon(SpotubeIcons.queueAdd), ),
title: Text(context.l10n.add_to_queue), PopSheetEntry(
), value: TrackOptionValue.playNext,
PopSheetEntry( leading: const Icon(SpotubeIcons.lightning),
value: TrackOptionValue.playNext, title: Text(context.l10n.play_next),
leading: const Icon(SpotubeIcons.lightning), ),
title: Text(context.l10n.play_next), ] else
), PopSheetEntry(
] else value: TrackOptionValue.removeFromQueue,
PopSheetEntry( enabled: playlist.activeTrack?.id != track.id,
value: TrackOptionValue.removeFromQueue, leading: const Icon(SpotubeIcons.queueRemove),
enabled: playlist.activeTrack?.id != track.id, title: Text(context.l10n.remove_from_queue),
leading: const Icon(SpotubeIcons.queueRemove), ),
title: Text(context.l10n.remove_from_queue), if (me.asData?.value != null && !isLocalTrack)
), PopSheetEntry(
if (me.asData?.value != null) value: TrackOptionValue.favorite,
PopSheetEntry( leading: favorites.isLiked
value: TrackOptionValue.favorite, ? const Icon(
leading: favorites.isLiked SpotubeIcons.heartFilled,
? const Icon( color: Colors.pink,
SpotubeIcons.heartFilled, )
color: Colors.pink, : const Icon(SpotubeIcons.heart),
) title: Text(
: const Icon(SpotubeIcons.heart), favorites.isLiked
title: Text( ? context.l10n.remove_from_favorites
favorites.isLiked : context.l10n.save_as_favorite,
? context.l10n.remove_from_favorites
: context.l10n.save_as_favorite,
),
),
if (auth != null) ...[
PopSheetEntry(
value: TrackOptionValue.startRadio,
leading: const Icon(SpotubeIcons.radio),
title: Text(context.l10n.start_a_radio),
),
PopSheetEntry(
value: TrackOptionValue.addToPlaylist,
leading: const Icon(SpotubeIcons.playlistAdd),
title: Text(context.l10n.add_to_playlist),
),
],
if (userPlaylist && auth != null)
PopSheetEntry(
value: TrackOptionValue.removeFromPlaylist,
leading: const Icon(SpotubeIcons.removeFilled),
title: Text(context.l10n.remove_from_playlist),
),
PopSheetEntry(
value: TrackOptionValue.download,
enabled: !isInQueue,
leading: isInQueue
? HookBuilder(builder: (context) {
final progress = useListenable(progressNotifier!);
return CircularProgressIndicator(
value: progress.value,
);
})
: const Icon(SpotubeIcons.download),
title: Text(context.l10n.download_track),
), ),
PopSheetEntry( ),
value: TrackOptionValue.blacklist, if (auth != null && !isLocalTrack) ...[
leading: const Icon(SpotubeIcons.playlistRemove), PopSheetEntry(
iconColor: !isBlackListed ? Colors.red[400] : null, value: TrackOptionValue.startRadio,
textColor: !isBlackListed ? Colors.red[400] : null, leading: const Icon(SpotubeIcons.radio),
title: Text( title: Text(context.l10n.start_a_radio),
isBlackListed ),
? context.l10n.remove_from_blacklist PopSheetEntry(
: context.l10n.add_to_blacklist, value: TrackOptionValue.addToPlaylist,
), leading: const Icon(SpotubeIcons.playlistAdd),
title: Text(context.l10n.add_to_playlist),
),
],
if (userPlaylist && auth != null && !isLocalTrack)
PopSheetEntry(
value: TrackOptionValue.removeFromPlaylist,
leading: const Icon(SpotubeIcons.removeFilled),
title: Text(context.l10n.remove_from_playlist),
),
if (!isLocalTrack)
PopSheetEntry(
value: TrackOptionValue.download,
enabled: !isInQueue,
leading: isInQueue
? HookBuilder(builder: (context) {
final progress = useListenable(progressNotifier!);
return CircularProgressIndicator(
value: progress.value,
);
})
: const Icon(SpotubeIcons.download),
title: Text(context.l10n.download_track),
),
if (!isLocalTrack)
PopSheetEntry(
value: TrackOptionValue.blacklist,
leading: const Icon(SpotubeIcons.playlistRemove),
iconColor: !isBlackListed ? Colors.red[400] : null,
textColor: !isBlackListed ? Colors.red[400] : null,
title: Text(
isBlackListed
? context.l10n.remove_from_blacklist
: context.l10n.add_to_blacklist,
), ),
PopSheetEntry( ),
value: TrackOptionValue.share, if (!isLocalTrack)
leading: const Icon(SpotubeIcons.share), PopSheetEntry(
title: Text(context.l10n.share), value: TrackOptionValue.share,
leading: const Icon(SpotubeIcons.share),
title: Text(context.l10n.share),
),
if (!isLocalTrack)
PopSheetEntry(
value: TrackOptionValue.songlink,
leading: Assets.logos.songlinkTransparent.image(
width: 22,
height: 22,
color: colorScheme.onSurface.withOpacity(0.5),
), ),
PopSheetEntry( title: Text(context.l10n.song_link),
value: TrackOptionValue.songlink, ),
leading: Assets.logos.songlinkTransparent.image( if (!isLocalTrack)
width: 22, PopSheetEntry(
height: 22, value: TrackOptionValue.details,
color: colorScheme.onSurface.withOpacity(0.5), leading: const Icon(SpotubeIcons.info),
), title: Text(context.l10n.details),
title: Text(context.l10n.song_link), ),
), ],
PopSheetEntry(
value: TrackOptionValue.details,
leading: const Icon(SpotubeIcons.info),
title: Text(context.l10n.details),
),
]
},
); );
//! This is the most ANTI pattern I've ever done, but it works //! This is the most ANTI pattern I've ever done, but it works

View File

@ -195,19 +195,26 @@ class TrackTile extends HookConsumerWidget {
children: [ children: [
Expanded( Expanded(
flex: 6, flex: 6,
child: LinkText( child: switch (track) {
track.name!, LocalTrack() => Text(
"/track/${track.id}", track.name!,
push: true, maxLines: 1,
maxLines: 1, overflow: TextOverflow.ellipsis,
overflow: TextOverflow.ellipsis, ),
), _ => LinkText(
track.name!,
"/track/${track.id}",
push: true,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
},
), ),
if (constrains.mdAndUp) ...[ if (constrains.mdAndUp) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
flex: 4, flex: 4,
child: switch (track.runtimeType) { child: switch (track) {
LocalTrack() => Text( LocalTrack() => Text(
track.album!.name!, track.album!.name!,
maxLines: 1, maxLines: 1,

View File

@ -17,6 +17,7 @@ import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/components/shared/tracks_view/track_view_provider.dart'; import 'package:spotube/components/shared/tracks_view/track_view_provider.dart';
import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/connect/connect.dart';
import 'package:spotube/provider/history/history.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart';
@ -28,6 +29,7 @@ class TrackViewBodySection extends HookConsumerWidget {
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final playlist = ref.watch(proxyPlaylistProvider); final playlist = ref.watch(proxyPlaylistProvider);
final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryProvider.notifier);
final props = InheritedTrackView.of(context); final props = InheritedTrackView.of(context);
final trackViewState = ref.watch(trackViewProvider(props.tracks)); final trackViewState = ref.watch(trackViewProvider(props.tracks));
@ -146,11 +148,17 @@ class TrackViewBodySection extends HookConsumerWidget {
} else { } else {
final tracks = await props.pagination.onFetchAll(); final tracks = await props.pagination.onFetchAll();
await remotePlayback.load( await remotePlayback.load(
WebSocketLoadEventData( props.collection is AlbumSimple
tracks: tracks, ? WebSocketLoadEventData.album(
collectionId: props.collectionId, tracks: tracks,
initialIndex: index, collection: props.collection as AlbumSimple,
), initialIndex: index,
)
: WebSocketLoadEventData.playlist(
tracks: tracks,
collection: props.collection as PlaylistSimple,
initialIndex: index,
),
); );
} }
} else { } else {
@ -164,6 +172,13 @@ class TrackViewBodySection extends HookConsumerWidget {
autoPlay: true, autoPlay: true,
); );
playlistNotifier.addCollection(props.collectionId); playlistNotifier.addCollection(props.collectionId);
if (props.collection is AlbumSimple) {
historyNotifier
.addAlbums([props.collection as AlbumSimple]);
} else {
historyNotifier
.addPlaylists([props.collection as PlaylistSimple]);
}
} }
} }
}, },

View File

@ -1,5 +1,6 @@
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:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart'; import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart';
import 'package:spotube/components/shared/dialogs/confirm_download_dialog.dart'; import 'package:spotube/components/shared/dialogs/confirm_download_dialog.dart';
@ -8,6 +9,7 @@ import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/components/shared/tracks_view/track_view_provider.dart'; import 'package:spotube/components/shared/tracks_view/track_view_provider.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/history/history.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
@ -23,6 +25,7 @@ class TrackViewBodyOptions extends HookConsumerWidget {
ref.watch(downloadManagerProvider); ref.watch(downloadManagerProvider);
final downloader = ref.watch(downloadManagerProvider.notifier); final downloader = ref.watch(downloadManagerProvider.notifier);
final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryProvider.notifier);
final audioSource = final audioSource =
ref.watch(userPreferencesProvider.select((s) => s.audioSource)); ref.watch(userPreferencesProvider.select((s) => s.audioSource));
@ -72,6 +75,12 @@ class TrackViewBodyOptions extends HookConsumerWidget {
{ {
playlistNotifier.addTracksAtFirst(selectedTracks); playlistNotifier.addTracksAtFirst(selectedTracks);
playlistNotifier.addCollection(props.collectionId); playlistNotifier.addCollection(props.collectionId);
if (props.collection is AlbumSimple) {
historyNotifier.addAlbums([props.collection as AlbumSimple]);
} else {
historyNotifier
.addPlaylists([props.collection as PlaylistSimple]);
}
trackViewState.deselectAll(); trackViewState.deselectAll();
break; break;
} }
@ -79,6 +88,12 @@ class TrackViewBodyOptions extends HookConsumerWidget {
{ {
playlistNotifier.addTracks(selectedTracks); playlistNotifier.addTracks(selectedTracks);
playlistNotifier.addCollection(props.collectionId); playlistNotifier.addCollection(props.collectionId);
if (props.collection is AlbumSimple) {
historyNotifier.addAlbums([props.collection as AlbumSimple]);
} else {
historyNotifier
.addPlaylists([props.collection as PlaylistSimple]);
}
trackViewState.deselectAll(); trackViewState.deselectAll();
break; break;
} }

View File

@ -1,7 +1,7 @@
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/image/universal_image.dart';
@ -12,6 +12,7 @@ import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/hooks/utils/use_palette_color.dart'; import 'package:spotube/hooks/utils/use_palette_color.dart';
import 'package:spotube/utils/platform.dart';
class TrackViewFlexHeader extends HookConsumerWidget { class TrackViewFlexHeader extends HookConsumerWidget {
const TrackViewFlexHeader({super.key}); const TrackViewFlexHeader({super.key});
@ -53,7 +54,7 @@ class TrackViewFlexHeader extends HookConsumerWidget {
floating: false, floating: false,
pinned: true, pinned: true,
expandedHeight: 450, expandedHeight: 450,
automaticallyImplyLeading: DesktopTools.platform.isMobile, automaticallyImplyLeading: kIsMobile,
backgroundColor: palette.color, backgroundColor: palette.color,
title: isExpanded ? null : Text(props.title, style: headingStyle), title: isExpanded ? null : Text(props.title, style: headingStyle),
flexibleSpace: FlexibleSpaceBar( flexibleSpace: FlexibleSpaceBar(

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.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:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/playlist/playlist_create_dialog.dart'; import 'package:spotube/components/playlist/playlist_create_dialog.dart';
import 'package:spotube/components/shared/heart_button.dart'; import 'package:spotube/components/shared/heart_button.dart';
@ -9,6 +10,7 @@ import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_
import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/history/history.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
class TrackViewHeaderActions extends HookConsumerWidget { class TrackViewHeaderActions extends HookConsumerWidget {
@ -20,6 +22,7 @@ class TrackViewHeaderActions extends HookConsumerWidget {
final playlist = ref.watch(proxyPlaylistProvider); final playlist = ref.watch(proxyPlaylistProvider);
final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryProvider.notifier);
final isActive = playlist.collections.contains(props.collectionId); final isActive = playlist.collections.contains(props.collectionId);
@ -61,6 +64,13 @@ class TrackViewHeaderActions extends HookConsumerWidget {
final tracks = await props.pagination.onFetchAll(); final tracks = await props.pagination.onFetchAll();
await playlistNotifier.addTracks(tracks); await playlistNotifier.addTracks(tracks);
playlistNotifier.addCollection(props.collectionId); playlistNotifier.addCollection(props.collectionId);
if (props.collection is AlbumSimple) {
historyNotifier
.addAlbums([props.collection as AlbumSimple]);
} else {
historyNotifier
.addPlaylists([props.collection as PlaylistSimple]);
}
}, },
), ),
if (props.onHeart != null && auth != null) if (props.onHeart != null && auth != null)

View File

@ -5,12 +5,14 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:palette_generator/palette_generator.dart'; import 'package:palette_generator/palette_generator.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/connect/connect.dart';
import 'package:spotube/provider/history/history.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
@ -28,6 +30,7 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
final props = InheritedTrackView.of(context); final props = InheritedTrackView.of(context);
final playlist = ref.watch(proxyPlaylistProvider); final playlist = ref.watch(proxyPlaylistProvider);
final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryProvider.notifier);
final isActive = playlist.collections.contains(props.collectionId); final isActive = playlist.collections.contains(props.collectionId);
@ -44,28 +47,45 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
try { try {
isLoading.value = true; isLoading.value = true;
final allTracks = await props.pagination.onFetchAll(); final initialTracks = props.tracks;
if (!context.mounted) return; if (!context.mounted) return;
final isRemoteDevice = await showSelectDeviceDialog(context, ref); final isRemoteDevice = await showSelectDeviceDialog(context, ref);
if (isRemoteDevice) { if (isRemoteDevice) {
final allTracks = await props.pagination.onFetchAll();
final remotePlayback = ref.read(connectProvider.notifier); final remotePlayback = ref.read(connectProvider.notifier);
await remotePlayback.load( await remotePlayback.load(
WebSocketLoadEventData( props.collection is AlbumSimple
tracks: allTracks, ? WebSocketLoadEventData.album(
collectionId: props.collectionId, tracks: allTracks,
initialIndex: Random().nextInt(allTracks.length)), collection: props.collection as AlbumSimple,
initialIndex: Random().nextInt(allTracks.length))
: WebSocketLoadEventData.playlist(
tracks: allTracks,
collection: props.collection as PlaylistSimple,
initialIndex: Random().nextInt(allTracks.length),
),
); );
await remotePlayback.setShuffle(true); await remotePlayback.setShuffle(true);
} else { } else {
await playlistNotifier.load( await playlistNotifier.load(
allTracks, initialTracks,
autoPlay: true, autoPlay: true,
initialIndex: Random().nextInt(allTracks.length), initialIndex: Random().nextInt(initialTracks.length),
); );
await audioPlayer.setShuffle(true); await audioPlayer.setShuffle(true);
playlistNotifier.addCollection(props.collectionId); playlistNotifier.addCollection(props.collectionId);
if (props.collection is AlbumSimple) {
historyNotifier.addAlbums([props.collection as AlbumSimple]);
} else {
historyNotifier.addPlaylists([props.collection as PlaylistSimple]);
}
final allTracks = await props.pagination.onFetchAll();
await playlistNotifier.addTracks(
allTracks.sublist(initialTracks.length),
);
} }
} finally { } finally {
isLoading.value = false; isLoading.value = false;
@ -76,22 +96,39 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
try { try {
isLoading.value = true; isLoading.value = true;
final allTracks = await props.pagination.onFetchAll(); final initialTracks = props.tracks;
if (!context.mounted) return; if (!context.mounted) return;
final isRemoteDevice = await showSelectDeviceDialog(context, ref); final isRemoteDevice = await showSelectDeviceDialog(context, ref);
if (isRemoteDevice) { if (isRemoteDevice) {
final allTracks = await props.pagination.onFetchAll();
final remotePlayback = ref.read(connectProvider.notifier); final remotePlayback = ref.read(connectProvider.notifier);
await remotePlayback.load( await remotePlayback.load(
WebSocketLoadEventData( props.collection is AlbumSimple
tracks: allTracks, ? WebSocketLoadEventData.album(
collectionId: props.collectionId, tracks: allTracks,
), collection: props.collection as AlbumSimple,
)
: WebSocketLoadEventData.playlist(
tracks: allTracks,
collection: props.collection as PlaylistSimple,
),
); );
} else { } else {
await playlistNotifier.load(allTracks, autoPlay: true); await playlistNotifier.load(initialTracks, autoPlay: true);
playlistNotifier.addCollection(props.collectionId); playlistNotifier.addCollection(props.collectionId);
if (props.collection is AlbumSimple) {
historyNotifier.addAlbums([props.collection as AlbumSimple]);
} else {
historyNotifier.addPlaylists([props.collection as PlaylistSimple]);
}
final allTracks = await props.pagination.onFetchAll();
await playlistNotifier.addTracks(
allTracks.sublist(initialTracks.length),
);
} }
} finally { } finally {
isLoading.value = false; isLoading.value = false;

View File

@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_desktop_tools/flutter_desktop_tools.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:sliver_tools/sliver_tools.dart'; import 'package:sliver_tools/sliver_tools.dart';
@ -8,6 +8,7 @@ import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/components/shared/tracks_view/sections/header/flexible_header.dart'; import 'package:spotube/components/shared/tracks_view/sections/header/flexible_header.dart';
import 'package:spotube/components/shared/tracks_view/sections/body/track_view_body.dart'; import 'package:spotube/components/shared/tracks_view/sections/body/track_view_body.dart';
import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/utils/platform.dart';
class TrackView extends HookConsumerWidget { class TrackView extends HookConsumerWidget {
const TrackView({super.key}); const TrackView({super.key});
@ -18,7 +19,7 @@ class TrackView extends HookConsumerWidget {
final controller = useScrollController(); final controller = useScrollController();
return Scaffold( return Scaffold(
appBar: DesktopTools.platform.isDesktop appBar: kIsDesktop
? const PageWindowTitleBar( ? const PageWindowTitleBar(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
foregroundColor: Colors.white, foregroundColor: Colors.white,

View File

@ -39,7 +39,7 @@ class PaginationProps {
} }
class InheritedTrackView extends InheritedWidget { class InheritedTrackView extends InheritedWidget {
final String collectionId; final Object collection;
final String title; final String title;
final String? description; final String? description;
final String image; final String image;
@ -55,7 +55,7 @@ class InheritedTrackView extends InheritedWidget {
const InheritedTrackView({ const InheritedTrackView({
super.key, super.key,
required super.child, required super.child,
required this.collectionId, required this.collection,
required this.title, required this.title,
this.description, this.description,
required this.image, required this.image,
@ -65,7 +65,11 @@ class InheritedTrackView extends InheritedWidget {
required this.shareUrl, required this.shareUrl,
this.isLiked = false, this.isLiked = false,
this.onHeart, this.onHeart,
}); }) : assert(collection is AlbumSimple || collection is PlaylistSimple);
String get collectionId => collection is AlbumSimple
? (collection as AlbumSimple).id!
: (collection as PlaylistSimple).id!;
@override @override
bool updateShouldNotify(InheritedTrackView oldWidget) { bool updateShouldNotify(InheritedTrackView oldWidget) {
@ -78,7 +82,7 @@ class InheritedTrackView extends InheritedWidget {
oldWidget.onHeart != onHeart || oldWidget.onHeart != onHeart ||
oldWidget.shareUrl != shareUrl || oldWidget.shareUrl != shareUrl ||
oldWidget.routePath != routePath || oldWidget.routePath != routePath ||
oldWidget.collectionId != collectionId || oldWidget.collection != collection ||
oldWidget.child != child; oldWidget.child != child;
} }

View File

@ -20,8 +20,6 @@ class Waypoint extends HookWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isMounted = useIsMounted();
useEffect(() { useEffect(() {
if (isGrid) { if (isGrid) {
return null; return null;
@ -32,19 +30,19 @@ class Waypoint extends HookWidget {
// scrollController fetches the next paginated data when the current // scrollController fetches the next paginated data when the current
// position of the user on the screen has surpassed // position of the user on the screen has surpassed
if (controller.position.pixels >= nextPageTrigger && isMounted()) { if (controller.position.pixels >= nextPageTrigger && context.mounted) {
await onTouchEdge?.call(); await onTouchEdge?.call();
} }
} }
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (controller.hasClients && isMounted()) { if (controller.hasClients && context.mounted) {
listener(); listener();
controller.addListener(listener); controller.addListener(listener);
} }
}); });
return () => controller.removeListener(listener); return () => controller.removeListener(listener);
}, [controller, onTouchEdge, isMounted]); }, [controller, onTouchEdge]);
if (isGrid) { if (isGrid) {
return VisibilityDetector( return VisibilityDetector(

View File

@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/album/album_card.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/components/shared/links/artist_link.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/pages/album/album.dart';
import 'package:spotube/utils/service_utils.dart';
class StatsAlbumItem extends StatelessWidget {
final AlbumSimple album;
final Widget info;
const StatsAlbumItem({super.key, required this.album, required this.info});
@override
Widget build(BuildContext context) {
return ListTile(
horizontalTitleGap: 8,
leading: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: UniversalImage(
path: (album.images).asUrlString(
placeholder: ImagePlaceholder.albumArt,
),
width: 40,
height: 40,
),
),
title: Text(album.name!),
subtitle: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text("${album.albumType?.formatted}"),
Flexible(
child: ArtistLink(
artists: album.artists!,
mainAxisAlignment: WrapAlignment.start,
),
),
],
),
trailing: info,
onTap: () {
ServiceUtils.pushNamed(
context,
AlbumPage.name,
pathParameters: {"id": album.id!},
extra: album,
);
},
);
}
}

View File

@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/pages/artist/artist.dart';
import 'package:spotube/utils/service_utils.dart';
class StatsArtistItem extends StatelessWidget {
final Artist artist;
final Widget info;
const StatsArtistItem({
super.key,
required this.artist,
required this.info,
});
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(artist.name!),
horizontalTitleGap: 8,
leading: CircleAvatar(
backgroundImage: UniversalImage.imageProvider(
(artist.images).asUrlString(
placeholder: ImagePlaceholder.artist,
),
),
),
trailing: info,
onTap: () {
ServiceUtils.pushNamed(
context,
ArtistPage.name,
pathParameters: {"id": artist.id!},
);
},
);
}
}

View File

@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/components/shared/playbutton_card.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/pages/playlist/playlist.dart';
import 'package:spotube/utils/service_utils.dart';
class StatsPlaylistItem extends StatelessWidget {
final PlaylistSimple playlist;
final Widget info;
const StatsPlaylistItem(
{super.key, required this.playlist, required this.info});
@override
Widget build(BuildContext context) {
return ListTile(
horizontalTitleGap: 8,
leading: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: UniversalImage(
path: (playlist.images).asUrlString(
placeholder: ImagePlaceholder.collection,
),
width: 40,
height: 40,
),
),
title: Text(playlist.name!),
subtitle: Text(
playlist.description!.replaceAll(htmlTagRegexp, ''),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: info,
onTap: () {
ServiceUtils.pushNamed(
context,
PlaylistPage.name,
pathParameters: {"id": playlist.id!},
extra: playlist,
);
},
);
}
}

View File

@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/components/shared/links/artist_link.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/pages/track/track.dart';
import 'package:spotube/utils/service_utils.dart';
class StatsTrackItem extends StatelessWidget {
final Track track;
final Widget info;
const StatsTrackItem({
super.key,
required this.track,
required this.info,
});
@override
Widget build(BuildContext context) {
return ListTile(
horizontalTitleGap: 8,
leading: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: UniversalImage(
path: (track.album?.images).asUrlString(
placeholder: ImagePlaceholder.albumArt,
),
width: 40,
height: 40,
),
),
title: Text(track.name!),
subtitle: ArtistLink(
artists: track.artists!,
mainAxisAlignment: WrapAlignment.start,
),
trailing: info,
onTap: () {
ServiceUtils.pushNamed(
context,
TrackPage.name,
pathParameters: {
"id": track.id!,
},
);
},
);
}
}

View File

@ -0,0 +1,100 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/formatters.dart';
import 'package:spotube/components/stats/summary/summary_card.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/pages/stats/albums/albums.dart';
import 'package:spotube/pages/stats/artists/artists.dart';
import 'package:spotube/pages/stats/fees/fees.dart';
import 'package:spotube/pages/stats/minutes/minutes.dart';
import 'package:spotube/pages/stats/playlists/playlists.dart';
import 'package:spotube/pages/stats/streams/streams.dart';
import 'package:spotube/provider/history/summary.dart';
import 'package:spotube/utils/service_utils.dart';
class StatsPageSummarySection extends HookConsumerWidget {
const StatsPageSummarySection({super.key});
@override
Widget build(BuildContext context, ref) {
final summary = ref.watch(playbackHistorySummaryProvider);
return SliverPadding(
padding: const EdgeInsets.all(10),
sliver: SliverLayoutBuilder(builder: (context, constrains) {
return SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: constrains.isXs
? 2
: constrains.smAndDown
? 3
: constrains.mdAndDown
? 4
: constrains.lgAndDown
? 5
: 6,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: constrains.isXs ? 1.3 : 1.5,
),
delegate: SliverChildListDelegate([
SummaryCard(
title: summary.duration.inMinutes.toDouble(),
unit: "minutes",
description: 'Listened to music',
color: Colors.purple,
onTap: () {
ServiceUtils.pushNamed(context, StatsMinutesPage.name);
},
),
SummaryCard(
title: summary.tracks.toDouble(),
unit: "songs",
description: 'Streamed overall',
color: Colors.lightBlue,
onTap: () {
ServiceUtils.pushNamed(context, StatsStreamsPage.name);
},
),
SummaryCard.unformatted(
title: usdFormatter.format(summary.fees.toDouble()),
unit: "",
description: 'Owed to artists\nthis month',
color: Colors.green,
onTap: () {
ServiceUtils.pushNamed(context, StatsStreamFeesPage.name);
},
),
SummaryCard(
title: summary.artists.toDouble(),
unit: "artist's",
description: 'Music reached you',
color: Colors.yellow,
onTap: () {
ServiceUtils.pushNamed(context, StatsArtistsPage.name);
},
),
SummaryCard(
title: summary.albums.toDouble(),
unit: "full albums",
description: 'Got your love',
color: Colors.pink,
onTap: () {
ServiceUtils.pushNamed(context, StatsAlbumsPage.name);
},
),
SummaryCard(
title: summary.playlists.toDouble(),
unit: "playlists",
description: 'Were on repeat',
color: Colors.teal,
onTap: () {
ServiceUtils.pushNamed(context, StatsPlaylistsPage.name);
},
),
]),
);
}),
);
}
}

View File

@ -0,0 +1,86 @@
import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:spotube/collections/formatters.dart';
class SummaryCard extends StatelessWidget {
final String title;
final String unit;
final String description;
final VoidCallback? onTap;
final MaterialColor color;
SummaryCard({
super.key,
required double title,
required this.unit,
required this.description,
required this.color,
this.onTap,
}) : title = compactNumberFormatter.format(title);
const SummaryCard.unformatted({
super.key,
required this.title,
required this.unit,
required this.description,
required this.color,
this.onTap,
});
@override
Widget build(BuildContext context) {
final ThemeData(:textTheme, :brightness) = Theme.of(context);
final descriptionNewLines = description.split("").where((s) => s == "\n");
return Card(
color: brightness == Brightness.dark ? color.shade100 : color.shade50,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 15),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
AutoSizeText.rich(
TextSpan(
children: [
TextSpan(
text: title,
style: textTheme.headlineLarge?.copyWith(
color: color.shade900,
),
),
TextSpan(
text: " $unit",
style: textTheme.titleMedium?.copyWith(
color: color.shade900,
),
),
],
),
maxLines: 1,
),
const Gap(5),
AutoSizeText(
description,
maxLines: description.contains("\n")
? descriptionNewLines.length + 1
: 1,
minFontSize: 9,
style: textTheme.labelMedium!.copyWith(
color: color.shade900,
),
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/formatters.dart';
import 'package:spotube/components/stats/common/album_item.dart';
import 'package:spotube/provider/history/top.dart';
class TopAlbums extends HookConsumerWidget {
const TopAlbums({super.key});
@override
Widget build(BuildContext context, ref) {
final historyDuration = ref.watch(playbackHistoryTopDurationProvider);
final albums = ref.watch(playbackHistoryTopProvider(historyDuration)
.select((value) => value.albums));
return SliverList.builder(
itemCount: albums.length,
itemBuilder: (context, index) {
final album = albums[index];
return StatsAlbumItem(
album: album.album,
info: Text(
"${compactNumberFormatter.format(album.count)} plays",
),
);
},
);
}
}

View File

@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/formatters.dart';
import 'package:spotube/components/stats/common/artist_item.dart';
import 'package:spotube/provider/history/top.dart';
class TopArtists extends HookConsumerWidget {
const TopArtists({super.key});
@override
Widget build(BuildContext context, ref) {
final historyDuration = ref.watch(playbackHistoryTopDurationProvider);
final artists = ref.watch(playbackHistoryTopProvider(historyDuration)
.select((value) => value.artists));
return SliverList.builder(
itemCount: artists.length,
itemBuilder: (context, index) {
final artist = artists[index];
return StatsArtistItem(
artist: artist.artist,
info: Text("${compactNumberFormatter.format(artist.count)} plays"),
);
},
);
}
}

View File

@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/components/shared/themed_button_tab_bar.dart';
import 'package:spotube/components/stats/top/albums.dart';
import 'package:spotube/components/stats/top/artists.dart';
import 'package:spotube/components/stats/top/tracks.dart';
import 'package:spotube/provider/history/state.dart';
import 'package:spotube/provider/history/top.dart';
class StatsPageTopSection extends HookConsumerWidget {
const StatsPageTopSection({super.key});
@override
Widget build(BuildContext context, ref) {
final tabController = useTabController(initialLength: 3);
final historyDuration = ref.watch(playbackHistoryTopDurationProvider);
final historyDurationNotifier =
ref.watch(playbackHistoryTopDurationProvider.notifier);
return SliverMainAxisGroup(
slivers: [
SliverAppBar(
floating: true,
flexibleSpace: ThemedButtonsTabBar(
controller: tabController,
tabs: const [
Tab(
child: Padding(
padding: EdgeInsets.all(5),
child: Text("Top Tracks"),
),
),
Tab(
child: Padding(
padding: EdgeInsets.all(5),
child: Text("Top Artists"),
),
),
Tab(
child: Padding(
padding: EdgeInsets.all(5),
child: Text("Top Albums"),
),
),
],
),
),
SliverToBoxAdapter(
child: Align(
alignment: Alignment.centerRight,
child: DropdownButton(
style: Theme.of(context).textTheme.bodySmall!,
isDense: true,
padding: const EdgeInsets.all(4),
borderRadius: BorderRadius.circular(4),
underline: const SizedBox(),
value: historyDuration,
onChanged: (value) {
if (value == null) return;
historyDurationNotifier.update((_) => value);
},
icon: const Icon(Icons.arrow_drop_down),
items: const [
DropdownMenuItem(
value: HistoryDuration.days7,
child: Text("This week"),
),
DropdownMenuItem(
value: HistoryDuration.days30,
child: Text("This month"),
),
DropdownMenuItem(
value: HistoryDuration.months6,
child: Text("Last 6 months"),
),
DropdownMenuItem(
value: HistoryDuration.year,
child: Text("This year"),
),
DropdownMenuItem(
value: HistoryDuration.years2,
child: Text("Last 2 years"),
),
DropdownMenuItem(
value: HistoryDuration.allTime,
child: Text("All time"),
),
],
),
),
),
ListenableBuilder(
listenable: tabController,
builder: (context, _) {
return switch (tabController.index) {
1 => const TopArtists(),
2 => const TopAlbums(),
_ => const TopTracks(),
};
},
),
],
);
}
}

View File

@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/formatters.dart';
import 'package:spotube/components/stats/common/track_item.dart';
import 'package:spotube/provider/history/top.dart';
class TopTracks extends HookConsumerWidget {
const TopTracks({super.key});
@override
Widget build(BuildContext context, ref) {
final historyDuration = ref.watch(playbackHistoryTopDurationProvider);
final tracks = ref.watch(
playbackHistoryTopProvider(historyDuration)
.select((value) => value.tracks),
);
return SliverList.builder(
itemCount: tracks.length,
itemBuilder: (context, index) {
final track = tracks[index];
return StatsTrackItem(
track: track.track,
info: Text(
"${compactNumberFormatter.format(track.count)} plays",
),
);
},
);
}
}

View File

@ -1,21 +1,6 @@
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
extension AlbumExtensions on AlbumSimple { extension AlbumExtensions on AlbumSimple {
Map<String, dynamic> toJson() {
return {
"albumType": albumType?.name,
"id": id,
"name": name,
"images": images
?.map((image) => {
"height": image.height,
"url": image.url,
"width": image.width,
})
.toList(),
};
}
Album toAlbum() { Album toAlbum() {
Album album = Album(); Album album = Album();
album.albumType = albumType; album.albumType = albumType;

View File

@ -1,17 +1,5 @@
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
extension ArtistJson on ArtistSimple {
Map<String, dynamic> toJson() {
return {
"href": href,
"id": id,
"name": name,
"type": type,
"uri": uri,
};
}
}
extension ArtistExtension on List<ArtistSimple> { extension ArtistExtension on List<ArtistSimple> {
String asString() { String asString() {
return map((e) => e.name?.replaceAll(",", " ")).join(", "); return map((e) => e.name?.replaceAll(",", " ")).join(", ");

View File

@ -3,8 +3,6 @@ import 'dart:io';
import 'package:metadata_god/metadata_god.dart'; import 'package:metadata_god/metadata_god.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/album_simple.dart';
import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
extension TrackExtensions on Track { extension TrackExtensions on Track {
@ -39,33 +37,6 @@ extension TrackExtensions on Track {
return this; return this;
} }
Map<String, dynamic> toJson() {
return TrackExtensions.trackToJson(this);
}
static Map<String, dynamic> trackToJson(Track track) {
return {
"album": track.album?.toJson(),
"artists": track.artists?.map((artist) => artist.toJson()).toList(),
"available_markets": track.availableMarkets?.map((e) => e.name).toList(),
"disc_number": track.discNumber,
"duration_ms": track.durationMs,
"explicit": track.explicit,
// "external_ids"track.: externalIds,
// "external_urls"track.: externalUrls,
"href": track.href,
"id": track.id,
"is_playable": track.isPlayable,
// "linked_from"track.: linkedFrom,
"name": track.name,
"popularity": track.popularity,
"preview_rrl": track.previewUrl,
"track_number": track.trackNumber,
"type": track.type,
"uri": track.uri,
};
}
} }
extension TrackSimpleExtensions on TrackSimple { extension TrackSimpleExtensions on TrackSimple {

View File

@ -1,29 +1,31 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/hooks/configurators/use_window_listener.dart'; import 'package:spotube/hooks/configurators/use_window_listener.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
// ignore: depend_on_referenced_packages
import 'package:local_notifier/local_notifier.dart'; import 'package:local_notifier/local_notifier.dart';
import 'package:spotube/utils/platform.dart';
import 'package:window_manager/window_manager.dart';
final closeNotification = DesktopTools.createNotification( final closeNotification = !kIsDesktop
title: 'Spotube', ? null
message: 'Running in background. Minimized to System Tray', : (LocalNotification(
actions: [ title: 'Spotube',
LocalNotificationAction(text: 'Close The App'), body: 'Running in background. Minimized to System Tray',
], actions: [
)?..onClickAction = (value) { LocalNotificationAction(text: 'Close The App'),
exit(0); ],
}; )..onClickAction = (value) {
exit(0);
});
void useCloseBehavior(WidgetRef ref) { void useCloseBehavior(WidgetRef ref) {
useWindowListener( useWindowListener(
onWindowClose: () async { onWindowClose: () async {
final preferences = ref.read(userPreferencesProvider); final preferences = ref.read(userPreferencesProvider);
if (preferences.closeBehavior == CloseBehavior.minimizeToTray) { if (preferences.closeBehavior == CloseBehavior.minimizeToTray) {
await DesktopTools.window.hide(); await windowManager.hide();
closeNotification?.show(); closeNotification?.show();
} else { } else {
exit(0); exit(0);

View File

@ -7,7 +7,7 @@ import 'package:spotube/collections/routes.dart';
import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/spotify_provider.dart';
import 'package:flutter_sharing_intent/flutter_sharing_intent.dart'; import 'package:flutter_sharing_intent/flutter_sharing_intent.dart';
import 'package:flutter_sharing_intent/model/sharing_file.dart'; import 'package:flutter_sharing_intent/model/sharing_file.dart';
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:spotube/utils/platform.dart';
final appLinks = AppLinks(); final appLinks = AppLinks();
final linkStream = appLinks.allStringLinkStream.asBroadcastStream(); final linkStream = appLinks.allStringLinkStream.asBroadcastStream();
@ -53,7 +53,7 @@ void useDeepLinking(WidgetRef ref) {
StreamSubscription? mediaStream; StreamSubscription? mediaStream;
if (DesktopTools.platform.isMobile) { if (kIsMobile) {
FlutterSharingIntent.instance.getInitialSharing().then(uriListener); FlutterSharingIntent.instance.getInitialSharing().then(uriListener);
mediaStream = mediaStream =

View File

@ -1,12 +1,12 @@
import 'package:disable_battery_optimization/disable_battery_optimization.dart'; import 'package:disable_battery_optimization/disable_battery_optimization.dart';
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
import 'package:spotube/hooks/utils/use_async_effect.dart'; import 'package:spotube/hooks/utils/use_async_effect.dart';
import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/services/kv_store/kv_store.dart';
import 'package:spotube/utils/platform.dart';
void useDisableBatteryOptimizations() { void useDisableBatteryOptimizations() {
useAsyncEffect(() async { useAsyncEffect(() async {
if (!DesktopTools.platform.isAndroid || if (!kIsAndroid || KVStoreService.askedForBatteryOptimization) return;
KVStoreService.askedForBatteryOptimization) return;
await DisableBatteryOptimization.showDisableBatteryOptimizationSettings(); await DisableBatteryOptimization.showDisableBatteryOptimizationSettings();

View File

@ -1,17 +1,18 @@
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter_desktop_tools/flutter_desktop_tools.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:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:spotube/components/library/user_local_tracks.dart';
import 'package:spotube/hooks/utils/use_async_effect.dart'; import 'package:spotube/hooks/utils/use_async_effect.dart';
import 'package:spotube/provider/local_tracks/local_tracks_provider.dart';
import 'package:spotube/utils/platform.dart';
void useGetStoragePermissions(WidgetRef ref) { void useGetStoragePermissions(WidgetRef ref) {
final isMounted = useIsMounted(); final context = useContext();
useAsyncEffect( useAsyncEffect(
() async { () async {
if (!DesktopTools.platform.isMobile) return; if (!kIsMobile) return;
final androidInfo = await DeviceInfoPlugin().androidInfo; final androidInfo = await DeviceInfoPlugin().androidInfo;
@ -25,11 +26,11 @@ void useGetStoragePermissions(WidgetRef ref) {
if (hasNoStoragePerm) { if (hasNoStoragePerm) {
await Permission.storage.request(); await Permission.storage.request();
if (isMounted()) ref.invalidate(localTracksProvider); if (context.mounted) ref.invalidate(localTracksProvider);
} }
if (hasNoAudioPerm) { if (hasNoAudioPerm) {
await Permission.audio.request(); await Permission.audio.request();
if (isMounted()) ref.invalidate(localTracksProvider); if (context.mounted) ref.invalidate(localTracksProvider);
} }
}, },
null, null,

View File

@ -1,128 +0,0 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/intents.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
void useInitSysTray(WidgetRef ref) {
final context = useContext();
final systemTray = useRef<SystemTray?>(null);
final initializeMenu = useCallback(() async {
systemTray.value?.destroy();
final playlist = ref.read(proxyPlaylistProvider);
final playlistQueue = ref.read(proxyPlaylistProvider.notifier);
final preferences = ref.read(userPreferencesProvider);
if (!preferences.showSystemTrayIcon) {
await systemTray.value?.destroy();
systemTray.value = null;
return;
}
final enabled = !playlist.isFetching;
systemTray.value = await DesktopTools.createSystemTrayMenu(
title: DesktopTools.platform.isWindows ? "Spotube" : "",
iconPath: "assets/spotube-logo.png",
windowsIconPath: "assets/spotube-logo.ico",
items: [
MenuItemLabel(
label: "Show/Hide",
name: "show-hide",
onClicked: (item) async {
if (await DesktopTools.window.isVisible()) {
await DesktopTools.window.hide();
} else {
await DesktopTools.window.show();
}
},
),
MenuSeparator(),
MenuItemLabel(
label: "Play/Pause",
name: "play-pause",
enabled: enabled,
onClicked: (_) async {
Actions.maybeInvoke<PlayPauseIntent>(
context, PlayPauseIntent(ref)) ??
PlayPauseAction().invoke(PlayPauseIntent(ref));
},
),
MenuItemLabel(
label: "Next",
name: "next",
enabled: enabled && (playlist.tracks.length) > 1,
onClicked: (p0) async {
await playlistQueue.next();
},
),
MenuItemLabel(
label: "Previous",
name: "previous",
enabled: enabled && (playlist.tracks.length) > 1,
onClicked: (p0) async {
await playlistQueue.previous();
},
),
MenuSeparator(),
MenuItemLabel(
label: "Quit",
name: "quit",
onClicked: (item) async {
exit(0);
},
),
],
onEvent: (event, tray) async {
if (DesktopTools.platform.isWindows) {
switch (event) {
case SystemTrayEvent.click:
await DesktopTools.window.show();
break;
case SystemTrayEvent.rightClick:
await tray.popUpContextMenu();
break;
default:
}
} else {
switch (event) {
case SystemTrayEvent.rightClick:
await DesktopTools.window.show();
break;
case SystemTrayEvent.click:
await tray.popUpContextMenu();
break;
default:
}
}
},
);
}, [ref]);
useReassemble(initializeMenu);
ref.listen<ProxyPlaylist?>(
proxyPlaylistProvider,
(previous, next) {
initializeMenu();
},
);
ref.listen(
userPreferencesProvider.select((s) => s.showSystemTrayIcon),
(previous, next) {
initializeMenu();
},
);
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
initializeMenu();
});
return () async {
await systemTray.value?.destroy();
};
}, [initializeMenu]);
}

View File

@ -1,100 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:spotube/collections/env.dart';
import 'package:spotube/components/shared/links/anchor_button.dart';
import 'package:spotube/hooks/controllers/use_package_info.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:version/version.dart';
void useUpdateChecker(WidgetRef ref) {
final isCheckUpdateEnabled =
ref.watch(userPreferencesProvider.select((s) => s.checkUpdate));
final packageInfo = usePackageInfo(
appName: 'Spotube',
packageName: 'spotube',
);
final Future<List<Version?>> Function() checkUpdate = useCallback(
() async {
final value = await http.get(
Uri.parse(
"https://api.github.com/repos/KRTirtho/spotube/releases/latest"),
);
final tagName =
(jsonDecode(value.body)["tag_name"] as String).replaceAll("v", "");
final currentVersion = packageInfo.version == "Unknown"
? null
: Version.parse(packageInfo.version);
final latestVersion =
tagName == "nightly" ? null : Version.parse(tagName);
return [currentVersion, latestVersion];
},
[packageInfo.version],
);
final context = useContext();
download(String url) => launchUrlString(
url,
mode: LaunchMode.externalApplication,
);
useEffect(() {
if (!Env.enableUpdateChecker) return;
if (!isCheckUpdateEnabled) return null;
checkUpdate().then((value) {
final currentVersion = value.first;
final latestVersion = value.last;
if (currentVersion == null ||
latestVersion == null ||
(latestVersion.isPreRelease && !currentVersion.isPreRelease) ||
(!latestVersion.isPreRelease && currentVersion.isPreRelease)) return;
if (latestVersion <= currentVersion) return;
showDialog(
context: context,
barrierDismissible: true,
barrierColor: Colors.black26,
builder: (context) {
const url =
"https://spotube.krtirtho.dev/other-downloads/stable-downloads";
return AlertDialog(
title: const Text("Spotube has an update"),
actions: [
FilledButton(
child: const Text("Download Now"),
onPressed: () => download(url),
),
],
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text("Spotube v${value.last} has been released"),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text("Read the latest "),
AnchorButton(
"release notes",
style: const TextStyle(color: Colors.blue),
onTap: () => launchUrlString(
url,
mode: LaunchMode.externalApplication,
),
),
],
),
],
),
);
},
);
});
return null;
}, [packageInfo, isCheckUpdateEnabled]);
}

Some files were not shown because too many files have changed in this diff Show More