chore: Release v3.7.0 (#1552)

* chore: fix analyzer issues

* fix(updater): dead link (#1408)

* docs: broken link in README.md (fixes #1310) (#1311)

* docs: remove appimage link in readme #1082 (#1171)

* Updating Readme according to #1082

Updating Readme according to #1082

* Added explanation

The explanation is now given and the expression is more formal and explanatory, instead of just linking the issue.

* Update use_update_checker.dart

---------

Co-authored-by: MerkomassDev <70111455+MerkomassDev@users.noreply.github.com>
Co-authored-by: Karim <37943746+ksaadDE@users.noreply.github.com>
Co-authored-by: Kingkor Roy Tirtho <krtirtho@gmail.com>

* fix(linux): tray icon not showing #541

upgrade old packages

* fix(search): load more button not working #1417

* fix: spotify friends and user profile icon (mobile) showing when not authenticated #1410

* chore: add docker and m1 based linux arm build

* cd: fix sed failing us

* cd: use docker cask

* fix: windows SSL Certificate error breaking login #905 (#1474)

* fix: certificate error by using custom ssl certificate

* Cd/docker linux ar (#1468)

* cd: use docker buildx

* cd: use linux host for linux arm instead of macos m1

m1 doesn't support nested virtualization. (Apple truly sucks)

* cd: don't specify arch in Dockerfile

* cd: use custom Dockerfile from ubuntu instead of flutter image

* cd: add setup java for android

* cd: add flutter distributor pre-built docker image for arm

* cd: save me from this cursed arm build

* cd: ??

* cd: ??

* cd: use docker build

* fix: windows SSL Exception for Signing in

* refactor: extract update checker as a basic function instead of a hook

* cd: fix windows build error due to nightly version format

* cd: fix github versioning scheme

* chore:  remove assets/ca entry in pubspec.yaml

* fix(macos): Logs directory not created by default #1353

* refactor: Dart based Github Workflow CLI (#1490)

* feat: add build dart script for windows

* feat: add android build support

* feat: add linux build support

* feat: add macos build support

* feat: add ios build support

* feat: add deps install command and workflow file

* cd: what?

* cd: what?

* cd: what?

* cd: update workflow inputs

* cd: replace release binary

* cd: run flutter pub get

* cd: use dpkg zstd instead of xz, windows disable innoInstall, fix channel enum.name and reset pubspec after changing build no for nightly

* cd: fix tar copy path

* cd: fix copy linux command

* cd: fix windows inno depend and fix android aab path

* cd: idk

* cd: linux why???

* cd: windows choco copy failed

* cd: use dart tar archive for creating tar file

* cd: fix linux file copy error

* cd: use tar command directly

* feat: add linux_arm platform

* cd: add linux_arm platform

* cd: don't know what?

* feat: notification about nightly channel update

* chore: fix some errors parsing nightly version info

* refactor: move dart scripts as commands under CLI

* chore: add translated message command to command list

* feat(translations): add Basque translation (#1493)

* added Basque translation

* chore: fix country codes and language native name

---------

Co-authored-by: Kingkor Roy Tirtho <krtirtho@gmail.com>

* feat(translations): add georgian language (#1450)

* feat: add georgian language

* feat: translate more georgian words

* feat(translations): add Finnish translations (#1449)

* docs: broken link in README.md (fixes #1310) (#1311)

* docs: remove appimage link in readme #1082 (#1171)

* Updating Readme according to #1082

Updating Readme according to #1082

* Added explanation

The explanation is now given and the expression is more formal and explanatory, instead of just linking the issue.

* added finnish translation

* chore: fix arb syntax errors and language in l10n entries

---------

Co-authored-by: MerkomassDev <70111455+MerkomassDev@users.noreply.github.com>
Co-authored-by: Karim <37943746+ksaadDE@users.noreply.github.com>
Co-authored-by: Kingkor Roy Tirtho <krtirtho@gmail.com>
Co-authored-by: Onni Nevala <nevalaonni@gmail.com>

* feat(translations): add Indonesian translation (#1426)

* docs: broken link in README.md (fixes #1310) (#1311)

* docs: remove appimage link in readme #1082 (#1171)

* Updating Readme according to #1082

Updating Readme according to #1082

* Added explanation

The explanation is now given and the expression is more formal and explanatory, instead of just linking the issue.

* Add Indonesia translation

---------

Co-authored-by: MerkomassDev <70111455+MerkomassDev@users.noreply.github.com>
Co-authored-by: Karim <37943746+ksaadDE@users.noreply.github.com>
Co-authored-by: Kingkor Roy Tirtho <krtirtho@gmail.com>

* feat(translations): Improve tr locales (#1419)

* docs: broken link in README.md (fixes #1310) (#1311)

* docs: remove appimage link in readme #1082 (#1171)

* Updating Readme according to #1082

Updating Readme according to #1082

* Added explanation

The explanation is now given and the expression is more formal and explanatory, instead of just linking the issue.

* Improve tr locales

---------

Co-authored-by: MerkomassDev <70111455+MerkomassDev@users.noreply.github.com>
Co-authored-by: Karim <37943746+ksaadDE@users.noreply.github.com>
Co-authored-by: Kingkor Roy Tirtho <krtirtho@gmail.com>

* feat(player): add volume slider floating label showing percentage (#1445)

* docs: broken link in README.md (fixes #1310) (#1311)

* docs: remove appimage link in readme #1082 (#1171)

* Updating Readme according to #1082

Updating Readme according to #1082

* Added explanation

The explanation is now given and the expression is more formal and explanatory, instead of just linking the issue.

* add volume level tooltip in volume_slider

---------

Co-authored-by: MerkomassDev <70111455+MerkomassDev@users.noreply.github.com>
Co-authored-by: Karim <37943746+ksaadDE@users.noreply.github.com>
Co-authored-by: Kingkor Roy Tirtho <krtirtho@gmail.com>

* fix: fallback to LRCLIB when lyrics line less than 6 lines #1461

* feat: Local music library (#1479)

* feat: add one additional library folder

This folder just doesn't get downloaded to.
I think I'm going to rework it so that it can be multiple folders,
but I'm going to commit my progress so far anyway.

Signed-off-by: Blake Leonard <me@blakes.dev>

* chore: update dependencies so that it builds

I'm not sure if this breaks CI or something, but I couldn't build
it locally to test my changes, so I made these changes and it
builds again.

Signed-off-by: Blake Leonard <me@blakes.dev>

* feat: index multiple folders of local music

If you used a previous commit from this branch, this is a breaking
change, because it changes the type of a configuration field. but
since this is still in development, it should be fine.

Signed-off-by: Blake Leonard <me@blakes.dev>

* refactor: manage local library in local tracks tab

This also refactors the list to use slivers instead. That's the
easiest way to have multiple scrolling lists here...

The console keeps getting spammed with some intermediate layout
error but I can't hold it long enough to figure out what's causing
it.

Signed-off-by: Blake Leonard <me@blakes.dev>

* refactor: use folder add/remove icons in library

Signed-off-by: Blake Leonard <me@blakes.dev>

* refactor: remove redundant settings page

Signed-off-by: Blake Leonard <me@blakes.dev>

* refactor: rename "Local Tracks" to just "Local"

Not sure if this would be the recommended way to do it...

Signed-off-by: Blake Leonard <me@blakes.dev>

* fix: console spam about useless Expanded

Signed-off-by: Blake Leonard <me@blakes.dev>

* chore: remove completed TODO

Signed-off-by: Blake Leonard <me@blakes.dev>

* chore: use new Platform constants; regenerate plugins

Signed-off-by: Blake Leonard <me@blakes.dev>

* refactor: put local libraries on separate pages

Signed-off-by: Blake Leonard <me@blakes.dev>

---------

Signed-off-by: Blake Leonard <me@blakes.dev>

* fix: local track not showing up in queue

* feat: local library folder cards

* feat: personalized stats based on local music history (#1522)

* feat: add playback history provider

* feat: implement recently played section

* refactor: use route names

* feat: add stats summary and top tracks/artists/albums

* feat: add top date based filtering

* feat: add stream money calculation

* refactor: place search in mobile navbar and settings in home appbar

* feat: add individual minutes and streams page

* feat(stats): add individual minutes and streams page

* chore: default period to 1 month

* feat: add text to explain user how hypothetical fees are calculated

* chore: ensure usage of route names instead of direct paths

* cd: add cache key

* cd: remove media_kit_event_loop from git

* fix: some text are garbled in different parts of the app #1463 #1505

* refactor: use replace http with dio and use it as the default

* cd: use dio in cli as well

* chore: fix home feed not showing up

* chore: downloaded tracks folder not opening

* feat: play initially available tracks of playlist/album immediately and fetch rest in background #670

* feat: upgrade to Flutter 3.22.0

* refactor: migrate deprecated warnings

* fix(playback): skipping tracks with unplayable sources instead of falling back #1492

* chore: migrate android gradle to declarative config syntax

* chore: disable impeller for now

* fix(windows): installer tries to install in current directory

* chore: upgrade deps and appbar bg fix

* chore: podspec update

* chore: bump version and generate changelogs

---------

Signed-off-by: Blake Leonard <me@blakes.dev>
Co-authored-by: Kshamendra <github@ghoulcloud.slmail.me>
Co-authored-by: MerkomassDev <70111455+MerkomassDev@users.noreply.github.com>
Co-authored-by: Karim <37943746+ksaadDE@users.noreply.github.com>
Co-authored-by: Josu Igoa <josuigoa@ni.eus>
Co-authored-by: Omari Sopromadze <omari.sopromadze@gmail.com>
Co-authored-by: ctih <78687256+ctih1@users.noreply.github.com>
Co-authored-by: Onni Nevala <nevalaonni@gmail.com>
Co-authored-by: Yusril Rapsanjani <yusriltakeuchi@gmail.com>
Co-authored-by: W͏ I͏ N͏ Z͏ O͏ R͏ T͏ <75412448+mikropsoft@users.noreply.github.com>
Co-authored-by: Akash Pattnaik <akashjio66666@gmail.com>
Co-authored-by: Blake Leonard <blake@1024256.xyz>
This commit is contained in:
Kingkor Roy Tirtho 2024-06-03 13:45:04 +06:00 committed by GitHub
parent cb95663412
commit 3aca7372af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
224 changed files with 8308 additions and 2937 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.22.1",
"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.22.1'
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.0
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.22.1
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,135 +112,10 @@ 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/download-artifact@v3 - uses: actions/download-artifact@v3
with: with:
@ -426,6 +131,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 +149,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 +167,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,39 @@
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.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

@ -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

@ -24,6 +24,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';
@ -34,6 +35,10 @@ class ArtistCard extends HookConsumerWidget {
final radius = BorderRadius.circular(15); final radius = BorderRadius.circular(15);
final bgColor = useBrightnessValue(
theme.colorScheme.surface,
theme.colorScheme.surfaceContainerHigh,
);
final double size = useBreakpointValue<double>( final double size = useBreakpointValue<double>(
xs: 130, xs: 130,
sm: 130, sm: 130,
@ -45,12 +50,8 @@ class ArtistCard extends HookConsumerWidget {
width: size, width: size,
margin: const EdgeInsets.symmetric(vertical: 5), margin: const EdgeInsets.symmetric(vertical: 5),
child: Material( child: Material(
shadowColor: theme.colorScheme.background, shadowColor: theme.colorScheme.surface,
color: Color.lerp( color: bgColor,
theme.colorScheme.surfaceVariant,
theme.colorScheme.surface,
useBrightnessValue(.9, .7),
),
elevation: 3, elevation: 3,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: radius, borderRadius: radius,
@ -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 {
@ -27,7 +30,7 @@ class FriendItem extends HookConsumerWidget {
return Container( return Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3), color: colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(15), borderRadius: BorderRadius.circular(15),
), ),
constraints: const BoxConstraints( constraints: const BoxConstraints(
@ -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(
@ -126,7 +134,7 @@ class HomeGenresSection extends HookConsumerWidget {
child: Ink( child: Ink(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
color: colorScheme.surfaceVariant, color: colorScheme.surfaceContainerHighest,
gradient: categoriesQuery.isLoading ? null : gradient, gradient: categoriesQuery.isLoading ? null : gradient,
), ),
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),

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.surfaceContainerHighest,
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

@ -71,7 +71,7 @@ class MultiSelectField<T> extends HookWidget {
: theme.colorScheme.onSurface.withOpacity(0.1), : theme.colorScheme.onSurface.withOpacity(0.1),
), ),
), ),
mouseCursor: MaterialStateMouseCursor.textable, mouseCursor: WidgetStateMouseCursor.textable,
onPressed: !enabled onPressed: !enabled
? null ? null
: () async { : () async {

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

@ -122,7 +122,8 @@ class PlayerQueue extends HookConsumerWidget {
top: 5.0, top: 5.0,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surfaceVariant.withOpacity(0.5), color:
theme.colorScheme.surfaceContainerHighest.withOpacity(0.5),
borderRadius: borderRadius, borderRadius: borderRadius,
), ),
child: CallbackShortcuts( child: CallbackShortcuts(

View File

@ -208,7 +208,8 @@ class SiblingTracksSheet extends HookConsumerWidget {
: mediaQuery.size.height * .6, : mediaQuery.size.height * .6,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: borderRadius, borderRadius: borderRadius,
color: theme.colorScheme.surfaceVariant.withOpacity(.5), color:
theme.colorScheme.surfaceContainerHighest.withOpacity(.5),
), ),
child: Scaffold( child: Scaffold(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,

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';
@ -15,7 +14,6 @@ import 'package:spotube/components/player/volume_slider.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/extensions/image.dart'; import 'package:spotube/extensions/image.dart';
import 'package:spotube/hooks/utils/use_brightness_value.dart';
import 'package:spotube/models/logger.dart'; import 'package:spotube/models/logger.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/authentication_provider.dart';
@ -24,6 +22,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});
@ -49,12 +48,6 @@ class BottomPlayer extends HookConsumerWidget {
); );
final theme = Theme.of(context); final theme = Theme.of(context);
final bg = theme.colorScheme.surfaceVariant;
final bgColor = useBrightnessValue(
Color.lerp(bg, Colors.white, 0.7),
Color.lerp(bg, Colors.black, 0.45)!,
);
// returning an empty non spacious Container as the overlay will take // returning an empty non spacious Container as the overlay will take
// place in the global overlay stack aka [_entries] // place in the global overlay stack aka [_entries]
@ -67,7 +60,9 @@ class BottomPlayer extends HookConsumerWidget {
child: BackdropFilter( child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15),
child: DecoratedBox( child: DecoratedBox(
decoration: BoxDecoration(color: bgColor?.withOpacity(0.8)), decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainer.withOpacity(.8),
),
child: Material( child: Material(
type: MaterialType.transparency, type: MaterialType.transparency,
textStyle: theme.textTheme.bodyMedium!, textStyle: theme.textTheme.bodyMedium!,
@ -95,19 +90,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

@ -14,8 +14,9 @@ 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/extensions/image.dart'; import 'package:spotube/extensions/image.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';
@ -26,13 +27,9 @@ import 'package:spotube/utils/platform.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.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 +44,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,41 +54,22 @@ class Sidebar extends HookConsumerWidget {
final layoutMode = final layoutMode =
ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); ref.watch(userPreferencesProvider.select((s) => s.layoutMode));
final controller = useSidebarXController(
selectedIndex: selectedIndex ?? 0,
extended: mediaQuery.lgAndUp,
);
final theme = Theme.of(context);
final bg = theme.colorScheme.surfaceVariant;
final bgColor = useBrightnessValue(
Color.lerp(bg, Colors.white, 0.7),
Color.lerp(bg, Colors.black, 0.45)!,
);
final sidebarTileList = useMemoized( final sidebarTileList = useMemoized(
() => getSidebarTileList(context.l10n), () => getSidebarTileList(context.l10n),
[context.l10n], [context.l10n],
); );
useEffect(() { final selectedIndex = sidebarTileList.indexWhere(
if (controller.selectedIndex != selectedIndex && selectedIndex != null) { (e) => routerState.namedLocation(e.name) == routerState.matchedLocation,
controller.selectIndex(selectedIndex!); );
}
return null;
}, [selectedIndex]);
useEffect(() { final controller = useSidebarXController(
void listener() { selectedIndex: selectedIndex,
onSelectedIndexChanged(controller.selectedIndex); extended: mediaQuery.lgAndUp,
} );
controller.addListener(listener); final theme = Theme.of(context);
return () { final bg = theme.colorScheme.surfaceContainer;
controller.removeListener(listener);
};
}, [controller]);
useEffect(() { useEffect(() {
if (!context.mounted) return; if (!context.mounted) return;
@ -106,6 +81,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 +101,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,
); );
}, },
@ -166,7 +153,7 @@ class Sidebar extends HookConsumerWidget {
), ),
padding: const EdgeInsets.symmetric(horizontal: 6), padding: const EdgeInsets.symmetric(horizontal: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: bgColor?.withOpacity(0.8), color: bg,
borderRadius: const BorderRadius.only( borderRadius: const BorderRadius.only(
topRight: Radius.circular(10), topRight: Radius.circular(10),
bottomRight: Radius.circular(10), bottomRight: Radius.circular(10),
@ -257,7 +244,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 +265,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 +297,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) ||
@ -69,7 +68,7 @@ class SpotubeNavigationBar extends HookConsumerWidget {
backgroundColor: backgroundColor:
theme.colorScheme.secondaryContainer.withOpacity(0.72), theme.colorScheme.secondaryContainer.withOpacity(0.72),
buttonBackgroundColor: buttonColor, buttonBackgroundColor: buttonColor,
color: theme.colorScheme.background, color: theme.colorScheme.surface,
height: panelHeight, height: panelHeight,
animationDuration: const Duration(milliseconds: 350), animationDuration: const Duration(milliseconds: 350),
items: navbarTileList.map( items: navbarTileList.map(
@ -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

@ -179,9 +179,9 @@ class ColorTile extends StatelessWidget {
colorScheme.primaryContainer, colorScheme.primaryContainer,
colorScheme.secondary, colorScheme.secondary,
colorScheme.secondaryContainer, colorScheme.secondaryContainer,
colorScheme.background,
colorScheme.surface, colorScheme.surface,
colorScheme.surfaceVariant, colorScheme.surface,
colorScheme.surfaceContainerHighest,
colorScheme.onPrimary, colorScheme.onPrimary,
colorScheme.onSurface, colorScheme.onSurface,
]; ];

View File

@ -187,7 +187,7 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
icon: icon ?? const Icon(SpotubeIcons.moreVertical), icon: icon ?? const Icon(SpotubeIcons.moreVertical),
tooltip: tooltip, tooltip: tooltip,
style: theme.iconButtonTheme.style?.copyWith( style: theme.iconButtonTheme.style?.copyWith(
shape: MaterialStatePropertyAll( shape: WidgetStatePropertyAll(
RoundedRectangleBorder( RoundedRectangleBorder(
borderRadius: borderRadius, borderRadius: borderRadius,
), ),

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

@ -29,7 +29,7 @@ class AnchorButton<T> extends HookWidget {
onTapUp: (event) => tap.value = false, onTapUp: (event) => tap.value = false,
onTap: onTap, onTap: onTap,
child: MouseRegion( child: MouseRegion(
cursor: MaterialStateMouseCursor.clickable, cursor: WidgetStateMouseCursor.clickable,
child: Text( child: Text(
text, text,
style: style.copyWith( style: style.copyWith(

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,
@ -149,11 +146,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,
@ -172,6 +165,10 @@ class _PageWindowTitleBarState extends ConsumerState<PageWindowTitleBar> {
toolbarTextStyle: widget.toolbarTextStyle, toolbarTextStyle: widget.toolbarTextStyle,
titleTextStyle: widget.titleTextStyle, titleTextStyle: widget.titleTextStyle,
title: widget.title, title: widget.title,
scrolledUnderElevation: 0,
shadowColor: Colors.transparent,
forceMaterialTransparency: true,
elevation: 0,
), ),
), ),
); );
@ -193,12 +190,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;
}); });
} }
@ -213,16 +210,16 @@ class WindowTitleBarButtons extends HookConsumerWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
final colors = WindowButtonColors( final colors = WindowButtonColors(
normal: Colors.transparent, normal: Colors.transparent,
iconNormal: foregroundColor ?? theme.colorScheme.onBackground, iconNormal: foregroundColor ?? theme.colorScheme.onSurface,
mouseOver: theme.colorScheme.onBackground.withOpacity(0.1), mouseOver: theme.colorScheme.onSurface.withOpacity(0.1),
mouseDown: theme.colorScheme.onBackground.withOpacity(0.2), mouseDown: theme.colorScheme.onSurface.withOpacity(0.2),
iconMouseOver: theme.colorScheme.onBackground, iconMouseOver: theme.colorScheme.onSurface,
iconMouseDown: theme.colorScheme.onBackground, iconMouseDown: theme.colorScheme.onSurface,
); );
final closeColors = WindowButtonColors( final closeColors = WindowButtonColors(
normal: Colors.transparent, normal: Colors.transparent,
iconNormal: foregroundColor ?? theme.colorScheme.onBackground, iconNormal: foregroundColor ?? theme.colorScheme.onSurface,
mouseOver: Colors.red, mouseOver: Colors.red,
mouseDown: Colors.red[800]!, mouseDown: Colors.red[800]!,
iconMouseOver: Colors.white, iconMouseOver: Colors.white,
@ -235,14 +232,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 +247,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 +267,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

@ -53,6 +53,10 @@ class PlaybuttonCard extends HookWidget {
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
final radius = BorderRadius.circular(15); final radius = BorderRadius.circular(15);
final bgColor = useBrightnessValue(
theme.colorScheme.surface,
theme.colorScheme.surfaceContainerHigh,
);
final double size = useBreakpointValue<double>( final double size = useBreakpointValue<double>(
xs: 130, xs: 130,
sm: 130, sm: 130,
@ -72,13 +76,9 @@ class PlaybuttonCard extends HookWidget {
constraints: BoxConstraints(maxWidth: size), constraints: BoxConstraints(maxWidth: size),
margin: margin, margin: margin,
child: Material( child: Material(
color: Color.lerp( color: bgColor,
theme.colorScheme.surfaceVariant,
theme.colorScheme.surface,
useBrightnessValue(.9, .7),
),
borderRadius: radius, borderRadius: radius,
shadowColor: theme.colorScheme.background, shadowColor: theme.colorScheme.surface,
elevation: 3, elevation: 3,
child: InkWell( child: InkWell(
mouseCursor: SystemMouseCursors.click, mouseCursor: SystemMouseCursors.click,
@ -158,7 +158,7 @@ class PlaybuttonCard extends HookWidget {
Skeleton.keep( Skeleton.keep(
child: IconButton( child: IconButton(
style: IconButton.styleFrom( style: IconButton.styleFrom(
backgroundColor: theme.colorScheme.background, backgroundColor: theme.colorScheme.surface,
foregroundColor: theme.colorScheme.primary, foregroundColor: theme.colorScheme.primary,
minimumSize: const Size.square(10), minimumSize: const Size.square(10),
), ),

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,
@ -32,7 +34,7 @@ class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget {
), ),
borderWidth: 0, borderWidth: 0,
unselectedDecoration: BoxDecoration( unselectedDecoration: BoxDecoration(
color: theme.colorScheme.background, color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(15), borderRadius: BorderRadius.circular(15),
), ),
unselectedLabelStyle: theme.textTheme.labelLarge?.copyWith( unselectedLabelStyle: theme.textTheme.labelLarge?.copyWith(

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 {

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