mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 07:55:18 +00:00
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:
parent
cb95663412
commit
3aca7372af
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@ -0,0 +1,6 @@
|
||||
build
|
||||
dist
|
||||
.dart_tool
|
||||
.idea
|
||||
.github
|
||||
.git
|
@ -9,3 +9,6 @@ ENABLE_UPDATE_CHECK=
|
||||
|
||||
LASTFM_API_KEY=
|
||||
LASTFM_API_SECRET=
|
||||
|
||||
# Release channel. Can be: nightly, stable
|
||||
RELEASE_CHANNEL=
|
||||
|
@ -1,4 +1,4 @@
|
||||
{
|
||||
"flutterSdkVersion": "3.19.1",
|
||||
"flutterSdkVersion": "3.22.1",
|
||||
"flavors": {}
|
||||
}
|
23
.github/Dockerfile
vendored
Normal file
23
.github/Dockerfile
vendored
Normal 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
23
.github/Dockerfile.flutter_distributor
vendored
Normal 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
|
2
.github/workflows/pr-lint.yml
vendored
2
.github/workflows/pr-lint.yml
vendored
@ -4,7 +4,7 @@ on:
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
FLUTTER_VERSION: '3.19.5'
|
||||
FLUTTER_VERSION: '3.22.1'
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
|
2
.github/workflows/spotube-publish-binary.yml
vendored
2
.github/workflows/spotube-publish-binary.yml
vendored
@ -4,7 +4,7 @@ on:
|
||||
inputs:
|
||||
version:
|
||||
description: Version to publish (x.x.x)
|
||||
default: 3.1.0
|
||||
default: 3.7.0
|
||||
required: true
|
||||
dry_run:
|
||||
description: Dry run
|
||||
|
442
.github/workflows/spotube-release-binary.yml
vendored
442
.github/workflows/spotube-release-binary.yml
vendored
@ -2,399 +2,109 @@ name: Spotube Release Binary
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Version to release (x.x.x)
|
||||
default: 3.6.0
|
||||
required: true
|
||||
channel:
|
||||
type: choice
|
||||
description: Release Channel
|
||||
required: true
|
||||
options:
|
||||
- stable
|
||||
- nightly
|
||||
default: nightly
|
||||
description: The release channel
|
||||
debug:
|
||||
description: Debug on failed when channel is nightly
|
||||
required: true
|
||||
type: boolean
|
||||
default: false
|
||||
description: Debug with SSH toggle
|
||||
required: false
|
||||
dry_run:
|
||||
description: Dry run
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
default: false
|
||||
description: Dry run without uploading to release
|
||||
|
||||
env:
|
||||
FLUTTER_VERSION: '3.19.1'
|
||||
FLUTTER_VERSION: 3.22.1
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
windows:
|
||||
runs-on: windows-latest
|
||||
build_platform:
|
||||
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:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2.12.0
|
||||
with:
|
||||
cache: true
|
||||
cache-key: ${{ runner.os }}-flutter-${{ hashFiles('**/pubspec.yaml') }}
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
|
||||
- name: Replace pubspec version and BUILD_VERSION Env (nightly)
|
||||
if: ${{ inputs.channel == 'nightly' }}
|
||||
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
|
||||
- name: Setup Java
|
||||
if: ${{matrix.platform == 'android'}}
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
if-no-files-found: error
|
||||
name: Spotube-Release-Binaries
|
||||
path: |
|
||||
dist/Spotube-windows-x86_64.nupkg
|
||||
dist/Spotube-windows-x86_64-setup.exe
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
cache: 'gradle'
|
||||
check-latest: true
|
||||
- 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
|
||||
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
|
||||
- name: Install ${{matrix.platform}} dependencies
|
||||
run: |
|
||||
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
|
||||
if: ${{matrix.platform == 'android'}}
|
||||
run: |
|
||||
echo '${{ secrets.KEYSTORE }}' | base64 --decode > android/app/upload-keystore.jks
|
||||
echo '${{ secrets.KEY_PROPERTIES }}' > android/key.properties
|
||||
|
||||
- name: Build Apk
|
||||
run: |
|
||||
flutter build apk --flavor ${{ inputs.channel }}
|
||||
mv build/app/outputs/flutter-apk/app-${{ inputs.channel }}-release.apk build/Spotube-android-all-arch.apk
|
||||
|
||||
- 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
|
||||
|
||||
- name: Build ${{matrix.platform}} binaries
|
||||
run: dart cli/cli.dart build ${{matrix.platform}}
|
||||
env:
|
||||
CHANNEL: ${{inputs.channel}}
|
||||
DOTENV: ${{secrets.DOTENV_RELEASE}}
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
if-no-files-found: error
|
||||
name: Spotube-Release-Binaries
|
||||
path: |
|
||||
build/Spotube-android-all-arch.apk
|
||||
build/Spotube-playstore-all-arch.aab
|
||||
|
||||
- name: Debug With SSH When fails
|
||||
if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }}
|
||||
uses: mxschmitt/action-tmate@v3
|
||||
with:
|
||||
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
|
||||
path: ${{matrix.files}}
|
||||
|
||||
- name: Debug With SSH When fails
|
||||
if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }}
|
||||
@ -404,13 +114,8 @@ jobs:
|
||||
|
||||
upload:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
needs:
|
||||
- windows
|
||||
- linux
|
||||
- android
|
||||
- macos
|
||||
- iOS
|
||||
- build_platform
|
||||
steps:
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
@ -427,6 +132,10 @@ jobs:
|
||||
sha256sum Spotube-Release-Binaries/* >> RELEASE.sha256sum
|
||||
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
|
||||
with:
|
||||
if-no-files-found: error
|
||||
@ -440,7 +149,7 @@ jobs:
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag: v${{ inputs.version }} # mind the "v" prefix
|
||||
tag: v${{ env.PUBSPEC_VERSION }} # mind the "v" prefix
|
||||
omitBodyDuringUpdate: true
|
||||
omitNameDuringUpdate: true
|
||||
omitPrereleaseDuringUpdate: true
|
||||
@ -458,3 +167,8 @@ jobs:
|
||||
omitPrereleaseDuringUpdate: true
|
||||
allowUpdates: true
|
||||
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
|
||||
|
16
.metadata
16
.metadata
@ -1,11 +1,11 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# 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:
|
||||
revision: eb6d86ee27deecba4a83536aa20f366a6044895c
|
||||
channel: stable
|
||||
revision: "300451adae589accbece3490f4396f10bdf15e6e"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
@ -13,11 +13,11 @@ project_type: app
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: eb6d86ee27deecba4a83536aa20f366a6044895c
|
||||
base_revision: eb6d86ee27deecba4a83536aa20f366a6044895c
|
||||
- platform: macos
|
||||
create_revision: eb6d86ee27deecba4a83536aa20f366a6044895c
|
||||
base_revision: eb6d86ee27deecba4a83536aa20f366a6044895c
|
||||
create_revision: 300451adae589accbece3490f4396f10bdf15e6e
|
||||
base_revision: 300451adae589accbece3490f4396f10bdf15e6e
|
||||
- platform: windows
|
||||
create_revision: 300451adae589accbece3490f4396f10bdf15e6e
|
||||
base_revision: 300451adae589accbece3490f4396f10bdf15e6e
|
||||
|
||||
# User provided section
|
||||
|
||||
|
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -24,5 +24,6 @@
|
||||
"explorer.fileNesting.patterns": {
|
||||
"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",
|
||||
"*.dart": "${capture}.g.dart,${capture}.freezed.dart",
|
||||
}
|
||||
}
|
34
CHANGELOG.md
34
CHANGELOG.md
@ -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.
|
||||
|
||||
## [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
|
||||
|
@ -1,3 +1,9 @@
|
||||
plugins {
|
||||
id "com.android.application"
|
||||
id "kotlin-android"
|
||||
id "dev.flutter.flutter-gradle-plugin"
|
||||
}
|
||||
|
||||
def localProperties = new Properties()
|
||||
def localPropertiesFile = rootProject.file('local.properties')
|
||||
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')
|
||||
if (flutterVersionCode == null) {
|
||||
flutterVersionCode = '1'
|
||||
@ -21,10 +22,6 @@ if (flutterVersionName == null) {
|
||||
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 keystorePropertiesFile = rootProject.file('key.properties')
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
@ -71,6 +68,9 @@ android {
|
||||
release {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
debug {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions "default"
|
||||
@ -81,16 +81,19 @@ android {
|
||||
resValue "string", "app_name_en", "Spotube Nightly"
|
||||
applicationIdSuffix ".nightly"
|
||||
versionNameSuffix "-nightly"
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
dev {
|
||||
dimension "default"
|
||||
resValue "string", "app_name_en", "Spotube Dev"
|
||||
applicationIdSuffix ".dev"
|
||||
versionNameSuffix "-dev"
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
stable {
|
||||
dimension "default"
|
||||
resValue "string", "app_name_en", "Spotube"
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
|
||||
@ -101,15 +104,6 @@ flutter {
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
// other deps so just ignore
|
||||
|
@ -24,6 +24,11 @@
|
||||
android:usesCleartextTraffic="true"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
>
|
||||
<!-- Enable Impeller -->
|
||||
<!-- <meta-data
|
||||
android:name="io.flutter.embedding.android.EnableImpeller"
|
||||
android:value="true" /> -->
|
||||
|
||||
<activity
|
||||
android:name="com.ryanheise.audioservice.AudioServiceActivity"
|
||||
android:exported="true"
|
||||
|
@ -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 {
|
||||
repositories {
|
||||
google()
|
||||
|
@ -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")
|
||||
def properties = new Properties()
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
assert localPropertiesFile.exists()
|
||||
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
def flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
|
||||
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version "7.2.1" apply false
|
||||
id "org.jetbrains.kotlin.android" version "1.8.22" apply false
|
||||
}
|
||||
|
||||
include ":app"
|
@ -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);
|
||||
}
|
@ -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');
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
@ -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");
|
||||
}
|
||||
});
|
||||
}
|
@ -3,3 +3,8 @@ targets:
|
||||
sources:
|
||||
exclude:
|
||||
- bin/*.dart
|
||||
builders:
|
||||
json_serializable:
|
||||
options:
|
||||
any_map: true
|
||||
explicit_to_json: true
|
||||
|
4
cli/README.md
Normal file
4
cli/README.md
Normal 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
22
cli/cli.dart
Normal 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
25
cli/commands/build.dart
Normal 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());
|
||||
}
|
||||
}
|
90
cli/commands/build/android.dart
Normal file
90
cli/commands/build/android.dart
Normal 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");
|
||||
}
|
||||
}
|
66
cli/commands/build/common.dart
Normal file
66
cli/commands/build/common.dart
Normal 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
|
||||
""",
|
||||
);
|
||||
}
|
||||
}
|
29
cli/commands/build/ios.dart
Normal file
29
cli/commands/build/ios.dart
Normal 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")}
|
||||
""",
|
||||
);
|
||||
}
|
||||
}
|
106
cli/commands/build/linux.dart
Normal file
106
cli/commands/build/linux.dart
Normal 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");
|
||||
}
|
||||
}
|
37
cli/commands/build/linux_arm.dart
Normal file
37
cli/commands/build/linux_arm.dart
Normal 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/
|
||||
""",
|
||||
);
|
||||
}
|
||||
}
|
42
cli/commands/build/macos.dart
Normal file
42
cli/commands/build/macos.dart
Normal 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();
|
||||
}
|
||||
}
|
100
cli/commands/build/windows.dart
Normal file
100
cli/commands/build/windows.dart
Normal 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
121
cli/commands/credits.dart
Normal 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'),
|
||||
);
|
||||
}
|
||||
}
|
74
cli/commands/install-dependencies.dart
Normal file
74
cli/commands/install-dependencies.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
39
cli/commands/translated.dart
Normal file
39
cli/commands/translated.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
48
cli/commands/untranslated.dart
Normal file
48
cli/commands/untranslated.dart
Normal 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
24
cli/core/env.dart
Normal 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
1
devtools_options.yaml
Normal file
@ -0,0 +1 @@
|
||||
extensions:
|
@ -69,9 +69,6 @@ PODS:
|
||||
- fluttertoast (0.0.2):
|
||||
- Flutter
|
||||
- Toast
|
||||
- FMDB (2.7.5):
|
||||
- FMDB/standard (= 2.7.5)
|
||||
- FMDB/standard (2.7.5)
|
||||
- image_picker_ios (0.0.1):
|
||||
- Flutter
|
||||
- integration_test (0.0.1):
|
||||
@ -87,7 +84,7 @@ PODS:
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- permission_handler_apple (9.1.1):
|
||||
- permission_handler_apple (9.3.0):
|
||||
- Flutter
|
||||
- SDWebImage (5.18.8):
|
||||
- SDWebImage/Core (= 5.18.8)
|
||||
@ -97,7 +94,7 @@ PODS:
|
||||
- FlutterMacOS
|
||||
- sqflite (0.0.3):
|
||||
- Flutter
|
||||
- FMDB (>= 2.7.5)
|
||||
- FlutterMacOS
|
||||
- SwiftyGif (5.4.4)
|
||||
- Toast (4.0.0)
|
||||
- url_launcher_ios (0.0.1):
|
||||
@ -129,14 +126,13 @@ DEPENDENCIES:
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
- 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`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- DKImagePickerController
|
||||
- DKPhotoGallery
|
||||
- FMDB
|
||||
- OrderedSet
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
@ -194,45 +190,44 @@ EXTERNAL SOURCES:
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
sqflite:
|
||||
:path: ".symlinks/plugins/sqflite/ios"
|
||||
:path: ".symlinks/plugins/sqflite/darwin"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
app_links: 5ef33d0d295a89d9d16bb81b0e3b0d5f70d6c875
|
||||
app_links: e70ca16b4b0f88253b3b3660200d4a10b4ea9795
|
||||
audio_service: f509d65da41b9521a61f1c404dd58651f265a567
|
||||
audio_session: 4f3e461722055d21515cf3261b64c973c062f345
|
||||
audio_session: 088d2483ebd1dc43f51d253d4a1c517d9a2e7207
|
||||
bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842
|
||||
device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6
|
||||
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
|
||||
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
|
||||
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
|
||||
file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de
|
||||
file_selector_ios: 8c25d700d625e1dcdd6599f2d927072f2254647b
|
||||
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
|
||||
file_selector_ios: 78baf21d03f1e37a7df97bb2494f9cd86de8fa5d
|
||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||
flutter_broadcasts: 3ece15b27d8ccbe2132c3df303e7c3401feab882
|
||||
flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0
|
||||
flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069
|
||||
flutter_mailer: 2ef5a67087bc8c6c4cefd04a178bf1ae2c94cd83
|
||||
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
|
||||
flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
|
||||
flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be
|
||||
flutter_sharing_intent: e35380d0e1501d7111dbb7e46d5ac6339da6da98
|
||||
fluttertoast: 31b00dabfa7fb7bacd9e7dbee580d7a2ff4bf265
|
||||
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
||||
image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5
|
||||
integration_test: 13825b8a9334a850581300559b8839134b124670
|
||||
fluttertoast: 9f2f8e81bb5ce18facb9748d7855bf5a756fe3db
|
||||
image_picker_ios: b545a5f16c0fa88e3ecbbce3ed4de45567a8ec18
|
||||
integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4
|
||||
media_kit_libs_ios_audio: 8f39d96a9c630685dfb844c289bd1d114c486fb3
|
||||
media_kit_native_event_loop: 99111eded5acbdc9c2738021ea6550dd36ca8837
|
||||
metadata_god: eceae399d0020475069a5cebc35943ce8562b5d7
|
||||
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
|
||||
package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7
|
||||
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
|
||||
permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6
|
||||
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
|
||||
path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c
|
||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||
SDWebImage: a81bbb3ba4ea5f810f4069c68727cb118467a04a
|
||||
shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126
|
||||
sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a
|
||||
shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695
|
||||
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
|
||||
SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f
|
||||
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
|
||||
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
|
||||
url_launcher_ios: 6116280ddcfe98ab8820085d8d76ae7449447586
|
||||
|
||||
PODFILE CHECKSUM: 0659b64ac6e9e96b61d8550decffa8bff51a957e
|
||||
|
||||
|
@ -324,6 +324,7 @@
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
6E9FEF583EA597C8B76255B2 /* [CP] Embed Pods Frameworks */,
|
||||
46F6EB27C31C41D86428A28B /* [CP] Copy Pods Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@ -346,6 +347,7 @@
|
||||
B536BD992B405DB1009B3CE4 /* Embed Frameworks */,
|
||||
B536BD9A2B405DB1009B3CE4 /* Thin Binary */,
|
||||
A6D446F111DE4C4A202BE7F7 /* [CP] Embed Pods Frameworks */,
|
||||
2DEF3CF18D30E819C0FF4BCE /* [CP] Copy Pods Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@ -368,6 +370,7 @@
|
||||
B536BDB62B405FDE009B3CE4 /* Embed Frameworks */,
|
||||
B536BDB72B405FDE009B3CE4 /* Thin Binary */,
|
||||
244D41CE80E4BC0FFD63F8C6 /* [CP] Embed Pods Frameworks */,
|
||||
4DD66E9E53D92195290872BE /* [CP] Copy Pods Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@ -390,6 +393,7 @@
|
||||
B536BDD82B4060B3009B3CE4 /* Embed Frameworks */,
|
||||
B536BDD92B4060B3009B3CE4 /* Thin Binary */,
|
||||
D566C841A84D807A607F6DE5 /* [CP] Embed Pods Frameworks */,
|
||||
5C9D945D6569D9C3AC420285 /* [CP] Copy Pods Resources */,
|
||||
);
|
||||
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";
|
||||
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 */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
@ -539,6 +560,57 @@
|
||||
shellPath = /bin/sh;
|
||||
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 */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
|
@ -1,8 +1,13 @@
|
||||
import 'package:envied/envied.dart';
|
||||
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
|
||||
part 'env.g.dart';
|
||||
|
||||
enum ReleaseChannel {
|
||||
nightly,
|
||||
stable,
|
||||
}
|
||||
|
||||
@Envied(obfuscate: true, requireEnvFile: true, path: ".env")
|
||||
abstract class Env {
|
||||
@EnviedField(varName: 'SPOTIFY_SECRETS')
|
||||
@ -25,8 +30,15 @@ abstract class Env {
|
||||
@EnviedField(varName: 'ENABLE_UPDATE_CHECK', defaultValue: "1")
|
||||
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 =>
|
||||
DesktopTools.platform.isFlatpak || _enableUpdateChecker == "1";
|
||||
kIsFlatpak || _enableUpdateChecker == "1";
|
||||
|
||||
static String discordAppId = "1176718791388975124";
|
||||
}
|
@ -1,5 +1,4 @@
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/extensions/track.dart';
|
||||
import 'package:spotube/models/spotify/home_feed.dart';
|
||||
import 'package:spotube/models/spotify_friends.dart';
|
||||
|
||||
|
8
lib/collections/formatters.dart
Normal file
8
lib/collections/formatters.dart
Normal 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,
|
||||
);
|
@ -1,9 +1,10 @@
|
||||
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';
|
||||
|
||||
Future<void> registerWindowsScheme(String scheme) async {
|
||||
if (!DesktopTools.platform.isWindows) return;
|
||||
if (!kIsWindows) return;
|
||||
String appPath = Platform.resolvedExecutable;
|
||||
|
||||
String protocolRegKey = 'Software\\Classes\\$scheme';
|
||||
|
@ -7,6 +7,10 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:spotube/collections/routes.dart';
|
||||
import 'package:spotube/components/player/player_controls.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/services/audio_player/audio_player.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
@ -67,16 +71,16 @@ class HomeTabAction extends Action<HomeTabIntent> {
|
||||
final router = intent.ref.read(routerProvider);
|
||||
switch (intent.tab) {
|
||||
case HomeTabs.browse:
|
||||
router.go("/");
|
||||
router.goNamed(HomePage.name);
|
||||
break;
|
||||
case HomeTabs.search:
|
||||
router.go("/search");
|
||||
router.goNamed(SearchPage.name);
|
||||
break;
|
||||
case HomeTabs.library:
|
||||
router.go("/library");
|
||||
router.goNamed(LibraryPage.name);
|
||||
break;
|
||||
case HomeTabs.lyrics:
|
||||
router.go("/lyrics");
|
||||
router.goNamed(LyricsPage.name);
|
||||
break;
|
||||
}
|
||||
return null;
|
||||
|
@ -81,10 +81,10 @@ abstract class LanguageLocals {
|
||||
// name: "Bashkir",
|
||||
// nativeName: "башҡорт теле",
|
||||
// ),
|
||||
// "eu": const ISOLanguageName(
|
||||
// name: "Basque",
|
||||
// nativeName: "euskara,",
|
||||
// ),
|
||||
"eu": const ISOLanguageName(
|
||||
name: "Basque",
|
||||
nativeName: "euskara",
|
||||
),
|
||||
// "be": const ISOLanguageName(
|
||||
// name: "Belarusian",
|
||||
// nativeName: "Беларуская",
|
||||
@ -197,10 +197,10 @@ abstract class LanguageLocals {
|
||||
// name: "Fijian",
|
||||
// nativeName: "vosa Vakaviti",
|
||||
// ),
|
||||
// "fi": const ISOLanguageName(
|
||||
// name: "Finnish",
|
||||
// nativeName: "suomi",
|
||||
// ),
|
||||
"fi": const ISOLanguageName(
|
||||
name: "Finnish",
|
||||
nativeName: "suomi",
|
||||
),
|
||||
"fr": const ISOLanguageName(
|
||||
name: "French",
|
||||
nativeName: "français",
|
||||
@ -213,10 +213,10 @@ abstract class LanguageLocals {
|
||||
// name: "Galician",
|
||||
// nativeName: "Galego",
|
||||
// ),
|
||||
// "ka": const ISOLanguageName(
|
||||
// name: "Georgian",
|
||||
// nativeName: "ქართული",
|
||||
// ),
|
||||
"ka": const ISOLanguageName(
|
||||
name: "Georgian",
|
||||
nativeName: "ქართული",
|
||||
),
|
||||
"de": const ISOLanguageName(
|
||||
name: "German",
|
||||
nativeName: "Deutsch",
|
||||
@ -265,10 +265,10 @@ abstract class LanguageLocals {
|
||||
// name: "Interlingua",
|
||||
// nativeName: "Interlingua",
|
||||
// ),
|
||||
// "id": const ISOLanguageName(
|
||||
// name: "Indonesian",
|
||||
// nativeName: "Bahasa Indonesia",
|
||||
// ),
|
||||
"id": const ISOLanguageName(
|
||||
name: "Indonesian",
|
||||
nativeName: "Bahasa Indonesia",
|
||||
),
|
||||
// "ie": const ISOLanguageName(
|
||||
// name: "Interlingue",
|
||||
// nativeName: "Occidental",
|
||||
|
@ -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/home.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_result.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/about.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/provider/authentication_provider.dart';
|
||||
import 'package:spotube/services/kv_store/kv_store.dart';
|
||||
@ -50,6 +58,7 @@ final routerProvider = Provider((ref) {
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: "/",
|
||||
name: HomePage.name,
|
||||
redirect: (context, state) async {
|
||||
final authNotifier = ref.read(authenticationProvider.notifier);
|
||||
final json = await authNotifier.box.get(authNotifier.cacheKey);
|
||||
@ -66,11 +75,13 @@ final routerProvider = Provider((ref) {
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: "genres",
|
||||
name: GenrePage.name,
|
||||
pageBuilder: (context, state) =>
|
||||
const SpotubePage(child: GenrePage()),
|
||||
),
|
||||
GoRoute(
|
||||
path: "genre/:categoryId",
|
||||
name: GenrePlaylistsPage.name,
|
||||
pageBuilder: (context, state) => SpotubePage(
|
||||
child: GenrePlaylistsPage(
|
||||
category: state.extra as Category,
|
||||
@ -79,6 +90,7 @@ final routerProvider = Provider((ref) {
|
||||
),
|
||||
GoRoute(
|
||||
path: "feeds/:feedId",
|
||||
name: HomeFeedSectionPage.name,
|
||||
pageBuilder: (context, state) => SpotubePage(
|
||||
child: HomeFeedSectionPage(
|
||||
sectionUri: state.pathParameters["feedId"] as String,
|
||||
@ -89,45 +101,62 @@ final routerProvider = Provider((ref) {
|
||||
),
|
||||
GoRoute(
|
||||
path: "/search",
|
||||
name: "Search",
|
||||
name: SearchPage.name,
|
||||
pageBuilder: (context, state) =>
|
||||
const SpotubePage(child: SearchPage()),
|
||||
),
|
||||
GoRoute(
|
||||
path: "/library",
|
||||
name: "Library",
|
||||
name: LibraryPage.name,
|
||||
pageBuilder: (context, state) =>
|
||||
const SpotubePage(child: LibraryPage()),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: "generate",
|
||||
pageBuilder: (context, state) =>
|
||||
const SpotubePage(child: PlaylistGeneratorPage()),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: "result",
|
||||
pageBuilder: (context, state) => SpotubePage(
|
||||
child: PlaylistGenerateResultPage(
|
||||
state: state.extra as GeneratePlaylistProviderInput,
|
||||
),
|
||||
path: "generate",
|
||||
name: PlaylistGeneratorPage.name,
|
||||
pageBuilder: (context, state) =>
|
||||
const SpotubePage(child: PlaylistGeneratorPage()),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: "result",
|
||||
name: PlaylistGenerateResultPage.name,
|
||||
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(
|
||||
path: "/lyrics",
|
||||
name: "Lyrics",
|
||||
name: LyricsPage.name,
|
||||
pageBuilder: (context, state) =>
|
||||
const SpotubePage(child: LyricsPage()),
|
||||
),
|
||||
GoRoute(
|
||||
path: "/settings",
|
||||
name: SettingsPage.name,
|
||||
pageBuilder: (context, state) => const SpotubePage(
|
||||
child: SettingsPage(),
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: "blacklist",
|
||||
name: BlackListPage.name,
|
||||
pageBuilder: (context, state) => SpotubeSlidePage(
|
||||
child: const BlackListPage(),
|
||||
),
|
||||
@ -135,12 +164,14 @@ final routerProvider = Provider((ref) {
|
||||
if (!kIsWeb)
|
||||
GoRoute(
|
||||
path: "logs",
|
||||
name: LogsPage.name,
|
||||
pageBuilder: (context, state) => SpotubeSlidePage(
|
||||
child: const LogsPage(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: "about",
|
||||
name: AboutSpotube.name,
|
||||
pageBuilder: (context, state) => SpotubeSlidePage(
|
||||
child: const AboutSpotube(),
|
||||
),
|
||||
@ -149,6 +180,7 @@ final routerProvider = Provider((ref) {
|
||||
),
|
||||
GoRoute(
|
||||
path: "/album/:id",
|
||||
name: AlbumPage.name,
|
||||
pageBuilder: (context, state) {
|
||||
assert(state.extra is AlbumSimple);
|
||||
return SpotubePage(
|
||||
@ -158,6 +190,7 @@ final routerProvider = Provider((ref) {
|
||||
),
|
||||
GoRoute(
|
||||
path: "/artist/:id",
|
||||
name: ArtistPage.name,
|
||||
pageBuilder: (context, state) {
|
||||
assert(state.pathParameters["id"] != null);
|
||||
return SpotubePage(
|
||||
@ -166,6 +199,7 @@ final routerProvider = Provider((ref) {
|
||||
),
|
||||
GoRoute(
|
||||
path: "/playlist/:id",
|
||||
name: PlaylistPage.name,
|
||||
pageBuilder: (context, state) {
|
||||
assert(state.extra is PlaylistSimple);
|
||||
return SpotubePage(
|
||||
@ -177,6 +211,7 @@ final routerProvider = Provider((ref) {
|
||||
),
|
||||
GoRoute(
|
||||
path: "/track/:id",
|
||||
name: TrackPage.name,
|
||||
pageBuilder: (context, state) {
|
||||
final id = state.pathParameters["id"]!;
|
||||
return SpotubePage(
|
||||
@ -186,12 +221,14 @@ final routerProvider = Provider((ref) {
|
||||
),
|
||||
GoRoute(
|
||||
path: "/connect",
|
||||
name: ConnectPage.name,
|
||||
pageBuilder: (context, state) => const SpotubePage(
|
||||
child: ConnectPage(),
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: "control",
|
||||
name: ConnectControlPage.name,
|
||||
pageBuilder: (context, state) {
|
||||
return const SpotubePage(
|
||||
child: ConnectControlPage(),
|
||||
@ -202,13 +239,66 @@ final routerProvider = Provider((ref) {
|
||||
),
|
||||
GoRoute(
|
||||
path: "/profile",
|
||||
name: ProfilePage.name,
|
||||
pageBuilder: (context, state) =>
|
||||
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(
|
||||
path: "/mini-player",
|
||||
name: MiniLyricsPage.name,
|
||||
parentNavigatorKey: rootNavigatorKey,
|
||||
pageBuilder: (context, state) => SpotubePage(
|
||||
child: MiniLyricsPage(prevSize: state.extra as Size),
|
||||
@ -216,6 +306,7 @@ final routerProvider = Provider((ref) {
|
||||
),
|
||||
GoRoute(
|
||||
path: "/getting-started",
|
||||
name: GettingStarting.name,
|
||||
parentNavigatorKey: rootNavigatorKey,
|
||||
pageBuilder: (context, state) => const SpotubePage(
|
||||
child: GettingStarting(),
|
||||
@ -223,6 +314,7 @@ final routerProvider = Provider((ref) {
|
||||
),
|
||||
GoRoute(
|
||||
path: "/login",
|
||||
name: WebViewLogin.name,
|
||||
parentNavigatorKey: rootNavigatorKey,
|
||||
pageBuilder: (context, state) => SpotubePage(
|
||||
child: kIsMobile ? const WebViewLogin() : const DesktopLoginPage(),
|
||||
@ -230,6 +322,7 @@ final routerProvider = Provider((ref) {
|
||||
),
|
||||
GoRoute(
|
||||
path: "/login-tutorial",
|
||||
name: LoginTutorial.name,
|
||||
parentNavigatorKey: rootNavigatorKey,
|
||||
pageBuilder: (context, state) => const SpotubePage(
|
||||
child: LoginTutorial(),
|
||||
@ -237,6 +330,7 @@ final routerProvider = Provider((ref) {
|
||||
),
|
||||
GoRoute(
|
||||
path: "/lastfm-login",
|
||||
name: LastFMLoginPage.name,
|
||||
parentNavigatorKey: rootNavigatorKey,
|
||||
pageBuilder: (context, state) =>
|
||||
const SpotubePage(child: LastFMLoginPage()),
|
||||
|
@ -1,33 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotube/collections/spotube_icons.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 {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
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) => [
|
||||
SideBarTiles(id: "browse", icon: SpotubeIcons.home, title: l10n.browse),
|
||||
SideBarTiles(id: "search", icon: SpotubeIcons.search, title: l10n.search),
|
||||
SideBarTiles(
|
||||
id: "library", icon: SpotubeIcons.library, title: l10n.library),
|
||||
SideBarTiles(id: "lyrics", icon: SpotubeIcons.music, title: l10n.lyrics),
|
||||
];
|
||||
|
||||
List<SideBarTiles> getNavbarTileList(AppLocalizations l10n) => [
|
||||
SideBarTiles(id: "browse", icon: SpotubeIcons.home, title: l10n.browse),
|
||||
SideBarTiles(id: "search", icon: SpotubeIcons.search, title: l10n.search),
|
||||
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: "settings",
|
||||
icon: SpotubeIcons.settings,
|
||||
title: l10n.settings,
|
||||
)
|
||||
id: "lyrics",
|
||||
name: LyricsPage.name,
|
||||
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,
|
||||
),
|
||||
];
|
||||
|
@ -121,4 +121,7 @@ abstract class SpotubeIcons {
|
||||
static const monitor = FeatherIcons.monitor;
|
||||
static const power = FeatherIcons.power;
|
||||
static const bluetooth = FeatherIcons.bluetooth;
|
||||
static const chart = FeatherIcons.barChart2;
|
||||
static const folderAdd = FeatherIcons.folderPlus;
|
||||
static const folderRemove = FeatherIcons.folderMinus;
|
||||
}
|
||||
|
@ -9,7 +9,9 @@ import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/extensions/image.dart';
|
||||
import 'package:spotube/extensions/track.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/history/history.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/provider/spotify/spotify.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
@ -32,6 +34,7 @@ class AlbumCard extends HookConsumerWidget {
|
||||
final playing =
|
||||
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
|
||||
final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
|
||||
final historyNotifier = ref.read(playbackHistoryProvider.notifier);
|
||||
|
||||
bool isPlaylistPlaying = useMemoized(
|
||||
() => playlist.containsCollection(album.id!),
|
||||
@ -62,7 +65,14 @@ class AlbumCard extends HookConsumerWidget {
|
||||
description:
|
||||
"${album.albumType?.formatted} • ${album.artists?.asString() ?? ""}",
|
||||
onTap: () {
|
||||
ServiceUtils.push(context, "/album/${album.id}", extra: album);
|
||||
ServiceUtils.pushNamed(
|
||||
context,
|
||||
AlbumPage.name,
|
||||
pathParameters: {
|
||||
"id": album.id!,
|
||||
},
|
||||
extra: album,
|
||||
);
|
||||
},
|
||||
onPlaybuttonPressed: () async {
|
||||
updating.value = true;
|
||||
@ -79,14 +89,15 @@ class AlbumCard extends HookConsumerWidget {
|
||||
if (isRemoteDevice) {
|
||||
final remotePlayback = ref.read(connectProvider.notifier);
|
||||
await remotePlayback.load(
|
||||
WebSocketLoadEventData(
|
||||
WebSocketLoadEventData.album(
|
||||
tracks: fetchedTracks,
|
||||
collectionId: album.id!,
|
||||
collection: album,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await playlistNotifier.load(fetchedTracks, autoPlay: true);
|
||||
playlistNotifier.addCollection(album.id!);
|
||||
historyNotifier.addAlbums([album]);
|
||||
}
|
||||
} finally {
|
||||
updating.value = false;
|
||||
@ -104,6 +115,7 @@ class AlbumCard extends HookConsumerWidget {
|
||||
if (fetchedTracks.isEmpty) return;
|
||||
playlistNotifier.addTracks(fetchedTracks);
|
||||
playlistNotifier.addCollection(album.id!);
|
||||
historyNotifier.addAlbums([album]);
|
||||
if (context.mounted) {
|
||||
final snackbar = SnackBar(
|
||||
content: Text(
|
||||
|
@ -9,6 +9,7 @@ import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/extensions/image.dart';
|
||||
import 'package:spotube/hooks/utils/use_breakpoint_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/utils/service_utils.dart';
|
||||
|
||||
@ -34,6 +35,10 @@ class ArtistCard extends HookConsumerWidget {
|
||||
|
||||
final radius = BorderRadius.circular(15);
|
||||
|
||||
final bgColor = useBrightnessValue(
|
||||
theme.colorScheme.surface,
|
||||
theme.colorScheme.surfaceContainerHigh,
|
||||
);
|
||||
final double size = useBreakpointValue<double>(
|
||||
xs: 130,
|
||||
sm: 130,
|
||||
@ -45,12 +50,8 @@ class ArtistCard extends HookConsumerWidget {
|
||||
width: size,
|
||||
margin: const EdgeInsets.symmetric(vertical: 5),
|
||||
child: Material(
|
||||
shadowColor: theme.colorScheme.background,
|
||||
color: Color.lerp(
|
||||
theme.colorScheme.surfaceVariant,
|
||||
theme.colorScheme.surface,
|
||||
useBrightnessValue(.9, .7),
|
||||
),
|
||||
shadowColor: theme.colorScheme.surface,
|
||||
color: bgColor,
|
||||
elevation: 3,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: radius,
|
||||
@ -63,7 +64,13 @@ class ArtistCard extends HookConsumerWidget {
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
ServiceUtils.push(context, "/artist/${artist.id}");
|
||||
ServiceUtils.pushNamed(
|
||||
context,
|
||||
ArtistPage.name,
|
||||
pathParameters: {
|
||||
"id": artist.id!,
|
||||
},
|
||||
);
|
||||
},
|
||||
borderRadius: radius,
|
||||
child: Padding(
|
||||
|
@ -3,6 +3,7 @@ import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/pages/connect/connect.dart';
|
||||
import 'package:spotube/provider/connect/clients.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
|
||||
@ -22,7 +23,7 @@ class ConnectDeviceButton extends HookConsumerWidget {
|
||||
width: double.infinity,
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
ServiceUtils.push(context, "/connect");
|
||||
ServiceUtils.pushNamed(context, ConnectPage.name);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
@ -59,7 +60,7 @@ class ConnectDeviceButton extends HookConsumerWidget {
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
ServiceUtils.push(context, "/connect");
|
||||
ServiceUtils.pushNamed(context, ConnectPage.name);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
child: Ink(
|
||||
@ -111,7 +112,7 @@ class ConnectDeviceButton extends HookConsumerWidget {
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
),
|
||||
onPressed: () {
|
||||
ServiceUtils.push(context, "/connect");
|
||||
ServiceUtils.pushNamed(context, ConnectPage.name);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
@ -16,7 +16,6 @@ class TokenLoginForm extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, ref) {
|
||||
final authenticationNotifier = ref.watch(authenticationProvider.notifier);
|
||||
final directCodeController = useTextEditingController();
|
||||
final mounted = useIsMounted();
|
||||
|
||||
final isLoading = useState(false);
|
||||
|
||||
@ -57,7 +56,7 @@ class TokenLoginForm extends HookConsumerWidget {
|
||||
await AuthenticationCredentials.fromCookie(
|
||||
cookieHeader),
|
||||
);
|
||||
if (mounted()) {
|
||||
if (context.mounted) {
|
||||
onDone?.call();
|
||||
}
|
||||
} finally {
|
||||
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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/pages/home/feed/feed_section.dart';
|
||||
import 'package:spotube/provider/spotify/views/home.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
|
||||
@ -41,8 +42,13 @@ class HomePageFeedSection extends HookConsumerWidget {
|
||||
child: TextButton.icon(
|
||||
label: const Text("Browse More"),
|
||||
icon: const Icon(SpotubeIcons.angleRight),
|
||||
onPressed: () =>
|
||||
ServiceUtils.push(context, "/feeds/${section.uri}"),
|
||||
onPressed: () => ServiceUtils.pushNamed(
|
||||
context,
|
||||
HomeFeedSectionPage.name,
|
||||
pathParameters: {
|
||||
"feedId": section.uri,
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -1,12 +1,14 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:spotube/collections/fake.dart';
|
||||
import 'package:spotube/components/home/sections/friends/friend_item.dart';
|
||||
import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
|
||||
import 'package:spotube/models/spotify_friends.dart';
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
import 'package:spotube/provider/spotify/spotify.dart';
|
||||
|
||||
class HomePageFriendsSection extends HookConsumerWidget {
|
||||
@ -14,6 +16,7 @@ class HomePageFriendsSection extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final auth = ref.watch(authenticationProvider);
|
||||
final friendsQuery = ref.watch(friendsProvider);
|
||||
final friends =
|
||||
friendsQuery.asData?.value.friends ?? FakeData.friends.friends;
|
||||
@ -27,32 +30,36 @@ class HomePageFriendsSection extends HookConsumerWidget {
|
||||
xxl: 7,
|
||||
);
|
||||
|
||||
final friendGroup = friends.fold<List<List<SpotifyFriendActivity>>>(
|
||||
[],
|
||||
(previousValue, element) {
|
||||
if (previousValue.isEmpty) {
|
||||
final friendGroup = useMemoized(
|
||||
() => friends.fold<List<List<SpotifyFriendActivity>>>(
|
||||
[],
|
||||
(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 [
|
||||
...previousValue,
|
||||
[element]
|
||||
];
|
||||
}
|
||||
|
||||
final lastGroup = previousValue.last;
|
||||
if (lastGroup.length < groupCount) {
|
||||
return [
|
||||
...previousValue.sublist(0, previousValue.length - 1),
|
||||
[...lastGroup, element]
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
...previousValue,
|
||||
[element]
|
||||
];
|
||||
},
|
||||
},
|
||||
),
|
||||
[friends, groupCount],
|
||||
);
|
||||
|
||||
if (friendsQuery.isLoading ||
|
||||
friendsQuery.asData?.value.friends.isEmpty == true) {
|
||||
friendsQuery.asData?.value.friends.isEmpty == true ||
|
||||
auth == null) {
|
||||
return const SliverToBoxAdapter(
|
||||
child: SizedBox.shrink(),
|
||||
);
|
||||
|
@ -6,6 +6,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/shared/image/universal_image.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';
|
||||
|
||||
class FriendItem extends HookConsumerWidget {
|
||||
@ -27,7 +30,7 @@ class FriendItem extends HookConsumerWidget {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
color: colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
constraints: const BoxConstraints(
|
||||
@ -57,7 +60,9 @@ class FriendItem extends HookConsumerWidget {
|
||||
text: friend.track.name,
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () {
|
||||
context.push("/track/${friend.track.id}");
|
||||
context.pushNamed(TrackPage.name, pathParameters: {
|
||||
"id": friend.track.id,
|
||||
});
|
||||
},
|
||||
),
|
||||
const TextSpan(text: " • "),
|
||||
@ -71,8 +76,12 @@ class FriendItem extends HookConsumerWidget {
|
||||
text: " ${friend.track.artist.name}",
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () {
|
||||
context.push(
|
||||
"/artist/${friend.track.artist.id}",
|
||||
context.pushNamed(
|
||||
ArtistPage.name,
|
||||
pathParameters: {
|
||||
"id": friend.track.artist.id,
|
||||
},
|
||||
extra: friend.track.artist,
|
||||
);
|
||||
},
|
||||
),
|
||||
@ -105,8 +114,11 @@ class FriendItem extends HookConsumerWidget {
|
||||
final album =
|
||||
await spotify.albums.get(friend.track.album.id);
|
||||
if (context.mounted) {
|
||||
context.push(
|
||||
"/album/${friend.track.album.id}",
|
||||
context.pushNamed(
|
||||
AlbumPage.name,
|
||||
pathParameters: {
|
||||
"id": friend.track.album.id,
|
||||
},
|
||||
extra: album,
|
||||
);
|
||||
}
|
||||
|
@ -13,6 +13,8 @@ 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/pages/home/genres/genre_playlists.dart';
|
||||
import 'package:spotube/pages/home/genres/genres.dart';
|
||||
import 'package:spotube/provider/spotify/spotify.dart';
|
||||
|
||||
class HomeGenresSection extends HookConsumerWidget {
|
||||
@ -50,11 +52,11 @@ class HomeGenresSection extends HookConsumerWidget {
|
||||
textDirection: TextDirection.rtl,
|
||||
child: TextButton.icon(
|
||||
onPressed: () {
|
||||
context.push('/genres');
|
||||
context.pushNamed(GenrePage.name);
|
||||
},
|
||||
icon: const Icon(SpotubeIcons.angleRight),
|
||||
label: Text(
|
||||
"Browse All",
|
||||
context.l10n.browse_all,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.secondary,
|
||||
),
|
||||
@ -110,7 +112,13 @@ class HomeGenresSection extends HookConsumerWidget {
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
context.push('/genre/${category.id}', extra: category);
|
||||
context.pushNamed(
|
||||
GenrePlaylistsPage.name,
|
||||
pathParameters: {
|
||||
"categoryId": category.id!,
|
||||
},
|
||||
extra: category,
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Ink(
|
||||
@ -126,7 +134,7 @@ class HomeGenresSection extends HookConsumerWidget {
|
||||
child: Ink(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
color: colorScheme.surfaceVariant,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
gradient: categoriesQuery.isLoading ? null : gradient,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
|
32
lib/components/home/sections/recent.dart
Normal file
32
lib/components/home/sections/recent.dart
Normal 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: () {},
|
||||
);
|
||||
}
|
||||
}
|
199
lib/components/library/local_folder/local_folder_item.dart
Normal file
199
lib/components/library/local_folder/local_folder_item.dart
Normal 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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -71,7 +71,7 @@ class MultiSelectField<T> extends HookWidget {
|
||||
: theme.colorScheme.onSurface.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
mouseCursor: MaterialStateMouseCursor.textable,
|
||||
mouseCursor: WidgetStateMouseCursor.textable,
|
||||
onPressed: !enabled
|
||||
? null
|
||||
: () async {
|
||||
|
@ -1,52 +1,18 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:catcher_2/catcher_2.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:file_selector/file_selector.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
|
||||
import 'package:gap/gap.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/components/shared/expandable_search/expandable_search.dart';
|
||||
import 'package:spotube/components/shared/fallbacks/not_found.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/components/library/local_folder/local_folder_item.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/extensions/track.dart';
|
||||
import 'package:spotube/models/local_track.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/provider/local_tracks/local_tracks_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
|
||||
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 {
|
||||
none,
|
||||
@ -59,273 +25,77 @@ enum SortBy {
|
||||
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 {
|
||||
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
|
||||
Widget build(BuildContext context, ref) {
|
||||
final sortBy = useState<SortBy>(SortBy.none);
|
||||
final playlist = ref.watch(proxyPlaylistProvider);
|
||||
final trackSnapshot = ref.watch(localTracksProvider);
|
||||
final isPlaylistPlaying =
|
||||
playlist.containsTracks(trackSnapshot.asData?.value ?? []);
|
||||
final preferencesNotifier = ref.watch(userPreferencesProvider.notifier);
|
||||
final preferences = ref.watch(userPreferencesProvider);
|
||||
|
||||
final searchController = useTextEditingController();
|
||||
useValueListenable(searchController);
|
||||
final searchFocus = useFocusNode();
|
||||
final isFiltering = useState(false);
|
||||
final addLocalLibraryLocation = useCallback(() async {
|
||||
if (kIsMobile || kIsMacOS) {
|
||||
final dirStr = await FilePicker.platform.getDirectoryPath(
|
||||
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(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 5),
|
||||
FilledButton(
|
||||
onPressed: trackSnapshot.asData?.value != null
|
||||
? () async {
|
||||
if (trackSnapshot.asData?.value.isNotEmpty == true) {
|
||||
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,
|
||||
),
|
||||
return LayoutBuilder(builder: (context, constrains) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
icon: const Icon(SpotubeIcons.folderAdd),
|
||||
label: Text(context.l10n.add_library_location),
|
||||
onPressed: addLocalLibraryLocation,
|
||||
),
|
||||
),
|
||||
),
|
||||
error: (error, stackTrace) =>
|
||||
Text(error.toString() + stackTrace.toString()),
|
||||
)
|
||||
],
|
||||
);
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
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],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -122,7 +122,8 @@ class PlayerQueue extends HookConsumerWidget {
|
||||
top: 5.0,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceVariant.withOpacity(0.5),
|
||||
color:
|
||||
theme.colorScheme.surfaceContainerHighest.withOpacity(0.5),
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
child: CallbackShortcuts(
|
||||
|
@ -208,7 +208,8 @@ class SiblingTracksSheet extends HookConsumerWidget {
|
||||
: mediaQuery.size.height * .6,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: borderRadius,
|
||||
color: theme.colorScheme.surfaceVariant.withOpacity(.5),
|
||||
color:
|
||||
theme.colorScheme.surfaceContainerHighest.withOpacity(.5),
|
||||
),
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
|
@ -1,6 +1,5 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
|
||||
@ -31,11 +30,17 @@ class VolumeSlider extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Slider(
|
||||
min: 0,
|
||||
max: 1,
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
child: SliderTheme(
|
||||
data: const SliderThemeData(
|
||||
showValueIndicator: ShowValueIndicator.always,
|
||||
),
|
||||
child: Slider(
|
||||
min: 0,
|
||||
max: 1,
|
||||
label: (value * 100).toStringAsFixed(0),
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
),
|
||||
),
|
||||
);
|
||||
return Row(
|
||||
|
@ -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/extensions/image.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/history/history.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/provider/spotify/spotify.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
@ -22,6 +24,8 @@ class PlaylistCard extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, ref) {
|
||||
final playlistQueue = ref.watch(proxyPlaylistProvider);
|
||||
final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
|
||||
final historyNotifier = ref.read(playbackHistoryProvider.notifier);
|
||||
|
||||
final playing =
|
||||
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
|
||||
bool isPlaylistPlaying = useMemoized(
|
||||
@ -32,12 +36,23 @@ class PlaylistCard extends HookConsumerWidget {
|
||||
final updating = useState(false);
|
||||
final me = ref.watch(meProvider);
|
||||
|
||||
Future<List<Track>> fetchAllTracks() async {
|
||||
Future<List<Track>> fetchInitialTracks() async {
|
||||
if (playlist.id == 'user-liked-tracks') {
|
||||
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();
|
||||
}
|
||||
@ -55,9 +70,12 @@ class PlaylistCard extends HookConsumerWidget {
|
||||
isOwner: playlist.owner?.id == me.asData?.value.id &&
|
||||
me.asData?.value.id != null,
|
||||
onTap: () {
|
||||
ServiceUtils.push(
|
||||
ServiceUtils.pushNamed(
|
||||
context,
|
||||
"/playlist/${playlist.id}",
|
||||
PlaylistPage.name,
|
||||
pathParameters: {
|
||||
"id": playlist.id!,
|
||||
},
|
||||
extra: playlist,
|
||||
);
|
||||
},
|
||||
@ -70,22 +88,29 @@ class PlaylistCard extends HookConsumerWidget {
|
||||
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);
|
||||
if (isRemoteDevice) {
|
||||
final remotePlayback = ref.read(connectProvider.notifier);
|
||||
final allTracks = await fetchAllTracks();
|
||||
await remotePlayback.load(
|
||||
WebSocketLoadEventData(
|
||||
tracks: fetchedTracks,
|
||||
collectionId: playlist.id!,
|
||||
WebSocketLoadEventData.playlist(
|
||||
tracks: allTracks,
|
||||
collection: playlist,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await playlistNotifier.load(fetchedTracks, autoPlay: true);
|
||||
await playlistNotifier.load(fetchedInitialTracks, autoPlay: true);
|
||||
playlistNotifier.addCollection(playlist.id!);
|
||||
historyNotifier.addPlaylists([playlist]);
|
||||
|
||||
final allTracks = await fetchAllTracks();
|
||||
|
||||
await playlistNotifier
|
||||
.addTracks(allTracks.sublist(fetchedInitialTracks.length));
|
||||
}
|
||||
} finally {
|
||||
if (context.mounted) {
|
||||
@ -98,20 +123,22 @@ class PlaylistCard extends HookConsumerWidget {
|
||||
try {
|
||||
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!);
|
||||
historyNotifier.addPlaylists([playlist]);
|
||||
if (context.mounted) {
|
||||
final snackbar = SnackBar(
|
||||
content: Text("Added ${fetchedTracks.length} tracks to queue"),
|
||||
content:
|
||||
Text("Added ${fetchedInitialTracks.length} tracks to queue"),
|
||||
action: SnackBarAction(
|
||||
label: "Undo",
|
||||
onPressed: () {
|
||||
playlistNotifier
|
||||
.removeTracks(fetchedTracks.map((e) => e.id!));
|
||||
.removeTracks(fetchedInitialTracks.map((e) => e.id!));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
@ -1,6 +1,5 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:go_router/go_router.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/context.dart';
|
||||
import 'package:spotube/extensions/image.dart';
|
||||
import 'package:spotube/hooks/utils/use_brightness_value.dart';
|
||||
import 'package:spotube/models/logger.dart';
|
||||
import 'package:flutter/material.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/volume_provider.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
class BottomPlayer extends HookConsumerWidget {
|
||||
BottomPlayer({super.key});
|
||||
@ -49,12 +48,6 @@ class BottomPlayer extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
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
|
||||
// place in the global overlay stack aka [_entries]
|
||||
@ -67,7 +60,9 @@ class BottomPlayer extends HookConsumerWidget {
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(color: bgColor?.withOpacity(0.8)),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainer.withOpacity(.8),
|
||||
),
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
textStyle: theme.textTheme.bodyMedium!,
|
||||
@ -95,19 +90,19 @@ class BottomPlayer extends HookConsumerWidget {
|
||||
tooltip: context.l10n.mini_player,
|
||||
icon: const Icon(SpotubeIcons.miniPlayer),
|
||||
onPressed: () async {
|
||||
final prevSize =
|
||||
await DesktopTools.window.getSize();
|
||||
await DesktopTools.window.setMinimumSize(
|
||||
if (!kIsDesktop) return;
|
||||
|
||||
final prevSize = await windowManager.getSize();
|
||||
await windowManager.setMinimumSize(
|
||||
const Size(300, 300),
|
||||
);
|
||||
await DesktopTools.window.setAlwaysOnTop(true);
|
||||
await windowManager.setAlwaysOnTop(true);
|
||||
if (!kIsLinux) {
|
||||
await DesktopTools.window.setHasShadow(false);
|
||||
await windowManager.setHasShadow(false);
|
||||
}
|
||||
await DesktopTools.window
|
||||
await windowManager
|
||||
.setAlignment(Alignment.topRight);
|
||||
await DesktopTools.window
|
||||
.setSize(const Size(400, 500));
|
||||
await windowManager.setSize(const Size(400, 500));
|
||||
await Future.delayed(
|
||||
const Duration(milliseconds: 100),
|
||||
() async {
|
||||
|
@ -14,8 +14,9 @@ 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/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/authentication_provider.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';
|
||||
|
||||
class Sidebar extends HookConsumerWidget {
|
||||
final int? selectedIndex;
|
||||
final void Function(int) onSelectedIndexChanged;
|
||||
final Widget child;
|
||||
|
||||
const Sidebar({
|
||||
required this.selectedIndex,
|
||||
required this.onSelectedIndexChanged,
|
||||
required this.child,
|
||||
super.key,
|
||||
});
|
||||
@ -47,12 +44,9 @@ class Sidebar extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
static void goToSettings(BuildContext context) {
|
||||
GoRouter.of(context).go("/settings");
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final routerState = GoRouterState.of(context);
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
|
||||
final downloadCount = ref.watch(downloadManagerProvider).$downloadCount;
|
||||
@ -60,41 +54,22 @@ class Sidebar extends HookConsumerWidget {
|
||||
final 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(
|
||||
() => getSidebarTileList(context.l10n),
|
||||
[context.l10n],
|
||||
);
|
||||
|
||||
useEffect(() {
|
||||
if (controller.selectedIndex != selectedIndex && selectedIndex != null) {
|
||||
controller.selectIndex(selectedIndex!);
|
||||
}
|
||||
return null;
|
||||
}, [selectedIndex]);
|
||||
final selectedIndex = sidebarTileList.indexWhere(
|
||||
(e) => routerState.namedLocation(e.name) == routerState.matchedLocation,
|
||||
);
|
||||
|
||||
useEffect(() {
|
||||
void listener() {
|
||||
onSelectedIndexChanged(controller.selectedIndex);
|
||||
}
|
||||
final controller = useSidebarXController(
|
||||
selectedIndex: selectedIndex,
|
||||
extended: mediaQuery.lgAndUp,
|
||||
);
|
||||
|
||||
controller.addListener(listener);
|
||||
return () {
|
||||
controller.removeListener(listener);
|
||||
};
|
||||
}, [controller]);
|
||||
final theme = Theme.of(context);
|
||||
final bg = theme.colorScheme.surfaceContainer;
|
||||
|
||||
useEffect(() {
|
||||
if (!context.mounted) return;
|
||||
@ -106,6 +81,13 @@ class Sidebar extends HookConsumerWidget {
|
||||
return null;
|
||||
}, [mediaQuery, controller]);
|
||||
|
||||
useEffect(() {
|
||||
if (controller.selectedIndex != selectedIndex) {
|
||||
controller.selectIndex(selectedIndex);
|
||||
}
|
||||
return null;
|
||||
}, [selectedIndex]);
|
||||
|
||||
if (layoutMode == LayoutMode.compact ||
|
||||
(mediaQuery.smAndDown && layoutMode == LayoutMode.adaptive)) {
|
||||
return Scaffold(body: child);
|
||||
@ -119,23 +101,28 @@ class Sidebar extends HookConsumerWidget {
|
||||
items: sidebarTileList.mapIndexed(
|
||||
(index, e) {
|
||||
return SidebarXItem(
|
||||
iconWidget: Badge(
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
isLabelVisible: e.title == "Library" && downloadCount > 0,
|
||||
label: Text(
|
||||
downloadCount.toString(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
onTap: () {
|
||||
context.goNamed(e.name);
|
||||
},
|
||||
iconBuilder: (selected, hovered) {
|
||||
return Badge(
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
isLabelVisible: e.title == "Library" && downloadCount > 0,
|
||||
label: Text(
|
||||
downloadCount.toString(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
e.icon,
|
||||
color: selectedIndex == index
|
||||
? theme.colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
e.icon,
|
||||
color: selected || hovered
|
||||
? theme.colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
label: e.title,
|
||||
);
|
||||
},
|
||||
@ -166,7 +153,7 @@ class Sidebar extends HookConsumerWidget {
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor?.withOpacity(0.8),
|
||||
color: bg,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topRight: Radius.circular(10),
|
||||
bottomRight: Radius.circular(10),
|
||||
@ -257,7 +244,7 @@ class SidebarFooter extends HookConsumerWidget {
|
||||
if (mediaQuery.mdAndDown) {
|
||||
return IconButton(
|
||||
icon: const Icon(SpotubeIcons.settings),
|
||||
onPressed: () => Sidebar.goToSettings(context),
|
||||
onPressed: () => ServiceUtils.navigateNamed(context, SettingsPage.name),
|
||||
);
|
||||
}
|
||||
|
||||
@ -278,7 +265,7 @@ class SidebarFooter extends HookConsumerWidget {
|
||||
Flexible(
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
ServiceUtils.push(context, "/profile");
|
||||
ServiceUtils.pushNamed(context, ProfilePage.name);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
child: Row(
|
||||
@ -310,7 +297,7 @@ class SidebarFooter extends HookConsumerWidget {
|
||||
IconButton(
|
||||
icon: const Icon(SpotubeIcons.settings),
|
||||
onPressed: () {
|
||||
Sidebar.goToSettings(context);
|
||||
ServiceUtils.pushNamed(context, SettingsPage.name);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
@ -3,55 +3,54 @@ import 'dart:ui';
|
||||
import 'package:curved_navigation_bar/curved_navigation_bar.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import 'package:spotube/collections/side_bar_tiles.dart';
|
||||
import 'package:spotube/components/root/sidebar.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/hooks/utils/use_brightness_value.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_state.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
|
||||
final navigationPanelHeight = StateProvider<double>((ref) => 50);
|
||||
|
||||
class SpotubeNavigationBar extends HookConsumerWidget {
|
||||
final int? selectedIndex;
|
||||
final void Function(int) onSelectedIndexChanged;
|
||||
|
||||
const SpotubeNavigationBar({
|
||||
required this.selectedIndex,
|
||||
required this.onSelectedIndexChanged,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final theme = Theme.of(context);
|
||||
final routerState = GoRouterState.of(context);
|
||||
|
||||
final downloadCount = ref.watch(downloadManagerProvider).$downloadCount;
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final layoutMode =
|
||||
ref.watch(userPreferencesProvider.select((s) => s.layoutMode));
|
||||
|
||||
final insideSelectedIndex = useState<int>(selectedIndex ?? 0);
|
||||
|
||||
final buttonColor = useBrightnessValue(
|
||||
theme.colorScheme.inversePrimary,
|
||||
theme.colorScheme.primary.withOpacity(0.2),
|
||||
);
|
||||
|
||||
final navbarTileList =
|
||||
useMemoized(() => getNavbarTileList(context.l10n), [context.l10n]);
|
||||
final navbarTileList = useMemoized(
|
||||
() => getNavbarTileList(context.l10n),
|
||||
[context.l10n],
|
||||
);
|
||||
|
||||
final panelHeight = ref.watch(navigationPanelHeight);
|
||||
|
||||
useEffect(() {
|
||||
if (selectedIndex != null) {
|
||||
insideSelectedIndex.value = selectedIndex!;
|
||||
}
|
||||
return null;
|
||||
}, [selectedIndex]);
|
||||
final selectedIndex = useMemoized(() {
|
||||
final index = navbarTileList.indexWhere(
|
||||
(e) => routerState.namedLocation(e.name) == routerState.matchedLocation,
|
||||
);
|
||||
|
||||
return index == -1 ? 0 : index;
|
||||
}, [navbarTileList, routerState.matchedLocation]);
|
||||
|
||||
if (layoutMode == LayoutMode.extended ||
|
||||
(mediaQuery.mdAndUp && layoutMode == LayoutMode.adaptive) ||
|
||||
@ -69,7 +68,7 @@ class SpotubeNavigationBar extends HookConsumerWidget {
|
||||
backgroundColor:
|
||||
theme.colorScheme.secondaryContainer.withOpacity(0.72),
|
||||
buttonBackgroundColor: buttonColor,
|
||||
color: theme.colorScheme.background,
|
||||
color: theme.colorScheme.surface,
|
||||
height: panelHeight,
|
||||
animationDuration: const Duration(milliseconds: 350),
|
||||
items: navbarTileList.map(
|
||||
@ -91,14 +90,9 @@ class SpotubeNavigationBar extends HookConsumerWidget {
|
||||
});
|
||||
},
|
||||
).toList(),
|
||||
index: insideSelectedIndex.value,
|
||||
index: selectedIndex,
|
||||
onTap: (i) {
|
||||
insideSelectedIndex.value = i;
|
||||
if (navbarTileList[i].id == "settings") {
|
||||
Sidebar.goToSettings(context);
|
||||
return;
|
||||
}
|
||||
onSelectedIndexChanged(i);
|
||||
ServiceUtils.navigateNamed(context, navbarTileList[i].name);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
56
lib/components/root/update_dialog.dart
Normal file
56
lib/components/root/update_dialog.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -179,9 +179,9 @@ class ColorTile extends StatelessWidget {
|
||||
colorScheme.primaryContainer,
|
||||
colorScheme.secondary,
|
||||
colorScheme.secondaryContainer,
|
||||
colorScheme.background,
|
||||
colorScheme.surface,
|
||||
colorScheme.surfaceVariant,
|
||||
colorScheme.surface,
|
||||
colorScheme.surfaceContainerHighest,
|
||||
colorScheme.onPrimary,
|
||||
colorScheme.onSurface,
|
||||
];
|
||||
|
@ -187,7 +187,7 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
|
||||
icon: icon ?? const Icon(SpotubeIcons.moreVertical),
|
||||
tooltip: tooltip,
|
||||
style: theme.iconButtonTheme.style?.copyWith(
|
||||
shape: MaterialStatePropertyAll(
|
||||
shape: WidgetStatePropertyAll(
|
||||
RoundedRectangleBorder(
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
|
@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/pages/settings/settings.dart';
|
||||
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
@ -25,7 +26,7 @@ class AnonymousFallback extends ConsumerWidget {
|
||||
const SizedBox(height: 10),
|
||||
FilledButton(
|
||||
child: Text(context.l10n.login_with_spotify),
|
||||
onPressed: () => ServiceUtils.push(context, "/settings"),
|
||||
onPressed: () => ServiceUtils.pushNamed(context, SettingsPage.name),
|
||||
)
|
||||
],
|
||||
),
|
||||
|
@ -96,7 +96,7 @@ class HorizontalPlaybuttonCardView<T> extends HookWidget {
|
||||
return switch (item) {
|
||||
PlaylistSimple() =>
|
||||
PlaylistCard(item as PlaylistSimple),
|
||||
AlbumSimple() => AlbumCard(item as Album),
|
||||
AlbumSimple() => AlbumCard(item as AlbumSimple),
|
||||
Artist() => Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12.0),
|
||||
|
@ -1,7 +1,7 @@
|
||||
import 'package:draggable_scrollbar/draggable_scrollbar.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
|
||||
class InterScrollbar extends HookWidget {
|
||||
final Widget child;
|
||||
@ -15,7 +15,7 @@ class InterScrollbar extends HookWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (DesktopTools.platform.isDesktop) return child;
|
||||
if (kIsDesktop) return child;
|
||||
|
||||
return DraggableScrollbar.semicircle(
|
||||
controller: controller,
|
||||
|
@ -29,7 +29,7 @@ class AnchorButton<T> extends HookWidget {
|
||||
onTapUp: (event) => tap.value = false,
|
||||
onTap: onTap,
|
||||
child: MouseRegion(
|
||||
cursor: MaterialStateMouseCursor.clickable,
|
||||
cursor: WidgetStateMouseCursor.clickable,
|
||||
child: Text(
|
||||
text,
|
||||
style: style.copyWith(
|
||||
|
@ -1,6 +1,7 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/components/shared/links/anchor_button.dart';
|
||||
import 'package:spotube/pages/artist/artist.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
|
||||
class ArtistLink extends StatelessWidget {
|
||||
@ -40,9 +41,12 @@ class ArtistLink extends StatelessWidget {
|
||||
if (onRouteChange != null) {
|
||||
onRouteChange?.call("/artist/${artist.value.id}");
|
||||
} else {
|
||||
ServiceUtils.push(
|
||||
ServiceUtils.pushNamed(
|
||||
context,
|
||||
"/artist/${artist.value.id}",
|
||||
ArtistPage.name,
|
||||
pathParameters: {
|
||||
"id": artist.value.id!,
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
|
@ -7,7 +7,8 @@ import 'package:titlebar_buttons/titlebar_buttons.dart';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
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
|
||||
implements PreferredSizeWidget {
|
||||
@ -89,7 +90,7 @@ class _PageWindowTitleBarState extends ConsumerState<PageWindowTitleBar> {
|
||||
final systemTitleBar =
|
||||
ref.read(userPreferencesProvider.select((s) => s.systemTitleBar));
|
||||
if (kIsDesktop && !systemTitleBar) {
|
||||
DesktopTools.window.startDragging();
|
||||
windowManager.startDragging();
|
||||
}
|
||||
}
|
||||
|
||||
@ -107,11 +108,7 @@ class _PageWindowTitleBarState extends ConsumerState<PageWindowTitleBar> {
|
||||
|
||||
return SliverPadding(
|
||||
padding: EdgeInsets.only(
|
||||
left: DesktopTools.platform.isMacOS &&
|
||||
hasFullscreen &&
|
||||
hasLeadingOrCanPop
|
||||
? 65
|
||||
: 0,
|
||||
left: kIsMacOS && hasFullscreen && hasLeadingOrCanPop ? 65 : 0,
|
||||
),
|
||||
sliver: SliverAppBar(
|
||||
leading: widget.leading,
|
||||
@ -149,11 +146,7 @@ class _PageWindowTitleBarState extends ConsumerState<PageWindowTitleBar> {
|
||||
onVerticalDragStart: onDrag,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: DesktopTools.platform.isMacOS &&
|
||||
hasFullscreen &&
|
||||
hasLeadingOrCanPop
|
||||
? 65
|
||||
: 0,
|
||||
left: kIsMacOS && hasFullscreen && hasLeadingOrCanPop ? 65 : 0,
|
||||
),
|
||||
child: AppBar(
|
||||
leading: widget.leading,
|
||||
@ -172,6 +165,10 @@ class _PageWindowTitleBarState extends ConsumerState<PageWindowTitleBar> {
|
||||
toolbarTextStyle: widget.toolbarTextStyle,
|
||||
titleTextStyle: widget.titleTextStyle,
|
||||
title: widget.title,
|
||||
scrolledUnderElevation: 0,
|
||||
shadowColor: Colors.transparent,
|
||||
forceMaterialTransparency: true,
|
||||
elevation: 0,
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -193,12 +190,12 @@ class WindowTitleBarButtons extends HookConsumerWidget {
|
||||
const type = ThemeType.auto;
|
||||
|
||||
Future<void> onClose() async {
|
||||
await DesktopTools.window.close();
|
||||
await windowManager.close();
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
if (kIsDesktop) {
|
||||
DesktopTools.window.isMaximized().then((value) {
|
||||
windowManager.isMaximized().then((value) {
|
||||
isMaximized.value = value;
|
||||
});
|
||||
}
|
||||
@ -213,16 +210,16 @@ class WindowTitleBarButtons extends HookConsumerWidget {
|
||||
final theme = Theme.of(context);
|
||||
final colors = WindowButtonColors(
|
||||
normal: Colors.transparent,
|
||||
iconNormal: foregroundColor ?? theme.colorScheme.onBackground,
|
||||
mouseOver: theme.colorScheme.onBackground.withOpacity(0.1),
|
||||
mouseDown: theme.colorScheme.onBackground.withOpacity(0.2),
|
||||
iconMouseOver: theme.colorScheme.onBackground,
|
||||
iconMouseDown: theme.colorScheme.onBackground,
|
||||
iconNormal: foregroundColor ?? theme.colorScheme.onSurface,
|
||||
mouseOver: theme.colorScheme.onSurface.withOpacity(0.1),
|
||||
mouseDown: theme.colorScheme.onSurface.withOpacity(0.2),
|
||||
iconMouseOver: theme.colorScheme.onSurface,
|
||||
iconMouseDown: theme.colorScheme.onSurface,
|
||||
);
|
||||
|
||||
final closeColors = WindowButtonColors(
|
||||
normal: Colors.transparent,
|
||||
iconNormal: foregroundColor ?? theme.colorScheme.onBackground,
|
||||
iconNormal: foregroundColor ?? theme.colorScheme.onSurface,
|
||||
mouseOver: Colors.red,
|
||||
mouseDown: Colors.red[800]!,
|
||||
iconMouseOver: Colors.white,
|
||||
@ -235,14 +232,14 @@ class WindowTitleBarButtons extends HookConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MinimizeWindowButton(
|
||||
onPressed: DesktopTools.window.minimize,
|
||||
onPressed: windowManager.minimize,
|
||||
colors: colors,
|
||||
),
|
||||
if (isMaximized.value != true)
|
||||
MaximizeWindowButton(
|
||||
colors: colors,
|
||||
onPressed: () {
|
||||
DesktopTools.window.maximize();
|
||||
windowManager.maximize();
|
||||
isMaximized.value = true;
|
||||
},
|
||||
)
|
||||
@ -250,7 +247,7 @@ class WindowTitleBarButtons extends HookConsumerWidget {
|
||||
RestoreWindowButton(
|
||||
colors: colors,
|
||||
onPressed: () {
|
||||
DesktopTools.window.unmaximize();
|
||||
windowManager.unmaximize();
|
||||
isMaximized.value = false;
|
||||
},
|
||||
),
|
||||
@ -270,16 +267,16 @@ class WindowTitleBarButtons extends HookConsumerWidget {
|
||||
children: [
|
||||
DecoratedMinimizeButton(
|
||||
type: type,
|
||||
onPressed: DesktopTools.window.minimize,
|
||||
onPressed: windowManager.minimize,
|
||||
),
|
||||
DecoratedMaximizeButton(
|
||||
type: type,
|
||||
onPressed: () async {
|
||||
if (await DesktopTools.window.isMaximized()) {
|
||||
await DesktopTools.window.unmaximize();
|
||||
if (await windowManager.isMaximized()) {
|
||||
await windowManager.unmaximize();
|
||||
isMaximized.value = false;
|
||||
} else {
|
||||
await DesktopTools.window.maximize();
|
||||
await windowManager.maximize();
|
||||
isMaximized.value = true;
|
||||
}
|
||||
},
|
||||
|
@ -53,6 +53,10 @@ class PlaybuttonCard extends HookWidget {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final radius = BorderRadius.circular(15);
|
||||
|
||||
final bgColor = useBrightnessValue(
|
||||
theme.colorScheme.surface,
|
||||
theme.colorScheme.surfaceContainerHigh,
|
||||
);
|
||||
final double size = useBreakpointValue<double>(
|
||||
xs: 130,
|
||||
sm: 130,
|
||||
@ -72,13 +76,9 @@ class PlaybuttonCard extends HookWidget {
|
||||
constraints: BoxConstraints(maxWidth: size),
|
||||
margin: margin,
|
||||
child: Material(
|
||||
color: Color.lerp(
|
||||
theme.colorScheme.surfaceVariant,
|
||||
theme.colorScheme.surface,
|
||||
useBrightnessValue(.9, .7),
|
||||
),
|
||||
color: bgColor,
|
||||
borderRadius: radius,
|
||||
shadowColor: theme.colorScheme.background,
|
||||
shadowColor: theme.colorScheme.surface,
|
||||
elevation: 3,
|
||||
child: InkWell(
|
||||
mouseCursor: SystemMouseCursors.click,
|
||||
@ -158,7 +158,7 @@ class PlaybuttonCard extends HookWidget {
|
||||
Skeleton.keep(
|
||||
child: IconButton(
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.background,
|
||||
backgroundColor: theme.colorScheme.surface,
|
||||
foregroundColor: theme.colorScheme.primary,
|
||||
minimumSize: const Size.square(10),
|
||||
),
|
||||
|
@ -5,7 +5,8 @@ import 'package:spotube/hooks/utils/use_brightness_value.dart';
|
||||
|
||||
class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget {
|
||||
final List<Widget> tabs;
|
||||
const ThemedButtonsTabBar({super.key, required this.tabs});
|
||||
final TabController? controller;
|
||||
const ThemedButtonsTabBar({super.key, required this.tabs, this.controller});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -21,6 +22,7 @@ class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget {
|
||||
bottom: 8,
|
||||
),
|
||||
child: ButtonsTabBar(
|
||||
controller: controller,
|
||||
radius: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
@ -32,7 +34,7 @@ class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget {
|
||||
),
|
||||
borderWidth: 0,
|
||||
unselectedDecoration: BoxDecoration(
|
||||
color: theme.colorScheme.background,
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
unselectedLabelStyle: theme.textTheme.labelLarge?.copyWith(
|
||||
|
@ -8,7 +8,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/collections/assets.gen.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/dialogs/playlist_add_track_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/blacklist_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/spotify/spotify.dart';
|
||||
import 'package:spotube/provider/spotify_provider.dart';
|
||||
@ -197,6 +197,8 @@ class TrackOptions extends HookConsumerWidget {
|
||||
return downloadManager.getProgressNotifier(spotubeTrack);
|
||||
});
|
||||
|
||||
final isLocalTrack = track is LocalTrack;
|
||||
|
||||
final adaptivePopSheetList = AdaptivePopSheetList<TrackOptionValue>(
|
||||
onSelected: (value) async {
|
||||
switch (value) {
|
||||
@ -314,118 +316,120 @@ class TrackOptions extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
],
|
||||
children: switch (track.runtimeType) {
|
||||
LocalTrack() => [
|
||||
PopSheetEntry(
|
||||
value: TrackOptionValue.delete,
|
||||
leading: const Icon(SpotubeIcons.trash),
|
||||
title: Text(context.l10n.delete),
|
||||
)
|
||||
],
|
||||
_ => [
|
||||
if (mediaQuery.smAndDown)
|
||||
PopSheetEntry(
|
||||
value: TrackOptionValue.album,
|
||||
leading: const Icon(SpotubeIcons.album),
|
||||
title: Text(context.l10n.go_to_album),
|
||||
subtitle: Text(track.album!.name!),
|
||||
),
|
||||
if (!playlist.containsTrack(track)) ...[
|
||||
PopSheetEntry(
|
||||
value: TrackOptionValue.addToQueue,
|
||||
leading: const Icon(SpotubeIcons.queueAdd),
|
||||
title: Text(context.l10n.add_to_queue),
|
||||
),
|
||||
PopSheetEntry(
|
||||
value: TrackOptionValue.playNext,
|
||||
leading: const Icon(SpotubeIcons.lightning),
|
||||
title: Text(context.l10n.play_next),
|
||||
),
|
||||
] else
|
||||
PopSheetEntry(
|
||||
value: TrackOptionValue.removeFromQueue,
|
||||
enabled: playlist.activeTrack?.id != track.id,
|
||||
leading: const Icon(SpotubeIcons.queueRemove),
|
||||
title: Text(context.l10n.remove_from_queue),
|
||||
),
|
||||
if (me.asData?.value != null)
|
||||
PopSheetEntry(
|
||||
value: TrackOptionValue.favorite,
|
||||
leading: favorites.isLiked
|
||||
? const Icon(
|
||||
SpotubeIcons.heartFilled,
|
||||
color: Colors.pink,
|
||||
)
|
||||
: const Icon(SpotubeIcons.heart),
|
||||
title: Text(
|
||||
favorites.isLiked
|
||||
? 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),
|
||||
children: [
|
||||
if (isLocalTrack)
|
||||
PopSheetEntry(
|
||||
value: TrackOptionValue.delete,
|
||||
leading: const Icon(SpotubeIcons.trash),
|
||||
title: Text(context.l10n.delete),
|
||||
),
|
||||
if (mediaQuery.smAndDown)
|
||||
PopSheetEntry(
|
||||
value: TrackOptionValue.album,
|
||||
leading: const Icon(SpotubeIcons.album),
|
||||
title: Text(context.l10n.go_to_album),
|
||||
subtitle: Text(track.album!.name!),
|
||||
),
|
||||
if (!playlist.containsTrack(track)) ...[
|
||||
PopSheetEntry(
|
||||
value: TrackOptionValue.addToQueue,
|
||||
leading: const Icon(SpotubeIcons.queueAdd),
|
||||
title: Text(context.l10n.add_to_queue),
|
||||
),
|
||||
PopSheetEntry(
|
||||
value: TrackOptionValue.playNext,
|
||||
leading: const Icon(SpotubeIcons.lightning),
|
||||
title: Text(context.l10n.play_next),
|
||||
),
|
||||
] else
|
||||
PopSheetEntry(
|
||||
value: TrackOptionValue.removeFromQueue,
|
||||
enabled: playlist.activeTrack?.id != track.id,
|
||||
leading: const Icon(SpotubeIcons.queueRemove),
|
||||
title: Text(context.l10n.remove_from_queue),
|
||||
),
|
||||
if (me.asData?.value != null && !isLocalTrack)
|
||||
PopSheetEntry(
|
||||
value: TrackOptionValue.favorite,
|
||||
leading: favorites.isLiked
|
||||
? const Icon(
|
||||
SpotubeIcons.heartFilled,
|
||||
color: Colors.pink,
|
||||
)
|
||||
: const Icon(SpotubeIcons.heart),
|
||||
title: Text(
|
||||
favorites.isLiked
|
||||
? context.l10n.remove_from_favorites
|
||||
: context.l10n.save_as_favorite,
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
if (auth != null && !isLocalTrack) ...[
|
||||
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 && !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,
|
||||
leading: const Icon(SpotubeIcons.share),
|
||||
title: Text(context.l10n.share),
|
||||
),
|
||||
if (!isLocalTrack)
|
||||
PopSheetEntry(
|
||||
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(
|
||||
value: TrackOptionValue.songlink,
|
||||
leading: Assets.logos.songlinkTransparent.image(
|
||||
width: 22,
|
||||
height: 22,
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
title: Text(context.l10n.song_link),
|
||||
),
|
||||
PopSheetEntry(
|
||||
value: TrackOptionValue.details,
|
||||
leading: const Icon(SpotubeIcons.info),
|
||||
title: Text(context.l10n.details),
|
||||
),
|
||||
]
|
||||
},
|
||||
title: Text(context.l10n.song_link),
|
||||
),
|
||||
if (!isLocalTrack)
|
||||
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
|
||||
|
@ -195,19 +195,26 @@ class TrackTile extends HookConsumerWidget {
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 6,
|
||||
child: LinkText(
|
||||
track.name!,
|
||||
"/track/${track.id}",
|
||||
push: true,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
child: switch (track) {
|
||||
LocalTrack() => Text(
|
||||
track.name!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
_ => LinkText(
|
||||
track.name!,
|
||||
"/track/${track.id}",
|
||||
push: true,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
},
|
||||
),
|
||||
if (constrains.mdAndUp) ...[
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: switch (track.runtimeType) {
|
||||
child: switch (track) {
|
||||
LocalTrack() => Text(
|
||||
track.album!.name!,
|
||||
maxLines: 1,
|
||||
|
@ -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/models/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/utils/service_utils.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) {
|
||||
final playlist = ref.watch(proxyPlaylistProvider);
|
||||
final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
|
||||
final historyNotifier = ref.watch(playbackHistoryProvider.notifier);
|
||||
final props = InheritedTrackView.of(context);
|
||||
final trackViewState = ref.watch(trackViewProvider(props.tracks));
|
||||
|
||||
@ -146,11 +148,17 @@ class TrackViewBodySection extends HookConsumerWidget {
|
||||
} else {
|
||||
final tracks = await props.pagination.onFetchAll();
|
||||
await remotePlayback.load(
|
||||
WebSocketLoadEventData(
|
||||
tracks: tracks,
|
||||
collectionId: props.collectionId,
|
||||
initialIndex: index,
|
||||
),
|
||||
props.collection is AlbumSimple
|
||||
? WebSocketLoadEventData.album(
|
||||
tracks: tracks,
|
||||
collection: props.collection as AlbumSimple,
|
||||
initialIndex: index,
|
||||
)
|
||||
: WebSocketLoadEventData.playlist(
|
||||
tracks: tracks,
|
||||
collection: props.collection as PlaylistSimple,
|
||||
initialIndex: index,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@ -164,6 +172,13 @@ class TrackViewBodySection extends HookConsumerWidget {
|
||||
autoPlay: true,
|
||||
);
|
||||
playlistNotifier.addCollection(props.collectionId);
|
||||
if (props.collection is AlbumSimple) {
|
||||
historyNotifier
|
||||
.addAlbums([props.collection as AlbumSimple]);
|
||||
} else {
|
||||
historyNotifier
|
||||
.addPlaylists([props.collection as PlaylistSimple]);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.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/extensions/context.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/user_preferences/user_preferences_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
|
||||
@ -23,6 +25,7 @@ class TrackViewBodyOptions extends HookConsumerWidget {
|
||||
ref.watch(downloadManagerProvider);
|
||||
final downloader = ref.watch(downloadManagerProvider.notifier);
|
||||
final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
|
||||
final historyNotifier = ref.watch(playbackHistoryProvider.notifier);
|
||||
final audioSource =
|
||||
ref.watch(userPreferencesProvider.select((s) => s.audioSource));
|
||||
|
||||
@ -72,6 +75,12 @@ class TrackViewBodyOptions extends HookConsumerWidget {
|
||||
{
|
||||
playlistNotifier.addTracksAtFirst(selectedTracks);
|
||||
playlistNotifier.addCollection(props.collectionId);
|
||||
if (props.collection is AlbumSimple) {
|
||||
historyNotifier.addAlbums([props.collection as AlbumSimple]);
|
||||
} else {
|
||||
historyNotifier
|
||||
.addPlaylists([props.collection as PlaylistSimple]);
|
||||
}
|
||||
trackViewState.deselectAll();
|
||||
break;
|
||||
}
|
||||
@ -79,6 +88,12 @@ class TrackViewBodyOptions extends HookConsumerWidget {
|
||||
{
|
||||
playlistNotifier.addTracks(selectedTracks);
|
||||
playlistNotifier.addCollection(props.collectionId);
|
||||
if (props.collection is AlbumSimple) {
|
||||
historyNotifier.addAlbums([props.collection as AlbumSimple]);
|
||||
} else {
|
||||
historyNotifier
|
||||
.addPlaylists([props.collection as PlaylistSimple]);
|
||||
}
|
||||
trackViewState.deselectAll();
|
||||
break;
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotube/collections/assets.gen.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:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/hooks/utils/use_palette_color.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
|
||||
class TrackViewFlexHeader extends HookConsumerWidget {
|
||||
const TrackViewFlexHeader({super.key});
|
||||
@ -53,7 +54,7 @@ class TrackViewFlexHeader extends HookConsumerWidget {
|
||||
floating: false,
|
||||
pinned: true,
|
||||
expandedHeight: 450,
|
||||
automaticallyImplyLeading: DesktopTools.platform.isMobile,
|
||||
automaticallyImplyLeading: kIsMobile,
|
||||
backgroundColor: palette.color,
|
||||
title: isExpanded ? null : Text(props.title, style: headingStyle),
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/playlist/playlist_create_dialog.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/extensions/context.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';
|
||||
|
||||
class TrackViewHeaderActions extends HookConsumerWidget {
|
||||
@ -20,6 +22,7 @@ class TrackViewHeaderActions extends HookConsumerWidget {
|
||||
|
||||
final playlist = ref.watch(proxyPlaylistProvider);
|
||||
final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
|
||||
final historyNotifier = ref.watch(playbackHistoryProvider.notifier);
|
||||
|
||||
final isActive = playlist.collections.contains(props.collectionId);
|
||||
|
||||
@ -61,6 +64,13 @@ class TrackViewHeaderActions extends HookConsumerWidget {
|
||||
final tracks = await props.pagination.onFetchAll();
|
||||
await playlistNotifier.addTracks(tracks);
|
||||
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)
|
||||
|
@ -5,12 +5,14 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:palette_generator/palette_generator.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/collections/spotube_icons.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/extensions/context.dart';
|
||||
import 'package:spotube/models/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/services/audio_player/audio_player.dart';
|
||||
|
||||
@ -28,6 +30,7 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
|
||||
final props = InheritedTrackView.of(context);
|
||||
final playlist = ref.watch(proxyPlaylistProvider);
|
||||
final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
|
||||
final historyNotifier = ref.watch(playbackHistoryProvider.notifier);
|
||||
|
||||
final isActive = playlist.collections.contains(props.collectionId);
|
||||
|
||||
@ -44,28 +47,45 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
|
||||
final allTracks = await props.pagination.onFetchAll();
|
||||
|
||||
final initialTracks = props.tracks;
|
||||
if (!context.mounted) return;
|
||||
|
||||
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
|
||||
if (isRemoteDevice) {
|
||||
final allTracks = await props.pagination.onFetchAll();
|
||||
final remotePlayback = ref.read(connectProvider.notifier);
|
||||
await remotePlayback.load(
|
||||
WebSocketLoadEventData(
|
||||
tracks: allTracks,
|
||||
collectionId: props.collectionId,
|
||||
initialIndex: Random().nextInt(allTracks.length)),
|
||||
props.collection is AlbumSimple
|
||||
? WebSocketLoadEventData.album(
|
||||
tracks: allTracks,
|
||||
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);
|
||||
} else {
|
||||
await playlistNotifier.load(
|
||||
allTracks,
|
||||
initialTracks,
|
||||
autoPlay: true,
|
||||
initialIndex: Random().nextInt(allTracks.length),
|
||||
initialIndex: Random().nextInt(initialTracks.length),
|
||||
);
|
||||
await audioPlayer.setShuffle(true);
|
||||
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 {
|
||||
isLoading.value = false;
|
||||
@ -76,22 +96,39 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
|
||||
final allTracks = await props.pagination.onFetchAll();
|
||||
final initialTracks = props.tracks;
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
|
||||
if (isRemoteDevice) {
|
||||
final allTracks = await props.pagination.onFetchAll();
|
||||
final remotePlayback = ref.read(connectProvider.notifier);
|
||||
await remotePlayback.load(
|
||||
WebSocketLoadEventData(
|
||||
tracks: allTracks,
|
||||
collectionId: props.collectionId,
|
||||
),
|
||||
props.collection is AlbumSimple
|
||||
? WebSocketLoadEventData.album(
|
||||
tracks: allTracks,
|
||||
collection: props.collection as AlbumSimple,
|
||||
)
|
||||
: WebSocketLoadEventData.playlist(
|
||||
tracks: allTracks,
|
||||
collection: props.collection as PlaylistSimple,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await playlistNotifier.load(allTracks, autoPlay: true);
|
||||
await playlistNotifier.load(initialTracks, autoPlay: true);
|
||||
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 {
|
||||
isLoading.value = false;
|
||||
|
@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
|
||||
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package: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/body/track_view_body.dart';
|
||||
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
|
||||
class TrackView extends HookConsumerWidget {
|
||||
const TrackView({super.key});
|
||||
@ -18,7 +19,7 @@ class TrackView extends HookConsumerWidget {
|
||||
final controller = useScrollController();
|
||||
|
||||
return Scaffold(
|
||||
appBar: DesktopTools.platform.isDesktop
|
||||
appBar: kIsDesktop
|
||||
? const PageWindowTitleBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: Colors.white,
|
||||
|
@ -39,7 +39,7 @@ class PaginationProps {
|
||||
}
|
||||
|
||||
class InheritedTrackView extends InheritedWidget {
|
||||
final String collectionId;
|
||||
final Object collection;
|
||||
final String title;
|
||||
final String? description;
|
||||
final String image;
|
||||
@ -55,7 +55,7 @@ class InheritedTrackView extends InheritedWidget {
|
||||
const InheritedTrackView({
|
||||
super.key,
|
||||
required super.child,
|
||||
required this.collectionId,
|
||||
required this.collection,
|
||||
required this.title,
|
||||
this.description,
|
||||
required this.image,
|
||||
@ -65,7 +65,11 @@ class InheritedTrackView extends InheritedWidget {
|
||||
required this.shareUrl,
|
||||
this.isLiked = false,
|
||||
this.onHeart,
|
||||
});
|
||||
}) : assert(collection is AlbumSimple || collection is PlaylistSimple);
|
||||
|
||||
String get collectionId => collection is AlbumSimple
|
||||
? (collection as AlbumSimple).id!
|
||||
: (collection as PlaylistSimple).id!;
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(InheritedTrackView oldWidget) {
|
||||
@ -78,7 +82,7 @@ class InheritedTrackView extends InheritedWidget {
|
||||
oldWidget.onHeart != onHeart ||
|
||||
oldWidget.shareUrl != shareUrl ||
|
||||
oldWidget.routePath != routePath ||
|
||||
oldWidget.collectionId != collectionId ||
|
||||
oldWidget.collection != collection ||
|
||||
oldWidget.child != child;
|
||||
}
|
||||
|
||||
|
@ -20,8 +20,6 @@ class Waypoint extends HookWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isMounted = useIsMounted();
|
||||
|
||||
useEffect(() {
|
||||
if (isGrid) {
|
||||
return null;
|
||||
@ -32,19 +30,19 @@ class Waypoint extends HookWidget {
|
||||
|
||||
// scrollController fetches the next paginated data when the current
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (controller.hasClients && isMounted()) {
|
||||
if (controller.hasClients && context.mounted) {
|
||||
listener();
|
||||
controller.addListener(listener);
|
||||
}
|
||||
});
|
||||
return () => controller.removeListener(listener);
|
||||
}, [controller, onTouchEdge, isMounted]);
|
||||
}, [controller, onTouchEdge]);
|
||||
|
||||
if (isGrid) {
|
||||
return VisibilityDetector(
|
||||
|
53
lib/components/stats/common/album_item.dart
Normal file
53
lib/components/stats/common/album_item.dart
Normal 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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
39
lib/components/stats/common/artist_item.dart
Normal file
39
lib/components/stats/common/artist_item.dart
Normal 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!},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
46
lib/components/stats/common/playlist_item.dart
Normal file
46
lib/components/stats/common/playlist_item.dart
Normal 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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
49
lib/components/stats/common/track_item.dart
Normal file
49
lib/components/stats/common/track_item.dart
Normal 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!,
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
100
lib/components/stats/summary/summary.dart
Normal file
100
lib/components/stats/summary/summary.dart
Normal 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);
|
||||
},
|
||||
),
|
||||
]),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
86
lib/components/stats/summary/summary_card.dart
Normal file
86
lib/components/stats/summary/summary_card.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
29
lib/components/stats/top/albums.dart
Normal file
29
lib/components/stats/top/albums.dart
Normal 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",
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
27
lib/components/stats/top/artists.dart
Normal file
27
lib/components/stats/top/artists.dart
Normal 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"),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
106
lib/components/stats/top/top.dart
Normal file
106
lib/components/stats/top/top.dart
Normal 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(),
|
||||
};
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
31
lib/components/stats/top/tracks.dart
Normal file
31
lib/components/stats/top/tracks.dart
Normal 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",
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -1,21 +1,6 @@
|
||||
import 'package:spotify/spotify.dart';
|
||||
|
||||
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 album = Album();
|
||||
album.albumType = albumType;
|
||||
|
@ -1,17 +1,5 @@
|
||||
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> {
|
||||
String asString() {
|
||||
return map((e) => e.name?.replaceAll(",", " ")).join(", ");
|
||||
|
@ -3,8 +3,6 @@ import 'dart:io';
|
||||
import 'package:metadata_god/metadata_god.dart';
|
||||
import 'package:path/path.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';
|
||||
|
||||
extension TrackExtensions on Track {
|
||||
@ -39,33 +37,6 @@ extension TrackExtensions on Track {
|
||||
|
||||
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 {
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user