diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 00000000..ddfd1517
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,6 @@
+build
+dist
+.dart_tool
+.idea
+.github
+.git
\ No newline at end of file
diff --git a/.env.example b/.env.example
index 22abd24b..56665663 100644
--- a/.env.example
+++ b/.env.example
@@ -9,3 +9,6 @@ ENABLE_UPDATE_CHECK=
LASTFM_API_KEY=
LASTFM_API_SECRET=
+
+# Release channel. Can be: nightly, stable
+RELEASE_CHANNEL=
diff --git a/.fvm/fvm_config.json b/.fvm/fvm_config.json
index 7ca74200..df8efa0e 100644
--- a/.fvm/fvm_config.json
+++ b/.fvm/fvm_config.json
@@ -1,4 +1,4 @@
{
- "flutterSdkVersion": "3.19.1",
+ "flutterSdkVersion": "3.22.1",
"flavors": {}
}
\ No newline at end of file
diff --git a/.github/Dockerfile b/.github/Dockerfile
new file mode 100644
index 00000000..2e393449
--- /dev/null
+++ b/.github/Dockerfile
@@ -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" ]
\ No newline at end of file
diff --git a/.github/Dockerfile.flutter_distributor b/.github/Dockerfile.flutter_distributor
new file mode 100644
index 00000000..952b9158
--- /dev/null
+++ b/.github/Dockerfile.flutter_distributor
@@ -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
\ No newline at end of file
diff --git a/.github/workflows/pr-lint.yml b/.github/workflows/pr-lint.yml
index e4fb55c5..2844986d 100644
--- a/.github/workflows/pr-lint.yml
+++ b/.github/workflows/pr-lint.yml
@@ -4,13 +4,15 @@ on:
pull_request:
env:
- FLUTTER_VERSION: '3.16.0'
+ FLUTTER_VERSION: '3.22.1'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
diff --git a/.github/workflows/spotube-publish-binary.yml b/.github/workflows/spotube-publish-binary.yml
index 12a2f99b..0d39ab1d 100644
--- a/.github/workflows/spotube-publish-binary.yml
+++ b/.github/workflows/spotube-publish-binary.yml
@@ -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
@@ -66,7 +66,7 @@ jobs:
- name: Release to AUR
if: ${{ !inputs.dry_run }}
- uses: KSXGitHub/github-actions-deploy-aur@v2.7.0
+ uses: KSXGitHub/github-actions-deploy-aur@v2.7.1
with:
pkgname: spotube-bin
pkgbuild: aur-struct/PKGBUILD
diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml
index 68ea2d67..8e68211c 100644
--- a/.github/workflows/spotube-release-binary.yml
+++ b/.github/workflows/spotube-release-binary.yml
@@ -2,279 +2,109 @@ name: Spotube Release Binary
on:
workflow_dispatch:
inputs:
- version:
- description: Version to release (x.x.x)
- default: 3.4.1
- 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}}%| |' 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
+ path: ${{matrix.files}}
- name: Debug With SSH When fails
if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }}
@@ -282,135 +112,10 @@ jobs:
with:
limit-access-to-actor: true
- macos:
-
- runs-on: macos-12
- 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: |
- python3 -m pip install 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-latest
- steps:
- - uses: actions/checkout@v4
- - uses: subosito/flutter-action@v2.10.0
- with:
- cache: true
- flutter-version: ${{ env.FLUTTER_VERSION }}
-
- - name: Replace pubspec version and BUILD_VERSION Env (nightly)
- if: ${{ inputs.channel == 'nightly' }}
- run: |
- brew install yq
- yq -i '.version |= sub("\+\d+", "+${{ inputs.channel }}.")' pubspec.yaml
- yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml
- echo "BUILD_VERSION=${{ inputs.version }}+${{ inputs.channel }}.${{ github.run_number }}" >> $GITHUB_ENV
-
- - name: BUILD_VERSION Env (stable)
- if: ${{ inputs.channel == 'stable' }}
- run: |
- echo "BUILD_VERSION=${{ inputs.version }}" >> $GITHUB_ENV
-
- - name: Create Stable .env
- if: ${{ inputs.channel == 'stable' }}
- run: echo '${{ secrets.DOTENV_RELEASE }}' > .env
-
- - name: Create Nightly .env
- if: ${{ inputs.channel == 'nightly' }}
- run: echo '${{ secrets.DOTENV_NIGHTLY }}' > .env
-
- - name: Generate Secrets
- run: |
- flutter pub get
- dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns
-
- - name: Build iOS iPA
- run: |
- flutter build ios --release --no-codesign --flavor ${{ inputs.channel }}
- ln -sf ./build/ios/iphoneos Payload
- zip -r9 Spotube-iOS.ipa Payload/${{ inputs.channel }}.app
-
- - uses: actions/upload-artifact@v3
- with:
- if-no-files-found: error
- name: Spotube-Release-Binaries
- path: |
- Spotube-iOS.ipa
-
- - name: Debug With SSH When fails
- if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }}
- uses: mxschmitt/action-tmate@v3
- with:
- limit-access-to-actor: true
-
upload:
runs-on: ubuntu-latest
-
needs:
- - windows
- - linux
- - android
- - macos
- - iOS
+ - build_platform
steps:
- uses: actions/download-artifact@v3
with:
@@ -426,6 +131,10 @@ jobs:
md5sum Spotube-Release-Binaries/* >> RELEASE.md5sum
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:
@@ -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
diff --git a/.gitignore b/.gitignore
index 96d81087..4f9ebc28 100644
--- a/.gitignore
+++ b/.gitignore
@@ -76,3 +76,5 @@ android/key.properties
.fvm/flutter_sdk
**/pb_data
+
+tm.json
diff --git a/.metadata b/.metadata
index 082985ad..828f2c0a 100644
--- a/.metadata
+++ b/.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
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 0e6a4294..de5fbd69 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -2,11 +2,19 @@
"cmake.configureOnOpen": false,
"cSpell.words": [
"acousticness",
+ "ambiguate",
+ "Amoled",
+ "Buildless",
"danceability",
+ "fuzzywuzzy",
+ "gapless",
"instrumentalness",
"Mpris",
+ "RGBO",
"riverpod",
"Scrobblenaut",
+ "skeletonizer",
+ "songlink",
"speechiness",
"Spotube",
"winget"
@@ -16,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",
}
}
\ No newline at end of file
diff --git a/.vscode/snippets.code-snippets b/.vscode/snippets.code-snippets
new file mode 100644
index 00000000..9a18929b
--- /dev/null
+++ b/.vscode/snippets.code-snippets
@@ -0,0 +1,170 @@
+{
+ "PaginatedState": {
+ "scope": "dart",
+ "prefix": "paginatedState",
+ "description": "Generate a PaginatedState",
+ "body": [
+ "class ${1:Model}State extends PaginatedState<${2:Model}> {",
+ " ${1:Model}State({",
+ " required super.items,",
+ " required super.offset,",
+ " required super.limit,",
+ " required super.hasMore,",
+ " });",
+ " ",
+ " @override",
+ " ${1:Model}State copyWith({",
+ " List<${2:Model}>? items,",
+ " int? offset,",
+ " int? limit,",
+ " bool? hasMore,",
+ " }) {",
+ " return ${1:Model}State(",
+ " items: items ?? this.items,",
+ " offset: offset ?? this.offset,",
+ " limit: limit ?? this.limit,",
+ " hasMore: hasMore ?? this.hasMore,",
+ " );",
+ " }",
+ "}"
+ ]
+ },
+ "PaginatedAsyncNotifier": {
+ "scope": "dart",
+ "prefix": "paginatedAsyncNotifier",
+ "description": "Generate a PaginatedAsyncNotifier",
+ "body": [
+ "class ${1:NotifierName}Notifier extends PaginatedAsyncNotifier<${3:Item}, ${2:Model}State> {",
+ " ${1:NotifierName}Notifier() : super();",
+ " ",
+ " @override",
+ " fetch(int offset, int limit) async {",
+ " throw UnimplementedError();",
+ " }",
+ " ",
+ " @override",
+ " build() async {",
+ " throw UnimplementedError();",
+ " }",
+ "}"
+ ]
+ },
+ "PaginaitedNotifierWithState": {
+ "scope": "dart",
+ "prefix": "paginatedNotifierWithState",
+ "description": "Generate a PaginatedNotifier with PaginatedState",
+ "body": [
+ "class $1State extends PaginatedState<$2> {",
+ " $1State({",
+ " required super.items,",
+ " required super.offset,",
+ " required super.limit,",
+ " required super.hasMore,",
+ " });",
+ " ",
+ " @override",
+ " $1State copyWith({",
+ " List<$2>? items,",
+ " int? offset,",
+ " int? limit,",
+ " bool? hasMore,",
+ " }) {",
+ " return $1State(",
+ " items: items ?? this.items,",
+ " offset: offset ?? this.offset,",
+ " limit: limit ?? this.limit,",
+ " hasMore: hasMore ?? this.hasMore,",
+ " );",
+ " }",
+ "}",
+ " ",
+ "class $1Notifier",
+ " extends PaginatedAsyncNotifier<$2, $1State> {",
+ " $1Notifier() : super();",
+ " ",
+ " @override",
+ " fetch(int offset, int limit) async {",
+ " throw UnimplementedError();",
+ " }",
+ " ",
+ " @override",
+ " build() async {",
+ " throw UnimplementedError();",
+ " }",
+ "}",
+ " ",
+ "final ${1/(.*)/${1:/camelcase}/}Provider = AsyncNotifierProvider<$1Notifier, $1State>(",
+ " ()=> $1Notifier(),",
+ ");"
+ ]
+ },
+ "FamilyPaginatedAsyncNotifier": {
+ "scope": "dart",
+ "prefix": "familyPaginatedAsyncNotifier",
+ "description": "Generate a FamilyPaginatedAsyncNotifier",
+ "body": [
+ "class ${1:NotifierName}Notifier extends FamilyPaginatedAsyncNotifier<${3:Item}, ${2:Model}State, {$4:Arg}> {",
+ " ${1:NotifierName}Notifier() : super();",
+ " ",
+ " @override",
+ " fetch(arg, offset, limit) async {",
+ " throw UnimplementedError();",
+ " }",
+ " ",
+ " @override",
+ " build(arg) async {",
+ " throw UnimplementedError();",
+ " }",
+ "}"
+ ]
+ },
+ "FamilyPaginaitedNotifierWithState": {
+ "scope": "dart",
+ "prefix": "familyPaginatedNotifierWithState",
+ "description": "Generate a FamilyPaginatedAsyncNotifier with PaginatedState",
+ "body": [
+ "class $1State extends PaginatedState<$2> {",
+ " $1State({",
+ " required super.items,",
+ " required super.offset,",
+ " required super.limit,",
+ " required super.hasMore,",
+ " });",
+ " ",
+ " @override",
+ " $1State copyWith({",
+ " List<$2>? items,",
+ " int? offset,",
+ " int? limit,",
+ " bool? hasMore,",
+ " }) {",
+ " return $1State(",
+ " items: items ?? this.items,",
+ " offset: offset ?? this.offset,",
+ " limit: limit ?? this.limit,",
+ " hasMore: hasMore ?? this.hasMore,",
+ " );",
+ " }",
+ "}",
+ " ",
+ "class $1Notifier",
+ " extends FamilyPaginatedAsyncNotifier<$2, $1State, $3> {",
+ " $1Notifier() : super();",
+ " ",
+ " @override",
+ " fetch(arg, offset, limit) async {",
+ " throw UnimplementedError();",
+ " }",
+ " ",
+ " @override",
+ " build(arg) async {",
+ " throw UnimplementedError();",
+ " }",
+ "}",
+ " ",
+ "final ${1/(.*)/${1:/camelcase}/}Provider = AsyncNotifierProviderFamily<$1Notifier, $1State, $3>(",
+ " ()=> $1Notifier(),",
+ ");"
+ ]
+ },
+}
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ddbd4fe1..21fb79d4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,59 @@
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.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
+
+* add Spotify homepage personalized recommendations ([#1402](https://github.com/krtirtho/spotube/issues/1402)) ([9e25c74](https://github.com/krtirtho/spotube/commit/9e25c742d4e43e4e10d2b48afb8e6d90288ffa11))
+* add user profile page ([39e97ee](https://github.com/krtirtho/spotube/commit/39e97eef34d87348a264843e145f31f82832d12e))
+* **android:** Filter Device To Force High Frame Rate ([#880](https://github.com/krtirtho/spotube/issues/880)) ([6e41b10](https://github.com/krtirtho/spotube/commit/6e41b106fa989adee393d3ce2535e75446ad3eea))
+* improved caching based on riverpod ([#1343](https://github.com/krtirtho/spotube/issues/1343)) ([6673e5a](https://github.com/krtirtho/spotube/commit/6673e5a8a86b9667cf9dbff9bb7c40ea6b7de771))
+* LAN connect a.k.a control remote Spotube playback and local output device selection ([#1355](https://github.com/krtirtho/spotube/issues/1355)) ([68374ef](https://github.com/krtirtho/spotube/commit/68374efd3ec556f31b937e5b96920787b54eec78))
+* **lyrics:** add LRCLIB lyrics provider as fallback ([5afe823](https://github.com/krtirtho/spotube/commit/5afe823abdb198340b55d138d8173d886a811632))
+* search history support [#1236](https://github.com/krtirtho/spotube/issues/1236) ([82b1cfa](https://github.com/krtirtho/spotube/commit/82b1cfa0d775e3958c666280943a893c9113d468))
+* **translations:** Add Czech translation ([#1401](https://github.com/krtirtho/spotube/issues/1401)) ([5a6b800](https://github.com/krtirtho/spotube/commit/5a6b80091259359bc38c4b91cd8cb496c4270fa4))
+* **translations:** add Thai Language ([#1319](https://github.com/krtirtho/spotube/issues/1319)) ([b70f250](https://github.com/krtirtho/spotube/commit/b70f250e8d5137fd990787ec9e3d058126cf14f3)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311)
+
+
+### Bug Fixes
+
+* instance of Artist bug [#1362](https://github.com/krtirtho/spotube/issues/1362) ([c8dd802](https://github.com/krtirtho/spotube/commit/c8dd8025ec96bd78ed77cae35f1429aa48c16fde))
+* **playback:** sponsor block skips and stutters in same position ([0d080b7](https://github.com/krtirtho/spotube/commit/0d080b77b72529c0be5ebc27ace1c52307511f73))
+
## [3.5.0](https://github.com/krtirtho/spotube/compare/v3.4.1...v3.5.0) (2024-03-08)
diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md
index 13996cea..e859f9e6 100644
--- a/CONTRIBUTION.md
+++ b/CONTRIBUTION.md
@@ -25,7 +25,7 @@ All types of contributions are encouraged and valued. See the [Table of Contents
- [Before Submitting an Enhancement](#before-submitting-an-enhancement)
- [How Do I Submit a Good Enhancement Suggestion?](#how-do-i-submit-a-good-enhancement-suggestion)
- [Your First Code Contribution](#your-first-code-contribution)
- - [Submit translations](#submit-translations)
+ - [Submit Translations](#submit-translations)
## Code of Conduct
@@ -123,16 +123,16 @@ Do the following:
- Install Development dependencies in linux
- Debian (>=12/Bookworm)/Ubuntu
```bash
- $ apt-get install mpv libmpv-dev libappindicator3-1 gir1.2-appindicator3-0.1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev
+ $ apt-get install mpv libmpv-dev libappindicator3-1 gir1.2-appindicator3-0.1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev avahi-daemon avahi-discover avahi-utils libnss-mdns mdns-scan
```
- Use `libjsoncpp1` instead of `libjsoncpp25` (for Ubuntu < 22.04)
- Arch/Manjaro
```bash
- yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify
+ yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify avahi nss-mdns mdns-scan
```
- Fedora
```bash
- dnf install mpv mpv-devel libappindicator-gtk3 libappindicator-gtk3-devel libsecret libsecret-devel jsoncpp jsoncpp-devel libnotify libnotify-devel
+ dnf install mpv mpv-devel libappindicator-gtk3 libappindicator-gtk3-devel libsecret libsecret-devel jsoncpp jsoncpp-devel libnotify libnotify-devel avahi mdns-scan nss-mdns
```
- Clone the Repo
- Create a `.env` in root of the project following the `.env.example` template
diff --git a/README.md b/README.md
index de00054f..5db4d5ad 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@ eliminating the need for Spotify Premium
Btw it's not just another Electron app 😉
-
+
@@ -97,12 +97,7 @@ This handy table lists all the methods you can use to install Spotube:
AppImage
-
-
-
-
- Note: AppimageLauncher is required!
-
+ AppImage's lacking stability led to it's temporal removal. More information at https://github.com/KRTirtho/spotube/issues/1082
Debian/Ubuntu
@@ -205,6 +200,7 @@ If you are concerned, you can [read the reason of choosing this license](https:/
1. [YouTube](https://youtube.com/) - YouTube is an American online video-sharing platform headquartered in San Bruno, California. Three former PayPal employees—Chad Hurley, Steve Chen, and Jawed Karim—created the service in February 2005
1. [JioSaavn](https://www.jiosaavn.com) - JioSaavn is an Indian online music streaming service and a digital distributor of Bollywood, English and other regional Indian music across the world. Since it was founded in 2007 as Saavn, the company has acquired rights to over 5 crore (50 million) music tracks in 15 languages
1. [SongLink](https://song.link) - SongLink is a free smart link service that helps you share music with your audience. It's a one-stop-shop for creating smart links for music, podcasts, and other audio content
+1. [LRCLib](https://lrclib.net/) - A public synced lyric API
1. [Linux](https://www.linux.org) - Linux is a family of open-source Unix-like operating systems based on the Linux kernel, an operating system kernel first released on September 17, 1991, by Linus Torvalds. Linux is typically packaged in a Linux distribution
1. [AUR](https://aur.archlinux.org) - AUR stands for Arch User Repository. It is a community-driven repository for Arch-based Linux distributions users
1. [Flatpak](https://flatpak.org) - Flatpak is a utility for software deployment and package management for Linux
@@ -214,109 +210,117 @@ If you are concerned, you can [read the reason of choosing this license](https:/
1. [LastFM](https://last.fm) - Last.fm is a music streaming and discovery platform that helps users discover and share new music. It tracks users' music listening habits across many devices and platforms.
### Dependencies
+1. [app_links](https://github.com/llfbandit/app_links) - Android App Links, Deep Links, iOs Universal Links and Custom URL schemes handler for Flutter (desktop included).
1. [args](https://pub.dev/packages/args) - Library for defining parsers for parsing raw command-line arguments into a set of options and values using GNU and POSIX style options.
1. [async](https://pub.dev/packages/async) - Utility functions and classes related to the 'dart:async' library.
+1. [audio_service_mpris](https://github.com/bdrazhzhov/audio-service-mpris) - audio_service platform interface supporting Media Player Remote Interfacing Specification.
1. [audio_service](https://pub.dev/packages/audio_service) - Flutter plugin to play audio in the background while the screen is off.
1. [audio_session](https://github.com/ryanheise/audio_session) - Sets the iOS audio session category and Android audio attributes for your app, and manages your app's audio focus, mixing and ducking behaviour.
1. [auto_size_text](https://github.com/leisim/auto_size_text) - Flutter widget that automatically resizes text to fit perfectly within its bounds.
+1. [bonsoir](https://bonsoir.skyost.eu) - A Zeroconf library that allows you to discover network services and to broadcast your own. Based on Apple Bonjour and Android NSD.
+1. [build_runner](https://pub.dev/packages/build_runner) - A build system for Dart code generation and modular compilation.
1. [buttons_tabbar](https://afonsoraposo.com) - A Flutter package that implements a TabBar where each label is a toggle button.
1. [cached_network_image](https://github.com/Baseflow/flutter_cached_network_image) - Flutter library to load and cache network images. Can also be used with placeholder and error widgets.
1. [catcher_2](https://github.com/ThexXTURBOXx/catcher_2) - Plugin for error catching which provides multiple handlers for dealing with errors when they are not caught by the developer.
1. [collection](https://pub.dev/packages/collection) - Collections and utilities functions and classes related to collections.
-1. [cupertino_icons](https://pub.dev/packages/cupertino_icons) - Default icons asset for Cupertino widgets based on Apple styled icons
+1. [crypto](https://pub.dev/packages/crypto) - Implementations of SHA, MD5, and HMAC cryptographic functions.
1. [curved_navigation_bar](https://github.com/rafalbednarczuk/curved_navigation_bar) - Stunning Animating Curved Shape Navigation Bar. Adjustable color, background color, animation curve, animation duration.
+1. [custom_lint](https://pub.dev/packages/custom_lint) - Lint rules are a powerful way to improve the maintainability of a project. Custom Lint allows package authors and developers to easily write custom lint rules.
+1. [dart_discord_rpc](https://github.com/alexmercerind/dart_discord_rpc) - Discord Rich Presence for Flutter & Dart apps & games.
1. [dbus](https://github.com/canonical/dbus.dart) - A native Dart implementation of the D-Bus message bus client. This package allows Dart applications to directly access services on the Linux desktop.
1. [device_info_plus](https://plus.fluttercommunity.dev/) - Flutter plugin providing detailed information about the device (make, model, etc.), and Android or iOS version the app is running on.
-1. [device_preview](https://github.com/aloisdeniel/flutter_device_preview) - Approximate how your Flutter app looks and performs on another device.
1. [dio](https://github.com/cfug/dio) - A powerful HTTP networking package,supports Interceptors,Aborting and canceling a request,Custom adapters, Transformers, etc.
1. [disable_battery_optimization](https://github.com/pvsvamsi/Disable-Battery-Optimizations) - Flutter plugin to check and disable battery optimizations. Also shows custom steps to disable the optimizations in devices like mi, xiaomi, samsung, oppo, huawei, oneplus etc
+1. [draggable_scrollbar](https://github.com/fluttercommunity/flutter-draggable-scrollbar) - A scrollbar that can be dragged for quickly navigation through a vertical list. Additional option is showing label next to scrollthumb with information about current item.
1. [duration](https://github.com/desktop-dart/duration) - Utilities to make working with 'Duration's easier. Formats duration in human readable form and also parses duration in human readable form to Dart's Duration.
+1. [envied_generator](https://github.com/petercinibulk/envied) - Generator for the Envied package. See https://pub.dev/packages/envied.
1. [envied](https://github.com/petercinibulk/envied) - Explicitly reads environment variables into a dart file from a .env file for more security and faster start up times.
+1. [file_picker](https://github.com/miguelpruivo/plugins_flutter_file_picker) - A package that allows you to use a native file explorer to pick single or multiple absolute file paths, with extension filtering support.
1. [file_selector](https://pub.dev/packages/file_selector) - Flutter plugin for opening and saving files, or selecting directories, using native file selection UI.
-1. [fl_query](https://fl-query.krtirtho.dev) - Asynchronous data caching, refetching & invalidation library for Flutter
-1. [fl_query_hooks](https://fl-query.krtirtho.dev) - Elite flutter_hooks compatible library for fl_query, the Asynchronous data caching, refetching & invalidation library for Flutter
-1. [fl_query_devtools](https://fl-query.krtirtho.dev) - Devtools support for Fl-Query
1. [fluentui_system_icons](https://github.com/microsoft/fluentui-system-icons/tree/main) - Fluent UI System Icons are a collection of familiar, friendly and modern icons from Microsoft.
+1. [flutter_broadcasts](https://pub.dev/packages/flutter_broadcasts) - A plugin for sending and receiving broadcasts with Android intents and iOS notifications.
1. [flutter_cache_manager](https://github.com/Baseflow/flutter_cache_manager/tree/develop/flutter_cache_manager) - Generic cache manager for flutter. Saves web files on the storages of the device and saves the cache info using sqflite.
1. [flutter_displaymode](https://github.com/ajinasokan/flutter_displaymode) - A Flutter plugin to set display mode (resolution, refresh rate) on Android platform. Allows to enable high refresh rate on supported devices.
1. [flutter_feather_icons](https://github.com/muj-programmer/flutter_feather_icons) - Feather is a collection of simply beautiful open source icons. Each icon is designed on a 24x24 grid with an emphasis on simplicity, consistency and usability.
+1. [flutter_gen_runner](https://github.com/FlutterGen/flutter_gen) - The Flutter code generator for your assets, fonts, colors, … — Get rid of all String-based APIs.
1. [flutter_hooks](https://github.com/rrousselGit/flutter_hooks) - A flutter implementation of React hooks. It adds a new kind of widget with enhanced code reuse.
1. [flutter_inappwebview](https://inappwebview.dev/) - A Flutter plugin that allows you to add an inline webview, to use an headless webview, and to open an in-app browser window.
+1. [flutter_launcher_icons](https://github.com/fluttercommunity/flutter_launcher_icons) - A package which simplifies the task of updating your Flutter app's launcher icon.
+1. [flutter_lints](https://pub.dev/packages/flutter_lints) - Recommended lints for Flutter apps, packages, and plugins to encourage good coding practices.
1. [flutter_native_splash](https://pub.dev/packages/flutter_native_splash) - Customize Flutter's default white native splash screen with background color and splash image. Supports dark mode, full screen, and more.
1. [flutter_riverpod](https://riverpod.dev) - A reactive caching and data-binding framework. Riverpod makes working with asynchronous code a breeze.
1. [flutter_secure_storage](https://pub.dev/packages/flutter_secure_storage) - Flutter Secure Storage provides API to store data in secure storage. Keychain is used in iOS, KeyStore based solution is used in Android.
+1. [flutter_sharing_intent](https://github.com/bhagat-techind/flutter_sharing_intent.git) - A flutter plugin that allow flutter apps to receive photos, videos, text, urls or any other file types from another app.
1. [flutter_svg](https://pub.dev/packages/flutter_svg) - An SVG rendering and widget library for Flutter, which allows painting and displaying Scalable Vector Graphics 1.1 files.
1. [form_validator](https://github.com/TheMisir/form-validator) - Simplest form validation library for flutter's form field widgets
+1. [freezed_annotation](https://pub.dev/packages/freezed_annotation) - Annotations for the freezed code-generator. This package does nothing without freezed too.
+1. [freezed](https://pub.dev/packages/freezed) - Code generation for immutable classes that has a simple syntax/API without compromising on the features.
1. [fuzzywuzzy](https://github.com/sphericalkat/dart-fuzzywuzzy) - An implementation of the popular fuzzywuzzy package in Dart, to suit all your fuzzy string matching/searching needs!
+1. [gap](https://github.com/letsar/gap) - Flutter widgets for easily adding gaps inside Flex widgets such as Columns and Rows or scrolling views.
1. [go_router](https://pub.dev/packages/go_router) - A declarative router for Flutter based on Navigation 2 supporting deep linking, data-driven routes and more
1. [google_fonts](https://pub.dev/packages/google_fonts) - A Flutter package to use fonts from fonts.google.com. Supports HTTP fetching, caching, and asset bundling.
-1. [hive](https://github.com/hivedb/hive/tree/master/hive) - Lightweight and blazing fast key-value database written in pure Dart. Strongly encrypted using AES-256.
1. [hive_flutter](https://github.com/hivedb/hive/tree/master/hive_flutter) - Extension for Hive. Makes it easier to use Hive in Flutter apps.
+1. [hive_generator](https://github.com/hivedb/hive/tree/master/hive_generator) - Extension for Hive. Automatically generates TypeAdapters to store any class.
+1. [hive](https://github.com/hivedb/hive/tree/master/hive) - Lightweight and blazing fast key-value database written in pure Dart. Strongly encrypted using AES-256.
1. [hooks_riverpod](https://riverpod.dev) - A reactive caching and data-binding framework. Riverpod makes working with asynchronous code a breeze.
+1. [html_unescape](https://github.com/filiph/html_unescape) - A small library for un-escaping HTML. Supports all Named Character References, Decimal Character References and Hexadecimal Character References.
1. [html](https://pub.dev/packages/html) - APIs for parsing and manipulating HTML content outside the browser.
1. [http](https://pub.dev/packages/http) - A composable, multi-platform, Future-based API for HTTP requests.
1. [image_picker](https://pub.dev/packages/image_picker) - Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera.
1. [intl](https://pub.dev/packages/intl) - Contains code to deal with internationalized/localized messages, date and number formatting and parsing, bi-directional text, and other internationalization issues.
-1. [introduction_screen](https://github.com/pyozer/introduction_screen) - Introduction/Onboarding package for flutter app with some customizations possibilities
+1. [introduction_screen](https://pub.dev/packages/introduction_screen) - Introduction/Onboarding package for flutter app with some customizations possibilities
+1. [io](https://pub.dev/packages/io) - Utilities for the Dart VM Runtime including support for ANSI colors, file copying, and standard exit code values.
+1. [jiosaavn](https://github.com/KRTirtho/jiosaavn) - Unofficial API client for jiosaavn.com
1. [json_annotation](https://pub.dev/packages/json_annotation) - Classes and helper functions that support JSON code generation via the `json_serializable` package.
+1. [json_serializable](https://pub.dev/packages/json_serializable) - Automatically generate code for converting to and from JSON by annotating Dart classes.
+1. [local_notifier](https://github.com/leanflutter/local_notifier) - This plugin allows Flutter desktop apps to displaying local notifications.
1. [logger](https://pub.dev/packages/logger) - Small, easy to use and extensible logger which prints beautiful logs.
-1. [media_kit](https://github.com/media-kit/media-kit) - A cross-platform video player & audio player for Flutter & Dart. Performant, stable, feature-proof & modular.
+1. [lrc](https://pub.dev/packages/lrc) - A Dart-only package that creates, parses, and handles LRC, which is a format that stores song lyrics.
1. [media_kit_libs_audio](https://github.com/media-kit/media-kit.git) - package:media_kit audio (only) playback native libraries for all platforms.
+1. [media_kit](https://github.com/media-kit/media-kit) - A cross-platform video player & audio player for Flutter & Dart. Performant, stable, feature-proof & modular.
1. [metadata_god](https://github.com/KRTirtho/metadata_god) - Plugin for retrieving and writing audio tags/metadata from audio files
1. [mime](https://pub.dev/packages/mime) - Utilities for handling media (MIME) types, including determining a type from a file extension and file contents.
1. [package_info_plus](https://plus.fluttercommunity.dev/) - Flutter plugin for querying information about the application package, such as CFBundleVersion on iOS or versionCode on Android.
1. [palette_generator](https://pub.dev/packages/palette_generator) - Flutter package for generating palette colors from a source image.
-1. [path](https://pub.dev/packages/path) - A string-based path manipulation library. All of the path operations you know and love, with solid support for Windows, POSIX (Linux and Mac OS X), and the web.
1. [path_provider](https://pub.dev/packages/path_provider) - Flutter plugin for getting commonly used locations on host platform file systems, such as the temp and app data directories.
+1. [path](https://pub.dev/packages/path) - A string-based path manipulation library. All of the path operations you know and love, with solid support for Windows, POSIX (Linux and Mac OS X), and the web.
1. [permission_handler](https://pub.dev/packages/permission_handler) - Permission plugin for Flutter. This plugin provides a cross-platform (iOS, Android) API to request and check permissions.
+1. [piped_client](https://github.com/KRTirtho/piped_client) - API Client for piped.video
1. [popover](https://github.com/minikin/popover) - A popover is a transient view that appears above other content onscreen when you tap a control or in an area.
+1. [process_run](https://github.com/tekartik/process_run.dart/blob/master/packages/process_run) - Process run helpers for Linux/Win/Mac and which like feature for finding executables.
+1. [pub_api_client](https://github.com/leoafarias/pub_api_client) - An API Client for Pub to interact with public package information.
+1. [pubspec_parse](https://pub.dev/packages/pubspec_parse) - Simple package for parsing pubspec.yaml files with a type-safe API and rich error reporting.
+1. [riverpod_lint](https://riverpod.dev) - Riverpod_lint is a developer tool for users of Riverpod, designed to help stop common issues and simplify repetitive tasks.
+1. [scrobblenaut](https://github.com/Nebulino/Scrobblenaut) - A deadly simple LastFM API Wrapper for Dart. So deadly simple that it's gonna hit the mark.
1. [scroll_to_index](https://github.com/quire-io/scroll-to-index) - Scroll to a specific child of any scrollable widget in Flutter
-1. [sidebarx](https://github.com/Frezyx/sidebarx) - flutter multiplatform navigation sidebar / side navigationbar / drawer widget
1. [shared_preferences](https://pub.dev/packages/shared_preferences) - Flutter plugin for reading and writing simple key-value pairs. Wraps NSUserDefaults on iOS and SharedPreferences on Android.
+1. [shelf_router](https://pub.dev/packages/shelf_router) - A convenient request router for the shelf web-framework, with support for URL-parameters, nested routers and routers generated from source annotations.
+1. [shelf_web_socket](https://pub.dev/packages/shelf_web_socket) - A shelf handler that wires up a listener for every connection.
+1. [shelf](https://pub.dev/packages/shelf) - A model for web server middleware that encourages composition and easy reuse.
+1. [sidebarx](https://github.com/Frezyx/sidebarx) - flutter multiplatform navigation sidebar / side navigationbar / drawer widget
+1. [simple_icons](https://teavelopment.com/) - The Simple Icon pack available as Flutter Icons. Provides over 1500 Free SVG icons for popular brands.
1. [skeleton_text](https://github.com/101Loop/Skeleton-Text) - A package that provides an easy way to add skeleton text loading animation in Flutter project. This project is a part of 101Loop community.
+1. [skeletonizer](https://github.com/Milad-Akarie/skeletonizer) - Converts already built widgets into skeleton loaders with no extra effort.
+1. [sliver_tools](https://github.com/Kavantix) - A set of useful sliver tools that are missing from the flutter framework
1. [smtc_windows](https://github.com/KRTirtho/smtc_windows) - Windows `SystemMediaTransportControls` implementation for Flutter giving access to Windows OS Media Control applet.
+1. [spotify](https://github.com/rinukkusu/spotify-dart) - An incomplete dart library for interfacing with the Spotify Web API.
1. [stroke_text](https://github.com/MohamedAbd0/stroke_text) - A Simple Flutter plugin for applying stroke (border) style to a text widget
1. [system_theme](https://pub.dev/packages/system_theme) - A plugin to get the current system theme info. Supports Android, Web, Windows, Linux and macOS
+1. [timezone](https://pub.dev/packages/timezone) - Time zone database and time zone aware DateTime.
1. [titlebar_buttons](https://github.com/gtk-flutter/titlebar_buttons) - A package which provides most of the titlebar buttons from windows, linux and macos.
+1. [tray_manager](https://github.com/leanflutter/tray_manager) - This plugin allows Flutter desktop apps to defines system tray.
1. [url_launcher](https://pub.dev/packages/url_launcher) - Flutter plugin for launching a URL. Supports web, phone, SMS, and email schemes.
1. [uuid](https://pub.dev/packages/uuid) - RFC4122 (v1, v4, v5, v6, v7, v8) UUID Generator and Parser for Dart
1. [version](https://github.com/dartninja/version) - Provides a simple class for parsing and comparing semantic versions as defined by http://semver.org/
-1. [visibility_detector](https://pub.dev/packages/visibility_detector) - A widget that detects the visibility of its child and notifies a callback.
-1. [window_manager](https://github.com/leanflutter/window_manager) - This plugin allows Flutter desktop apps to resizing and repositioning the window.
-1. [youtube_explode_dart](https://github.com/Hexer10/youtube_explode_dart) - A port in dart of the youtube explode library. Supports several API functions without the need of Youtube API Key.
-1. [simple_icons](https://teavelopment.com/) - The Simple Icon pack available as Flutter Icons. Provides over 1500 Free SVG icons for popular brands.
-1. [audio_service_mpris](https://github.com/bdrazhzhov/audio-service-mpris) - audio_service platform interface supporting Media Player Remote Interfacing Specification.
-1. [file_picker](https://github.com/miguelpruivo/plugins_flutter_file_picker) - A package that allows you to use a native file explorer to pick single or multiple absolute file paths, with extension filtering support.
-1. [jiosaavn](https://github.com/KRTirtho/jiosaavn) - Unofficial API client for jiosaavn.com
1. [very_good_infinite_list](https://github.com/VeryGoodOpenSource/very_good_infinite_list) - A library for easily displaying paginated data, created by Very Good Ventures. Great for activity feeds, news feeds, and more.
-1. [gap](https://github.com/letsar/gap) - Flutter widgets for easily adding gaps inside Flex widgets such as Columns and Rows or scrolling views.
-1. [sliver_tools](https://github.com/Kavantix) - A set of useful sliver tools that are missing from the flutter framework
-1. [html_unescape](https://github.com/filiph/html_unescape) - A small library for un-escaping HTML. Supports all Named Character References, Decimal Character References and Hexadecimal Character References.
+1. [visibility_detector](https://pub.dev/packages/visibility_detector) - A widget that detects the visibility of its child and notifies a callback.
+1. [web_socket_channel](https://pub.dev/packages/web_socket_channel) - StreamChannel wrappers for WebSockets. Provides a cross-platform WebSocketChannel API, a cross-platform implementation of that API that communicates over an underlying StreamChannel.
1. [wikipedia_api](https://github.com/KRTirtho/wikipedia_api) - Wikipedia API for dart and flutter
-1. [skeletonizer](https://github.com/Milad-Akarie/skeletonizer) - Converts already built widgets into skeleton loaders with no extra effort.
-1. [app_links](https://github.com/llfbandit/app_links) - Android App Links, Deep Links, iOs Universal Links and Custom URL schemes handler for Flutter (desktop included).
-1. [win32_registry](https://win32.pub) - A package that provides a friendly Dart API for accessing the Windows Registry.
-1. [flutter_sharing_intent](https://github.com/bhagat-techind/flutter_sharing_intent.git) - A flutter plugin that allow flutter apps to receive photos, videos, text, urls or any other file types from another app.
-1. [flutter_broadcasts](https://pub.dev/packages/flutter_broadcasts) - A plugin for sending and receiving broadcasts with Android intents and iOS notifications.
-1. [freezed_annotation](https://pub.dev/packages/freezed_annotation) - Annotations for the freezed code-generator. This package does nothing without freezed too.
-1. [spotify](https://github.com/rinukkusu/spotify-dart) - An incomplete dart library for interfacing with the Spotify Web API.
-1. [build_runner](https://pub.dev/packages/build_runner) - A build system for Dart code generation and modular compilation.
-1. [envied_generator](https://github.com/petercinibulk/envied) - Generator for the Envied package. See https://pub.dev/packages/envied.
-1. [flutter_distributor](https://distributor.leanflutter.org) - A complete tool for packaging and publishing your Flutter apps.
-1. [flutter_gen_runner](https://github.com/FlutterGen/flutter_gen) - The Flutter code generator for your assets, fonts, colors, … — Get rid of all String-based APIs.
-1. [flutter_launcher_icons](https://github.com/fluttercommunity/flutter_launcher_icons) - A package which simplifies the task of updating your Flutter app's launcher icon.
-1. [flutter_lints](https://pub.dev/packages/flutter_lints) - Recommended lints for Flutter apps, packages, and plugins to encourage good coding practices.
-1. [hive_generator](https://github.com/hivedb/hive/tree/master/hive_generator) - Extension for Hive. Automatically generates TypeAdapters to store any class.
-1. [json_serializable](https://pub.dev/packages/json_serializable) - Automatically generate code for converting to and from JSON by annotating Dart classes.
-1. [pub_api_client](https://github.com/leoafarias/pub_api_client) - An API Client for Pub to interact with public package information.
-1. [pubspec_parse](https://pub.dev/packages/pubspec_parse) - Simple package for parsing pubspec.yaml files with a type-safe API and rich error reporting.
-1. [freezed](https://pub.dev/packages/freezed) - Code generation for immutable classes that has a simple syntax/API without compromising on the features.
-1. [flutter_desktop_tools](https://github.com/KRTirtho/flutter_desktop_tools) - Essential collection of tools for flutter desktop app development
-1. [piped_client](https://github.com/KRTirtho/piped_client) - API Client for piped.video
-1. [scrobblenaut](https://github.com/Nebulino/Scrobblenaut) - A deadly simple LastFM API Wrapper for Dart. So deadly simple that it's gonna hit the mark.
+1. [win32_registry](https://pub.dev/packages/win32_registry) - A package that provides a friendly Dart API for accessing the Windows Registry.
+1. [window_manager](https://github.com/leanflutter/window_manager) - This plugin allows Flutter desktop apps to resizing and repositioning the window.
1. [window_size](https://github.com/google/flutter-desktop-embedding.git) - Allows resizing and repositioning the window containing Flutter.
-1. [draggable_scrollbar](https://github.com/fluttercommunity/flutter-draggable-scrollbar) - A scrollbar that can be dragged for quickly navigation through a vertical list. Additional option is showing label next to scrollthumb with information about current item.
-1. [dart_discord_rpc](https://github.com/alexmercerind/dart_discord_rpc) - Discord Rich Presence for Flutter & Dart apps & games.
+1. [xml](https://github.com/renggli/dart-xml) - A lightweight library for parsing, traversing, querying, transforming and building XML documents.
+1. [youtube_explode_dart](https://github.com/Hexer10/youtube_explode_dart) - A port in dart of the youtube explode library. Supports several API functions without the need of Youtube API Key.
© Copyright Spotube 2024
diff --git a/analysis_options.yaml b/analysis_options.yaml
index 5f2cbbe1..d5b904cc 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -25,12 +25,17 @@ linter:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
file_names: false
+ avoid_renaming_method_parameters: false
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
analyzer:
- enable-experiment:
- - records
- - patterns
errors:
invalid_annotation_target: ignore
+ plugins:
+ - custom_lint
+ exclude:
+ - "**.freezed.dart"
+ - "**.g.dart"
+ - "**.gr.dart"
+ - "**/generated_plugin_registrant.dart"
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 2f85cdeb..7bcd9b6a 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -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
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 5ab7a0b5..589e22ff 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -24,6 +24,11 @@
android:usesCleartextTraffic="true"
android:requestLegacyExternalStorage="true"
>
+
+
+
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"
\ No newline at end of file
diff --git a/bin/gen-credits.dart b/bin/gen-credits.dart
deleted file mode 100644
index f8975335..00000000
--- a/bin/gen-credits.dart
+++ /dev/null
@@ -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>(
- 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);
-}
diff --git a/bin/translated_messages.dart b/bin/translated_messages.dart
deleted file mode 100644
index 0de398df..00000000
--- a/bin/translated_messages.dart
+++ /dev/null
@@ -1,26 +0,0 @@
-import 'dart:convert';
-import 'dart:io';
-
-void main(List args) async {
- final translatedFile =
- jsonDecode(await File('tm.json').readAsString()) as Map;
-
- 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;
-
- final newContent = {
- ...fileContent,
- ...value,
- };
-
- await file.writeAsString(
- const JsonEncoder.withIndent(' ').convert(newContent),
- );
-
- print('✅ Updated locale: $key');
- }
-}
diff --git a/bin/untranslated_messages.dart b/bin/untranslated_messages.dart
deleted file mode 100644
index e19f9a07..00000000
--- a/bin/untranslated_messages.dart
+++ /dev/null
@@ -1,49 +0,0 @@
-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 args) {
- final file = jsonDecode(
- File('untranslated_messages.json').readAsStringSync(),
- ) as Map;
-
- final englishMessages =
- jsonDecode(File('lib/l10n/app_en.arb').readAsStringSync())
- as Map;
-
- final messagesWithValues = {};
-
- for (final MapEntry(key: locale, value: messages) in file.entries) {
- messagesWithValues[locale] = Map.fromEntries(
- messages
- .map(
- (message) =>
- MapEntry(message, englishMessages[message]),
- )
- .toList()
- .cast>(),
- );
- }
-
- 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.",
- );
- // ignore: avoid_print
- print(
- const JsonEncoder.withIndent(' ').convert(
- args.isNotEmpty ? messagesWithValues[args.first] : messagesWithValues,
- ),
- );
-}
diff --git a/bin/verify-pkgbuild.dart b/bin/verify-pkgbuild.dart
deleted file mode 100644
index 587e63d0..00000000
--- a/bin/verify-pkgbuild.dart
+++ /dev/null
@@ -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");
- }
- });
-}
diff --git a/build.yaml b/build.yaml
index f074d6e1..d83d6a20 100644
--- a/build.yaml
+++ b/build.yaml
@@ -2,4 +2,9 @@ targets:
$default:
sources:
exclude:
- - bin/*.dart
\ No newline at end of file
+ - bin/*.dart
+ builders:
+ json_serializable:
+ options:
+ any_map: true
+ explicit_to_json: true
diff --git a/cli/README.md b/cli/README.md
new file mode 100644
index 00000000..b2ba8ebd
--- /dev/null
+++ b/cli/README.md
@@ -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
diff --git a/cli/cli.dart b/cli/cli.dart
new file mode 100644
index 00000000..26190d4c
--- /dev/null
+++ b/cli/cli.dart
@@ -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 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);
+}
diff --git a/cli/commands/build.dart b/cli/commands/build.dart
new file mode 100644
index 00000000..fdf35a95
--- /dev/null
+++ b/cli/commands/build.dart
@@ -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());
+ }
+}
diff --git a/cli/commands/build/android.dart b/cli/commands/build/android.dart
new file mode 100644
index 00000000..800522b8
--- /dev/null
+++ b/cli/commands/build/android.dart
@@ -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");
+ }
+}
diff --git a/cli/commands/build/common.dart b/cli/commands/build/common.dart
new file mode 100644
index 00000000..4c7e3e51
--- /dev/null
+++ b/cli/commands/build/common.dart
@@ -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 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
+ """,
+ );
+ }
+}
diff --git a/cli/commands/build/ios.dart b/cli/commands/build/ios.dart
new file mode 100644
index 00000000..6460f9ed
--- /dev/null
+++ b/cli/commands/build/ios.dart
@@ -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")}
+ """,
+ );
+ }
+}
diff --git a/cli/commands/build/linux.dart b/cli/commands/build/linux.dart
new file mode 100644
index 00000000..a218720c
--- /dev/null
+++ b/cli/commands/build/linux.dart
@@ -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,
+ ' ',
+ ),
+ );
+
+ 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");
+ }
+}
diff --git a/cli/commands/build/linux_arm.dart b/cli/commands/build/linux_arm.dart
new file mode 100644
index 00000000..a09f0980
--- /dev/null
+++ b/cli/commands/build/linux_arm.dart
@@ -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/
+ """,
+ );
+ }
+}
diff --git a/cli/commands/build/macos.dart b/cli/commands/build/macos.dart
new file mode 100644
index 00000000..e8f34b77
--- /dev/null
+++ b/cli/commands/build/macos.dart
@@ -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();
+ }
+}
diff --git a/cli/commands/build/windows.dart b/cli/commands/build/windows.dart
new file mode 100644
index 00000000..15e0bf17
--- /dev/null
+++ b/cli/commands/build/windows.dart
@@ -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 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");
+ }
+}
diff --git a/cli/commands/credits.dart b/cli/commands/credits.dart
new file mode 100644
index 00000000..6bad7a44
--- /dev/null
+++ b/cli/commands/credits.dart
@@ -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>(
+ 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'),
+ );
+ }
+}
diff --git a/cli/commands/install-dependencies.dart b/cli/commands/install-dependencies.dart
new file mode 100644
index 00000000..75df28df
--- /dev/null
+++ b/cli/commands/install-dependencies.dart
@@ -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;
+ }
+ }
+}
diff --git a/cli/commands/translated.dart b/cli/commands/translated.dart
new file mode 100644
index 00000000..43c4ea49
--- /dev/null
+++ b/cli/commands/translated.dart
@@ -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;
+
+ 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;
+
+ final newContent = {...fileContent, ...value};
+
+ await file.writeAsString(
+ const JsonEncoder.withIndent(' ').convert(newContent),
+ );
+
+ stdout.writeln('✅ Updated locale: $key');
+ }
+ }
+}
diff --git a/cli/commands/untranslated.dart b/cli/commands/untranslated.dart
new file mode 100644
index 00000000..dadcd8b5
--- /dev/null
+++ b/cli/commands/untranslated.dart
@@ -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;
+
+ final englishMessages = jsonDecode(
+ File(join(cwd.path, 'lib', 'l10n', 'app_en.arb')).readAsStringSync(),
+ ) as Map;
+
+ final messagesWithValues = {};
+
+ for (final MapEntry(key: locale, value: messages) in file.entries) {
+ messagesWithValues[locale] = Map.fromEntries(
+ messages
+ .map(
+ (message) =>
+ MapEntry(message, englishMessages[message]),
+ )
+ .toList()
+ .cast>(),
+ );
+ }
+
+ 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),
+ );
+ }
+}
diff --git a/cli/core/env.dart b/cli/core/env.dart
new file mode 100644
index 00000000..33cc5df1
--- /dev/null
+++ b/cli/core/env.dart
@@ -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"]!;
+}
diff --git a/devtools_options.yaml b/devtools_options.yaml
new file mode 100644
index 00000000..7e7e7f67
--- /dev/null
+++ b/devtools_options.yaml
@@ -0,0 +1 @@
+extensions:
diff --git a/ios/Podfile b/ios/Podfile
index bc3dcaa6..7235f482 100644
--- a/ios/Podfile
+++ b/ios/Podfile
@@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
-# platform :ios, '12.0'
+platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 0b75217f..f8533902 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -5,6 +5,9 @@ PODS:
- Flutter
- audio_session (0.0.1):
- Flutter
+ - bonsoir_darwin (0.0.1):
+ - Flutter
+ - FlutterMacOS
- device_info_plus (0.0.1):
- Flutter
- DKImagePickerController/Core (4.3.4):
@@ -44,11 +47,13 @@ PODS:
- file_selector_ios (0.0.1):
- Flutter
- Flutter (1.0.0)
- - flutter_inappwebview (0.0.1):
+ - flutter_broadcasts (0.0.1):
- Flutter
- - flutter_inappwebview/Core (= 0.0.1)
+ - flutter_inappwebview_ios (0.0.1):
+ - Flutter
+ - flutter_inappwebview_ios/Core (= 0.0.1)
- OrderedSet (~> 5.0)
- - flutter_inappwebview/Core (0.0.1):
+ - flutter_inappwebview_ios/Core (0.0.1):
- Flutter
- OrderedSet (~> 5.0)
- flutter_keyboard_visibility (0.0.1):
@@ -64,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):
@@ -82,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)
@@ -92,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):
@@ -102,11 +104,13 @@ DEPENDENCIES:
- app_links (from `.symlinks/plugins/app_links/ios`)
- audio_service (from `.symlinks/plugins/audio_service/ios`)
- audio_session (from `.symlinks/plugins/audio_session/ios`)
+ - bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`)
- Flutter (from `Flutter`)
- - flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`)
+ - flutter_broadcasts (from `.symlinks/plugins/flutter_broadcasts/ios`)
+ - flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`)
- flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`)
- flutter_mailer (from `.symlinks/plugins/flutter_mailer/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
@@ -122,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
@@ -142,6 +145,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/audio_service/ios"
audio_session:
:path: ".symlinks/plugins/audio_session/ios"
+ bonsoir_darwin:
+ :path: ".symlinks/plugins/bonsoir_darwin/darwin"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
file_picker:
@@ -150,8 +155,10 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/file_selector_ios/ios"
Flutter:
:path: Flutter
- flutter_inappwebview:
- :path: ".symlinks/plugins/flutter_inappwebview/ios"
+ flutter_broadcasts:
+ :path: ".symlinks/plugins/flutter_broadcasts/ios"
+ flutter_inappwebview_ios:
+ :path: ".symlinks/plugins/flutter_inappwebview_ios/ios"
flutter_keyboard_visibility:
:path: ".symlinks/plugins/flutter_keyboard_visibility/ios"
flutter_mailer:
@@ -183,44 +190,45 @@ 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
- device_info_plus: 7545d84d8d1b896cb16a4ff98c19f07ec4b298ea
+ audio_session: 088d2483ebd1dc43f51d253d4a1c517d9a2e7207
+ bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842
+ 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_inappwebview: acd4fc0f012cefd09015000c241137d82f01ba62
+ 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: 5129d2e80ab0dfc533f262cedf032011b1dfe4fd
+PODFILE CHECKSUM: 0659b64ac6e9e96b61d8550decffa8bff51a957e
COCOAPODS: 1.15.2
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index 13f624a4..34793f68 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -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;
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 8e103cfa..ffd511a4 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -66,5 +66,11 @@
UIViewControllerBasedStatusBarAppearance
+ NSLocalNetworkUsageDescription
+ To allow other devices on the network control playback of Spotube securely.
+ NSBonjourServices
+
+ _spotube._tcp
+
\ No newline at end of file
diff --git a/lib/collections/assets.gen.dart b/lib/collections/assets.gen.dart
index 8a2950fb..2a30260b 100644
--- a/lib/collections/assets.gen.dart
+++ b/lib/collections/assets.gen.dart
@@ -88,7 +88,7 @@ class Assets {
AssetGenImage('assets/user-placeholder.png');
/// List of all assets
- List get values => [
+ static List get values => [
albumPlaceholder,
bengaliPatternsBg,
branding,
diff --git a/lib/collections/env.dart b/lib/collections/env.dart
index 50fe1e6a..df45cee9 100644
--- a/lib/collections/env.dart
+++ b/lib/collections/env.dart
@@ -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";
-}
+}
\ No newline at end of file
diff --git a/lib/collections/fake.dart b/lib/collections/fake.dart
index 8f5f9e8b..7391d3a0 100644
--- a/lib/collections/fake.dart
+++ b/lib/collections/fake.dart
@@ -1,12 +1,12 @@
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';
abstract class FakeData {
static final Image image = Image()
..height = 1
..width = 1
- ..url = "url";
+ ..url = "https://dummyimage.com/100x100/cfcfcf/cfcfcf.jpg";
static final Followers followers = Followers()
..href = "text"
@@ -196,4 +196,30 @@ abstract class FakeData {
),
],
);
+
+ static final feedSection = SpotifyHomeFeedSection(
+ typename: "HomeGenericSectionData",
+ uri: "spotify:section:lol",
+ title: "Dummy",
+ items: [
+ for (int i = 0; i < 10; i++)
+ SpotifyHomeFeedSectionItem(
+ typename: "PlaylistResponseWrapper",
+ playlist: SpotifySectionPlaylist(
+ name: "Playlist $i",
+ description: "Really super important description $i",
+ format: "daily-mix",
+ images: [
+ const SpotifySectionItemImage(
+ height: 1,
+ width: 1,
+ url: "https://dummyimage.com/100x100/cfcfcf/cfcfcf.jpg",
+ ),
+ ],
+ owner: "Spotify",
+ uri: "spotify:playlist:id",
+ ),
+ )
+ ],
+ );
}
diff --git a/lib/collections/formatters.dart b/lib/collections/formatters.dart
new file mode 100644
index 00000000..0aed9e9f
--- /dev/null
+++ b/lib/collections/formatters.dart
@@ -0,0 +1,8 @@
+import 'package:intl/intl.dart';
+
+final compactNumberFormatter = NumberFormat.compact();
+final usdFormatter = NumberFormat.compactCurrency(
+ locale: 'en-US',
+ symbol: r"$",
+ decimalDigits: 2,
+);
diff --git a/lib/collections/initializers.dart b/lib/collections/initializers.dart
index 9627de1c..976661fc 100644
--- a/lib/collections/initializers.dart
+++ b/lib/collections/initializers.dart
@@ -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 registerWindowsScheme(String scheme) async {
- if (!DesktopTools.platform.isWindows) return;
+ if (!kIsWindows) return;
String appPath = Platform.resolvedExecutable;
String protocolRegKey = 'Software\\Classes\\$scheme';
diff --git a/lib/collections/intents.dart b/lib/collections/intents.dart
index 6f42113c..579aff18 100644
--- a/lib/collections/intents.dart
+++ b/lib/collections/intents.dart
@@ -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 {
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;
@@ -92,7 +96,7 @@ class SeekIntent extends Intent {
class SeekAction extends Action {
@override
invoke(intent) async {
- final playlist = intent.ref.read(ProxyPlaylistNotifier.provider);
+ final playlist = intent.ref.read(proxyPlaylistProvider);
if (playlist.isFetching) {
DirectionalFocusAction().invoke(
DirectionalFocusIntent(
diff --git a/lib/collections/language_codes.dart b/lib/collections/language_codes.dart
index 4b7a3a90..f46e0efe 100644
--- a/lib/collections/language_codes.dart
+++ b/lib/collections/language_codes.dart
@@ -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: "Беларуская",
@@ -157,10 +157,10 @@ abstract class LanguageLocals {
// name: "Croatian",
// nativeName: "hrvatski",
// ),
- // "cs": const ISOLanguageName(
- // name: "Czech",
- // nativeName: "česky, čeština",
- // ),
+ "cs": const ISOLanguageName(
+ name: "Czech",
+ nativeName: "česky, čeština",
+ ),
// "da": const ISOLanguageName(
// name: "Danish",
// nativeName: "dansk",
@@ -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",
@@ -637,10 +637,10 @@ abstract class LanguageLocals {
// name: "Tajik",
// nativeName: "тоҷикӣ, toğikī, تاجیکی",
// ),
- // "th": const ISOLanguageName(
- // name: "Thai",
- // nativeName: "ไทย",
- // ),
+ "th": const ISOLanguageName(
+ name: "Thai",
+ nativeName: "ไทย",
+ ),
// "ti": const ISOLanguageName(
// name: "Tigrinya",
// nativeName: "ትግርኛ",
diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart
index 43d0cf2e..dc2e4b7c 100644
--- a/lib/collections/routes.dart
+++ b/lib/collections/routes.dart
@@ -4,21 +4,34 @@ import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart' hide Search;
+import 'package:spotube/models/spotify/recommendation_seeds.dart';
import 'package:spotube/pages/album/album.dart';
+import 'package:spotube/pages/connect/connect.dart';
+import 'package:spotube/pages/connect/control/control.dart';
import 'package:spotube/pages/getting_started/getting_started.dart';
+import 'package:spotube/pages/home/feed/feed_section.dart';
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';
import 'package:spotube/pages/playlist/liked_playlist.dart';
import 'package:spotube/pages/playlist/playlist.dart';
+import 'package:spotube/pages/profile/profile.dart';
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';
@@ -45,9 +58,9 @@ final routerProvider = Provider((ref) {
routes: [
GoRoute(
path: "/",
+ name: HomePage.name,
redirect: (context, state) async {
- final authNotifier =
- ref.read(AuthenticationNotifier.provider.notifier);
+ final authNotifier = ref.read(authenticationProvider.notifier);
final json = await authNotifier.box.get(authNotifier.cacheKey);
if (json?["cookie"] == null &&
@@ -62,61 +75,88 @@ 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,
),
),
),
+ GoRoute(
+ path: "feeds/:feedId",
+ name: HomeFeedSectionPage.name,
+ pageBuilder: (context, state) => SpotubePage(
+ child: HomeFeedSectionPage(
+ sectionUri: state.pathParameters["feedId"] as String,
+ ),
+ ),
+ )
],
),
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 PlaylistGenerateResultRouteState,
- ),
+ 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(),
),
@@ -124,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(),
),
@@ -138,6 +180,7 @@ final routerProvider = Provider((ref) {
),
GoRoute(
path: "/album/:id",
+ name: AlbumPage.name,
pageBuilder: (context, state) {
assert(state.extra is AlbumSimple);
return SpotubePage(
@@ -147,6 +190,7 @@ final routerProvider = Provider((ref) {
),
GoRoute(
path: "/artist/:id",
+ name: ArtistPage.name,
pageBuilder: (context, state) {
assert(state.pathParameters["id"] != null);
return SpotubePage(
@@ -155,6 +199,7 @@ final routerProvider = Provider((ref) {
),
GoRoute(
path: "/playlist/:id",
+ name: PlaylistPage.name,
pageBuilder: (context, state) {
assert(state.extra is PlaylistSimple);
return SpotubePage(
@@ -166,6 +211,7 @@ final routerProvider = Provider((ref) {
),
GoRoute(
path: "/track/:id",
+ name: TrackPage.name,
pageBuilder: (context, state) {
final id = state.pathParameters["id"]!;
return SpotubePage(
@@ -173,10 +219,86 @@ 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(),
+ );
+ },
+ )
+ ],
+ ),
+ 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),
@@ -184,6 +306,7 @@ final routerProvider = Provider((ref) {
),
GoRoute(
path: "/getting-started",
+ name: GettingStarting.name,
parentNavigatorKey: rootNavigatorKey,
pageBuilder: (context, state) => const SpotubePage(
child: GettingStarting(),
@@ -191,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(),
@@ -198,6 +322,7 @@ final routerProvider = Provider((ref) {
),
GoRoute(
path: "/login-tutorial",
+ name: LoginTutorial.name,
parentNavigatorKey: rootNavigatorKey,
pageBuilder: (context, state) => const SpotubePage(
child: LoginTutorial(),
@@ -205,6 +330,7 @@ final routerProvider = Provider((ref) {
),
GoRoute(
path: "/lastfm-login",
+ name: LastFMLoginPage.name,
parentNavigatorKey: rootNavigatorKey,
pageBuilder: (context, state) =>
const SpotubePage(child: LastFMLoginPage()),
diff --git a/lib/collections/side_bar_tiles.dart b/lib/collections/side_bar_tiles.dart
index 551d70d7..4f23c049 100644
--- a/lib/collections/side_bar_tiles.dart
+++ b/lib/collections/side_bar_tiles.dart
@@ -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 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 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 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,
+ ),
];
diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart
index 6cf92085..a45e581e 100644
--- a/lib/collections/spotube_icons.dart
+++ b/lib/collections/spotube_icons.dart
@@ -115,4 +115,13 @@ abstract class SpotubeIcons {
static const github = SimpleIcons.github;
static const openCollective = SimpleIcons.opencollective;
static const anonymous = FeatherIcons.user;
+ static const history = FeatherIcons.clock;
+ static const connect = FeatherIcons.link;
+ static const speaker = FeatherIcons.speaker;
+ 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;
}
diff --git a/lib/components/album/album_card.dart b/lib/components/album/album_card.dart
index 4d2e12d6..7212a574 100644
--- a/lib/components/album/album_card.dart
+++ b/lib/components/album/album_card.dart
@@ -1,17 +1,21 @@
-import 'package:fl_query_hooks/fl_query_hooks.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
+import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
import 'package:spotube/components/shared/playbutton_card.dart';
+import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/context.dart';
-import 'package:spotube/extensions/infinite_query.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_provider.dart';
+import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
-import 'package:spotube/services/queries/album.dart';
import 'package:spotube/utils/service_utils.dart';
-import 'package:spotube/utils/type_conversion_utils.dart';
extension FormattedAlbumType on AlbumType {
String get formatted => name.replaceFirst(name[0], name[0].toUpperCase());
@@ -26,12 +30,11 @@ class AlbumCard extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
- final playlist = ref.watch(ProxyPlaylistNotifier.provider);
+ final playlist = ref.watch(proxyPlaylistProvider);
final playing =
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
- final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
-
- final queryClient = useQueryClient();
+ final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
+ final historyNotifier = ref.read(playbackHistoryProvider.notifier);
bool isPlaylistPlaying = useMemoized(
() => playlist.containsCollection(album.id!),
@@ -39,39 +42,19 @@ class AlbumCard extends HookConsumerWidget {
);
final updating = useState(false);
- final spotify = ref.watch(spotifyProvider);
final scaffoldMessenger = ScaffoldMessenger.maybeOf(context);
Future> fetchAllTrack() async {
if (album.tracks != null && album.tracks!.isNotEmpty) {
- return album.tracks!
- .map((track) =>
- TypeConversionUtils.simpleTrack_X_Track(track, album))
- .toList();
+ return album.tracks!.map((track) => track.asTrack(album)).toList();
}
- final job = AlbumQueries.tracksOfJob(album.id!);
-
- final query = queryClient.createInfiniteQuery(
- job.queryKey,
- (page) => job.task(page, (spotify: spotify, album: album)),
- initialPage: 0,
- nextPage: job.nextPage,
- );
-
- return await query.fetchAllTracks(
- getAllTracks: () async {
- final res = await spotify.albums.tracks(album.id!).all();
- return res
- .map((e) => TypeConversionUtils.simpleTrack_X_Track(e, album))
- .toList();
- },
- );
+ await ref.read(albumTracksProvider(album).future);
+ return ref.read(albumTracksProvider(album).notifier).fetchAll();
}
return PlaybuttonCard(
- imageUrl: TypeConversionUtils.image_X_UrlString(
- album.images,
+ imageUrl: album.images.asUrlString(
placeholder: ImagePlaceholder.collection,
),
margin: const EdgeInsets.symmetric(horizontal: 10),
@@ -80,9 +63,16 @@ class AlbumCard extends HookConsumerWidget {
updating.value,
title: album.name!,
description:
- "${album.albumType?.formatted} • ${TypeConversionUtils.artists_X_String(album.artists ?? [])}",
+ "${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;
@@ -93,10 +83,22 @@ class AlbumCard extends HookConsumerWidget {
final fetchedTracks = await fetchAllTrack();
- if (fetchedTracks.isEmpty) return;
+ if (fetchedTracks.isEmpty || !context.mounted) return;
- await playlistNotifier.load(fetchedTracks, autoPlay: true);
- playlistNotifier.addCollection(album.id!);
+ final isRemoteDevice = await showSelectDeviceDialog(context, ref);
+ if (isRemoteDevice) {
+ final remotePlayback = ref.read(connectProvider.notifier);
+ await remotePlayback.load(
+ WebSocketLoadEventData.album(
+ tracks: fetchedTracks,
+ collection: album,
+ ),
+ );
+ } else {
+ await playlistNotifier.load(fetchedTracks, autoPlay: true);
+ playlistNotifier.addCollection(album.id!);
+ historyNotifier.addAlbums([album]);
+ }
} finally {
updating.value = false;
}
@@ -113,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(
diff --git a/lib/components/artist/artist_album_list.dart b/lib/components/artist/artist_album_list.dart
index 5114170c..a91327ce 100644
--- a/lib/components/artist/artist_album_list.dart
+++ b/lib/components/artist/artist_album_list.dart
@@ -1,38 +1,35 @@
import 'package:flutter/material.dart' hide Page;
-import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/logger.dart';
-import 'package:spotube/services/queries/queries.dart';
+import 'package:spotube/provider/spotify/spotify.dart';
class ArtistAlbumList extends HookConsumerWidget {
final String artistId;
ArtistAlbumList(
this.artistId, {
- Key? key,
- }) : super(key: key);
+ super.key,
+ });
final logger = getLogger(ArtistAlbumList);
@override
Widget build(BuildContext context, ref) {
- final albumsQuery = useQueries.artist.albumsOf(ref, artistId);
+ final albumsQuery = ref.watch(artistAlbumsProvider(artistId));
+ final albumsQueryNotifier =
+ ref.watch(artistAlbumsProvider(artistId).notifier);
- final albums = useMemoized(() {
- return albumsQuery.pages
- .expand((page) => page.items ?? const Iterable.empty())
- .toList();
- }, [albumsQuery.pages]);
+ final albums = albumsQuery.asData?.value.items ?? [];
final theme = Theme.of(context);
return HorizontalPlaybuttonCardView(
isLoadingNextPage: albumsQuery.isLoadingNextPage,
- hasNextPage: albumsQuery.hasNextPage,
+ hasNextPage: albumsQuery.asData?.value.hasMore ?? false,
items: albums,
- onFetchMore: albumsQuery.fetchNext,
+ onFetchMore: albumsQueryNotifier.fetchMore,
title: Text(
context.l10n.albums,
style: theme.textTheme.headlineSmall,
diff --git a/lib/components/artist/artist_card.dart b/lib/components/artist/artist_card.dart
index 3526e88f..9c1ee14a 100644
--- a/lib/components/artist/artist_card.dart
+++ b/lib/components/artist/artist_card.dart
@@ -6,27 +6,27 @@ import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
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';
-import 'package:spotube/utils/type_conversion_utils.dart';
class ArtistCard extends HookConsumerWidget {
final Artist artist;
- const ArtistCard(this.artist, {Key? key}) : super(key: key);
+ const ArtistCard(this.artist, {super.key});
@override
Widget build(BuildContext context, ref) {
final theme = Theme.of(context);
final backgroundImage = UniversalImage.imageProvider(
- TypeConversionUtils.image_X_UrlString(
- artist.images,
+ artist.images.asUrlString(
placeholder: ImagePlaceholder.artist,
),
);
final isBlackListed = ref.watch(
- BlackListNotifier.provider.select(
+ blacklistProvider.select(
(blacklist) => blacklist.contains(
BlacklistedElement.artist(artist.id!, artist.name!),
),
@@ -35,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(
xs: 130,
sm: 130,
@@ -46,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,
@@ -64,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(
diff --git a/lib/components/connect/connect_device.dart b/lib/components/connect/connect_device.dart
new file mode 100644
index 00000000..f4888534
--- /dev/null
+++ b/lib/components/connect/connect_device.dart
@@ -0,0 +1,123 @@
+import 'package:flutter/material.dart';
+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';
+
+class ConnectDeviceButton extends HookConsumerWidget {
+ final bool _sidebar;
+ const ConnectDeviceButton({super.key}) : _sidebar = false;
+ const ConnectDeviceButton.sidebar({super.key}) : _sidebar = true;
+
+ @override
+ Widget build(BuildContext context, ref) {
+ final ThemeData(:colorScheme) = Theme.of(context);
+ final pixelRatio = MediaQuery.of(context).devicePixelRatio;
+ final connectClients = ref.watch(connectClientsProvider);
+
+ if (_sidebar) {
+ return SizedBox(
+ width: double.infinity,
+ child: TextButton(
+ onPressed: () {
+ ServiceUtils.pushNamed(context, ConnectPage.name);
+ },
+ style: FilledButton.styleFrom(
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(8),
+ ),
+ padding: const EdgeInsets.all(5),
+ ),
+ child: Row(
+ children: [
+ Text(context.l10n.devices),
+ if (connectClients.asData?.value.services.isNotEmpty == true)
+ Text(
+ " (${connectClients.asData?.value.services.length})",
+ ),
+ const Spacer(),
+ const Icon(SpotubeIcons.speaker),
+ const Gap(5),
+ ],
+ ),
+ ),
+ );
+ }
+
+ return SizedBox(
+ height: 40 * pixelRatio,
+ child: Stack(
+ alignment: Alignment.centerRight,
+ fit: StackFit.loose,
+ children: [
+ Material(
+ type: MaterialType.transparency,
+ child: Center(
+ child: ClipRect(
+ clipBehavior: Clip.hardEdge,
+ child: InkWell(
+ onTap: () {
+ ServiceUtils.pushNamed(context, ConnectPage.name);
+ },
+ borderRadius: BorderRadius.circular(50),
+ child: Ink(
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(50),
+ color: colorScheme.primaryContainer,
+ ),
+ padding:
+ const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ if (connectClients.asData?.value.resolvedService !=
+ null) ...[
+ Container(
+ width: 7,
+ height: 7,
+ decoration: BoxDecoration(
+ color: Colors.greenAccent,
+ borderRadius: BorderRadius.circular(50),
+ ),
+ ),
+ const Gap(5),
+ ],
+ Text(context.l10n.devices),
+ if (connectClients.asData?.value.services.isNotEmpty ==
+ true)
+ Text(
+ " (${connectClients.asData?.value.services.length})",
+ style: TextStyle(
+ color: colorScheme.onPrimaryContainer
+ .withOpacity(0.5),
+ ),
+ ),
+ const Gap(35),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ Positioned(
+ right: -3,
+ child: IconButton.filled(
+ icon: const Icon(SpotubeIcons.speaker),
+ style: IconButton.styleFrom(
+ visualDensity: VisualDensity.standard,
+ foregroundColor: colorScheme.onPrimary,
+ ),
+ onPressed: () {
+ ServiceUtils.pushNamed(context, ConnectPage.name);
+ },
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/components/connect/local_devices.dart b/lib/components/connect/local_devices.dart
new file mode 100644
index 00000000..dd7db971
--- /dev/null
+++ b/lib/components/connect/local_devices.dart
@@ -0,0 +1,60 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:gap/gap.dart';
+import 'package:spotube/collections/spotube_icons.dart';
+import 'package:spotube/extensions/context.dart';
+import 'package:spotube/services/audio_player/audio_player.dart';
+
+class ConnectPageLocalDevices extends HookWidget {
+ const ConnectPageLocalDevices({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ final ThemeData(:textTheme) = Theme.of(context);
+ final devicesFuture = useFuture(audioPlayer.devices);
+ final devicesStream = useStream(audioPlayer.devicesStream);
+ final selectedDeviceFuture = useFuture(audioPlayer.selectedDevice);
+ final selectedDeviceStream = useStream(audioPlayer.selectedDeviceStream);
+
+ final devices = devicesStream.data ?? devicesFuture.data;
+ final selectedDevice =
+ selectedDeviceStream.data ?? selectedDeviceFuture.data;
+
+ if (devices == null) {
+ return const SliverToBoxAdapter(child: SizedBox.shrink());
+ }
+
+ return SliverMainAxisGroup(
+ slivers: [
+ const SliverGap(10),
+ SliverPadding(
+ padding: const EdgeInsets.symmetric(horizontal: 8.0),
+ sliver: SliverToBoxAdapter(
+ child: Text(
+ context.l10n.this_device,
+ style: textTheme.titleMedium,
+ ),
+ ),
+ ),
+ const SliverGap(10),
+ SliverList.separated(
+ itemCount: devices.length,
+ separatorBuilder: (context, index) => const Gap(10),
+ itemBuilder: (context, index) {
+ final device = devices[index];
+
+ return Card(
+ child: ListTile(
+ leading: const Icon(SpotubeIcons.speaker),
+ title: Text(device.description),
+ subtitle: Text(device.name),
+ selected: selectedDevice == device,
+ onTap: () => audioPlayer.setAudioDevice(device),
+ ),
+ );
+ },
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/components/desktop_login/login_form.dart b/lib/components/desktop_login/login_form.dart
index 5abb9524..6091829c 100644
--- a/lib/components/desktop_login/login_form.dart
+++ b/lib/components/desktop_login/login_form.dart
@@ -8,16 +8,14 @@ import 'package:spotube/provider/authentication_provider.dart';
class TokenLoginForm extends HookConsumerWidget {
final void Function()? onDone;
const TokenLoginForm({
- Key? key,
+ super.key,
this.onDone,
- }) : super(key: key);
+ });
@override
Widget build(BuildContext context, ref) {
- final authenticationNotifier =
- ref.watch(AuthenticationNotifier.provider.notifier);
+ final authenticationNotifier = ref.watch(authenticationProvider.notifier);
final directCodeController = useTextEditingController();
- final mounted = useIsMounted();
final isLoading = useState(false);
@@ -58,7 +56,7 @@ class TokenLoginForm extends HookConsumerWidget {
await AuthenticationCredentials.fromCookie(
cookieHeader),
);
- if (mounted()) {
+ if (context.mounted) {
onDone?.call();
}
} finally {
diff --git a/lib/components/home/sections/featured.dart b/lib/components/home/sections/featured.dart
index 8a7c2c95..0db5a1e8 100644
--- a/lib/components/home/sections/featured.dart
+++ b/lib/components/home/sections/featured.dart
@@ -1,35 +1,28 @@
import 'package:flutter/material.dart' hide Page;
-import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart';
import 'package:spotube/extensions/context.dart';
-import 'package:spotube/services/queries/queries.dart';
+import 'package:spotube/provider/spotify/spotify.dart';
class HomeFeaturedSection extends HookConsumerWidget {
- const HomeFeaturedSection({Key? key}) : super(key: key);
+ const HomeFeaturedSection({super.key});
@override
Widget build(BuildContext context, ref) {
- final featuredPlaylistsQuery = useQueries.playlist.featured(ref);
- final playlists = useMemoized(
- () => featuredPlaylistsQuery.pages
- .whereType>()
- .expand((page) => page.items ?? const []),
- [featuredPlaylistsQuery.pages],
- );
- final isLoadingFeaturedPlaylists = !featuredPlaylistsQuery.hasPageData &&
- !featuredPlaylistsQuery.isLoadingNextPage;
+ final featuredPlaylists = ref.watch(featuredPlaylistsProvider);
+ final featuredPlaylistsNotifier =
+ ref.watch(featuredPlaylistsProvider.notifier);
return Skeletonizer(
- enabled: isLoadingFeaturedPlaylists,
+ enabled: featuredPlaylists.isLoading,
child: HorizontalPlaybuttonCardView(
- items: playlists.toList(),
+ items: featuredPlaylists.asData?.value.items ?? [],
title: Text(context.l10n.featured),
- isLoadingNextPage: featuredPlaylistsQuery.isLoadingNextPage,
- hasNextPage: featuredPlaylistsQuery.hasNextPage,
- onFetchMore: featuredPlaylistsQuery.fetchNext,
+ isLoadingNextPage: featuredPlaylists.isLoadingNextPage,
+ hasNextPage: featuredPlaylists.asData?.value.hasMore ?? false,
+ onFetchMore: featuredPlaylistsNotifier.fetchMore,
),
);
}
diff --git a/lib/components/home/sections/feed.dart b/lib/components/home/sections/feed.dart
new file mode 100644
index 00000000..f3f632ce
--- /dev/null
+++ b/lib/components/home/sections/feed.dart
@@ -0,0 +1,58 @@
+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';
+
+class HomePageFeedSection extends HookConsumerWidget {
+ const HomePageFeedSection({super.key});
+
+ @override
+ Widget build(BuildContext context, ref) {
+ final homeFeed = ref.watch(homeViewProvider);
+ final nonShortSections = homeFeed.asData?.value?.sections
+ .where((s) => s.typename == "HomeGenericSectionData")
+ .toList() ??
+ [];
+
+ return SliverList.builder(
+ itemCount: nonShortSections.length,
+ itemBuilder: (context, index) {
+ final section = nonShortSections[index];
+ if (section.items.isEmpty) return const SizedBox.shrink();
+
+ return HorizontalPlaybuttonCardView(
+ items: [
+ for (final item in section.items)
+ if (item.album != null)
+ item.album!.asAlbum
+ else if (item.artist != null)
+ item.artist!.asArtist
+ else if (item.playlist != null)
+ item.playlist!.asPlaylist
+ ],
+ title: Text(section.title ?? "No Titel"),
+ hasNextPage: false,
+ isLoadingNextPage: false,
+ onFetchMore: () {},
+ titleTrailing: Directionality(
+ textDirection: TextDirection.rtl,
+ child: TextButton.icon(
+ label: const Text("Browse More"),
+ icon: const Icon(SpotubeIcons.angleRight),
+ onPressed: () => ServiceUtils.pushNamed(
+ context,
+ HomeFeedSectionPage.name,
+ pathParameters: {
+ "feedId": section.uri,
+ },
+ ),
+ ),
+ ),
+ );
+ },
+ );
+ }
+}
diff --git a/lib/components/home/sections/friends.dart b/lib/components/home/sections/friends.dart
index 6382f6fd..4ae802e6 100644
--- a/lib/components/home/sections/friends.dart
+++ b/lib/components/home/sections/friends.dart
@@ -1,22 +1,25 @@
-import 'dart:ffi';
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/services/queries/queries.dart';
+import 'package:spotube/provider/authentication_provider.dart';
+import 'package:spotube/provider/spotify/spotify.dart';
class HomePageFriendsSection extends HookConsumerWidget {
- const HomePageFriendsSection({Key? key}) : super(key: key);
+ const HomePageFriendsSection({super.key});
@override
Widget build(BuildContext context, ref) {
- final friendsQuery = useQueries.user.friendActivity(ref);
- final friends = friendsQuery.data?.friends ?? FakeData.friends.friends;
+ final auth = ref.watch(authenticationProvider);
+ final friendsQuery = ref.watch(friendsProvider);
+ final friends =
+ friendsQuery.asData?.value.friends ?? FakeData.friends.friends;
final groupCount = useBreakpointValue(
sm: 3,
@@ -27,32 +30,36 @@ class HomePageFriendsSection extends HookConsumerWidget {
xxl: 7,
);
- final friendGroup = friends.fold>>(
- [],
- (previousValue, element) {
- if (previousValue.isEmpty) {
+ final friendGroup = useMemoized(
+ () => friends.fold>>(
+ [],
+ (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.hasData || friendsQuery.data!.friends.isEmpty)) {
+ if (friendsQuery.isLoading ||
+ friendsQuery.asData?.value.friends.isEmpty == true ||
+ auth == null) {
return const SliverToBoxAdapter(
child: SizedBox.shrink(),
);
diff --git a/lib/components/home/sections/friends/friend_item.dart b/lib/components/home/sections/friends/friend_item.dart
index fcdadab7..096964a6 100644
--- a/lib/components/home/sections/friends/friend_item.dart
+++ b/lib/components/home/sections/friends/friend_item.dart
@@ -1,21 +1,22 @@
-import 'package:fl_query_hooks/fl_query_hooks.dart';
import 'package:flutter/gestures.dart';
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:spotify/spotify.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 {
final SpotifyFriendActivity friend;
const FriendItem({
- Key? key,
+ super.key,
required this.friend,
- }) : super(key: key);
+ });
@override
Widget build(BuildContext context, ref) {
@@ -24,13 +25,12 @@ class FriendItem extends HookConsumerWidget {
colorScheme: colorScheme,
) = Theme.of(context);
- final queryClient = useQueryClient();
final spotify = ref.watch(spotifyProvider);
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(
@@ -60,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: " • "),
@@ -74,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,
);
},
),
@@ -86,15 +92,11 @@ class FriendItem extends HookConsumerWidget {
..onTap = () async {
context.push(
"/${friend.track.context.path}",
- extra: !friend.track.context.path
- .startsWith("album")
- ? null
- : await queryClient.fetchQuery(
- "album/${friend.track.album.id}",
- () => spotify.albums.get(
- friend.track.album.id,
- ),
- ),
+ extra:
+ !friend.track.context.path.startsWith("album")
+ ? null
+ : await spotify.albums
+ .get(friend.track.context.id),
);
},
),
@@ -110,15 +112,13 @@ class FriendItem extends HookConsumerWidget {
recognizer: TapGestureRecognizer()
..onTap = () async {
final album =
- await queryClient.fetchQuery(
- "album/${friend.track.album.id}",
- () => spotify.albums.get(
- friend.track.album.id,
- ),
- );
+ 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,
);
}
diff --git a/lib/components/home/sections/genres.dart b/lib/components/home/sections/genres.dart
index 41ba235c..62f462e2 100644
--- a/lib/components/home/sections/genres.dart
+++ b/lib/components/home/sections/genres.dart
@@ -13,28 +13,28 @@ 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/provider/user_preferences/user_preferences_provider.dart';
-import 'package:spotube/services/queries/queries.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 {
- const HomeGenresSection({Key? key}) : super(key: key);
+ const HomeGenresSection({super.key});
@override
Widget build(BuildContext context, ref) {
final ThemeData(:textTheme, :colorScheme) = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
- final recommendationMarket = ref.watch(
- userPreferencesProvider.select((s) => s.recommendationMarket),
+ final categoriesQuery = ref.watch(categoriesProvider);
+ final categories = useMemoized(
+ () =>
+ categoriesQuery.asData?.value
+ .where((c) => (c.icons?.length ?? 0) > 0)
+ .take(mediaQuery.mdAndDown ? 6 : 10)
+ .toList() ??
+ [],
+ [mediaQuery.mdAndDown, categoriesQuery.asData?.value],
);
- final categoriesQuery =
- useQueries.category.listAll(ref, recommendationMarket);
-
- final categories = categoriesQuery.data
- ?.where((c) => (c.icons?.length ?? 0) > 0)
- .take(mediaQuery.mdAndDown ? 6 : 10)
- .toList() ??
- [];
return SliverMainAxisGroup(
slivers: [
@@ -52,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,
),
@@ -112,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(
@@ -128,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),
diff --git a/lib/components/home/sections/made_for_user.dart b/lib/components/home/sections/made_for_user.dart
index a3f96899..d1d269f6 100644
--- a/lib/components/home/sections/made_for_user.dart
+++ b/lib/components/home/sections/made_for_user.dart
@@ -2,19 +2,19 @@ import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart';
-import 'package:spotube/services/queries/queries.dart';
+import 'package:spotube/provider/spotify/spotify.dart';
class HomeMadeForUserSection extends HookConsumerWidget {
- const HomeMadeForUserSection({Key? key}) : super(key: key);
+ const HomeMadeForUserSection({super.key});
@override
Widget build(BuildContext context, ref) {
- final madeForUser = useQueries.views.get(ref, "made-for-x-hub");
+ final madeForUser = ref.watch(viewProvider("made-for-x-hub"));
return SliverList.builder(
- itemCount: madeForUser.data?["content"]?["items"]?.length ?? 0,
+ itemCount: madeForUser.asData?.value["content"]?["items"]?.length ?? 0,
itemBuilder: (context, index) {
- final item = madeForUser.data?["content"]?["items"]?[index];
+ final item = madeForUser.asData?.value["content"]?["items"]?[index];
final playlists = item["content"]?["items"]
?.where((itemL2) => itemL2["type"] == "playlist")
.map((itemL2) => PlaylistSimple.fromJson(itemL2))
diff --git a/lib/components/home/sections/new_releases.dart b/lib/components/home/sections/new_releases.dart
index 0f4a046a..82bc0e8c 100644
--- a/lib/components/home/sections/new_releases.dart
+++ b/lib/components/home/sections/new_releases.dart
@@ -1,56 +1,35 @@
import 'package:flutter/material.dart' hide Page;
-import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/authentication_provider.dart';
-import 'package:spotube/services/queries/queries.dart';
-import 'package:spotube/utils/type_conversion_utils.dart';
+import 'package:spotube/provider/spotify/spotify.dart';
class HomeNewReleasesSection extends HookConsumerWidget {
- const HomeNewReleasesSection({Key? key}) : super(key: key);
+ const HomeNewReleasesSection({super.key});
@override
Widget build(BuildContext context, ref) {
- final auth = ref.watch(AuthenticationNotifier.provider);
+ final auth = ref.watch(authenticationProvider);
- final newReleases = useQueries.album.newReleases(ref);
- final userArtistsQuery = useQueries.artist.followedByMeAll(ref);
- final userArtists =
- userArtistsQuery.data?.map((s) => s.id!).toList() ?? const [];
+ final newReleases = ref.watch(albumReleasesProvider);
+ final newReleasesNotifier = ref.read(albumReleasesProvider.notifier);
- final albums = useMemoized(
- () {
- final allReleases = newReleases.pages
- .whereType>()
- .expand((page) => page.items ?? const [])
- .map((album) => TypeConversionUtils.simpleAlbum_X_Album(album));
+ final albums = ref.watch(userArtistAlbumReleasesProvider);
- final userArtistReleases = allReleases.where((album) {
- return album.artists
- ?.any((artist) => userArtists.contains(artist.id!)) ==
- true;
- }).toList();
-
- if (userArtistReleases.isEmpty) return allReleases.toList();
- return userArtistReleases;
- },
- [newReleases.pages],
- );
-
- final hasNewReleases = newReleases.hasPageData &&
- userArtistsQuery.hasData &&
- !newReleases.isLoadingNextPage;
-
- if (auth == null || !hasNewReleases) return const SizedBox.shrink();
+ if (auth == null ||
+ newReleases.isLoading ||
+ newReleases.asData?.value.items.isEmpty == true) {
+ return const SizedBox.shrink();
+ }
return HorizontalPlaybuttonCardView(
items: albums,
title: Text(context.l10n.new_releases),
isLoadingNextPage: newReleases.isLoadingNextPage,
- hasNextPage: newReleases.hasNextPage,
- onFetchMore: newReleases.fetchNext,
+ hasNextPage: newReleases.asData?.value.hasMore ?? false,
+ onFetchMore: newReleasesNotifier.fetchMore,
);
}
}
diff --git a/lib/components/home/sections/recent.dart b/lib/components/home/sections/recent.dart
new file mode 100644
index 00000000..0fc5fadf
--- /dev/null
+++ b/lib/components/home/sections/recent.dart
@@ -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: () {},
+ );
+ }
+}
diff --git a/lib/components/library/local_folder/local_folder_item.dart b/lib/components/library/local_folder/local_folder_item.dart
new file mode 100644
index 00000000..6220a967
--- /dev/null
+++ b/lib/components/library/local_folder/local_folder_item.dart
@@ -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(),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/components/library/playlist_generate/multi_select_field.dart b/lib/components/library/playlist_generate/multi_select_field.dart
index ed5eb38f..d8e0506d 100644
--- a/lib/components/library/playlist_generate/multi_select_field.dart
+++ b/lib/components/library/playlist_generate/multi_select_field.dart
@@ -25,7 +25,7 @@ class MultiSelectField extends HookWidget {
final bool enabled;
const MultiSelectField({
- Key? key,
+ super.key,
required this.options,
required this.selectedOptions,
required this.getValueForOption,
@@ -36,7 +36,7 @@ class MultiSelectField extends HookWidget {
this.dialogTitle,
this.helperText,
this.enabled = true,
- }) : super(key: key);
+ });
Widget defaultSelectedOptionBuilder(T option) {
return Chip(
@@ -71,7 +71,7 @@ class MultiSelectField extends HookWidget {
: theme.colorScheme.onSurface.withOpacity(0.1),
),
),
- mouseCursor: MaterialStateMouseCursor.textable,
+ mouseCursor: WidgetStateMouseCursor.textable,
onPressed: !enabled
? null
: () async {
@@ -134,14 +134,14 @@ class _MultiSelectDialog extends HookWidget {
final String? helperText;
const _MultiSelectDialog({
- Key? key,
+ super.key,
required this.dialogTitle,
required this.options,
required this.getValueForOption,
this.optionBuilder,
this.initialSelection = const [],
this.helperText,
- }) : super(key: key);
+ });
@override
Widget build(BuildContext context) {
diff --git a/lib/components/library/playlist_generate/recommendation_attribute_dials.dart b/lib/components/library/playlist_generate/recommendation_attribute_dials.dart
index 87f7cb1b..d7f51ffb 100644
--- a/lib/components/library/playlist_generate/recommendation_attribute_dials.dart
+++ b/lib/components/library/playlist_generate/recommendation_attribute_dials.dart
@@ -20,12 +20,12 @@ class RecommendationAttributeDials extends HookWidget {
final double base;
const RecommendationAttributeDials({
- Key? key,
+ super.key,
required this.values,
required this.onChanged,
required this.title,
this.base = 1,
- }) : super(key: key);
+ });
@override
Widget build(BuildContext context) {
diff --git a/lib/components/library/playlist_generate/recommendation_attribute_fields.dart b/lib/components/library/playlist_generate/recommendation_attribute_fields.dart
index de169147..75437360 100644
--- a/lib/components/library/playlist_generate/recommendation_attribute_fields.dart
+++ b/lib/components/library/playlist_generate/recommendation_attribute_fields.dart
@@ -12,12 +12,12 @@ class RecommendationAttributeFields extends HookWidget {
final Map? presets;
const RecommendationAttributeFields({
- Key? key,
+ super.key,
required this.values,
required this.onChanged,
required this.title,
this.presets,
- }) : super(key: key);
+ });
@override
Widget build(BuildContext context) {
diff --git a/lib/components/library/playlist_generate/seeds_multi_autocomplete.dart b/lib/components/library/playlist_generate/seeds_multi_autocomplete.dart
index b1665d32..73c58deb 100644
--- a/lib/components/library/playlist_generate/seeds_multi_autocomplete.dart
+++ b/lib/components/library/playlist_generate/seeds_multi_autocomplete.dart
@@ -26,7 +26,7 @@ class SeedsMultiAutocomplete extends HookWidget {
final SelectedItemDisplayType selectedItemDisplayType;
const SeedsMultiAutocomplete({
- Key? key,
+ super.key,
required this.seeds,
required this.fetchSeeds,
required this.autocompleteOptionBuilder,
@@ -35,7 +35,7 @@ class SeedsMultiAutocomplete extends HookWidget {
this.inputDecoration,
this.enabled = true,
this.selectedItemDisplayType = SelectedItemDisplayType.wrap,
- }) : super(key: key);
+ });
@override
Widget build(BuildContext context) {
diff --git a/lib/components/library/playlist_generate/simple_track_tile.dart b/lib/components/library/playlist_generate/simple_track_tile.dart
index 86800d06..cf4ddb1a 100644
--- a/lib/components/library/playlist_generate/simple_track_tile.dart
+++ b/lib/components/library/playlist_generate/simple_track_tile.dart
@@ -4,16 +4,16 @@ import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
-import 'package:spotube/utils/type_conversion_utils.dart';
+import 'package:spotube/extensions/image.dart';
class SimpleTrackTile extends HookWidget {
final Track track;
final VoidCallback? onDelete;
const SimpleTrackTile({
- Key? key,
+ super.key,
required this.track,
this.onDelete,
- }) : super(key: key);
+ });
@override
Widget build(BuildContext context) {
@@ -21,8 +21,7 @@ class SimpleTrackTile extends HookWidget {
leading: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: UniversalImage(
- path: TypeConversionUtils.image_X_UrlString(
- track.album?.images,
+ path: (track.album?.images).asUrlString(
placeholder: ImagePlaceholder.artist,
),
height: 40,
diff --git a/lib/components/library/user_albums.dart b/lib/components/library/user_albums.dart
index 200d1c59..e1b82113 100644
--- a/lib/components/library/user_albums.dart
+++ b/lib/components/library/user_albums.dart
@@ -2,123 +2,113 @@ import 'package:flutter/material.dart' hide Image;
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: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/album/album_card.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/fallbacks/anonymous_fallback.dart';
import 'package:spotube/components/shared/waypoint.dart';
+import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/authentication_provider.dart';
-import 'package:spotube/services/queries/queries.dart';
-
-import 'package:spotube/utils/type_conversion_utils.dart';
+import 'package:spotube/provider/spotify/spotify.dart';
class UserAlbums extends HookConsumerWidget {
- const UserAlbums({Key? key}) : super(key: key);
+ const UserAlbums({super.key});
@override
Widget build(BuildContext context, ref) {
- final auth = ref.watch(AuthenticationNotifier.provider);
- final albumsQuery = useQueries.album.ofMine(ref);
+ final auth = ref.watch(authenticationProvider);
+ final albumsQuery = ref.watch(favoriteAlbumsProvider);
+ final albumsQueryNotifier = ref.watch(favoriteAlbumsProvider.notifier);
final controller = useScrollController();
final searchText = useState('');
- final allAlbums = useMemoized(
- () => albumsQuery.pages
- .expand((element) => element.items ?? []),
- [albumsQuery.pages],
- );
-
final albums = useMemoized(() {
if (searchText.value.isEmpty) {
- return allAlbums;
+ return albumsQuery.asData?.value.items ?? [];
}
- return allAlbums
- .map((e) => (
- weightedRatio(e.name!, searchText.value),
- e,
- ))
- .sorted((a, b) => b.$1.compareTo(a.$1))
- .where((e) => e.$1 > 50)
- .map((e) => e.$2)
- .toList();
- }, [allAlbums, searchText.value]);
+ return albumsQuery.asData?.value.items
+ .map((e) => (
+ weightedRatio(e.name!, searchText.value),
+ e,
+ ))
+ .sorted((a, b) => b.$1.compareTo(a.$1))
+ .where((e) => e.$1 > 50)
+ .map((e) => e.$2)
+ .toList() ??
+ [];
+ }, [albumsQuery.asData?.value, searchText.value]);
if (auth == null) {
return const AnonymousFallback();
}
- final theme = Theme.of(context);
-
- return RefreshIndicator(
- onRefresh: () async {
- await albumsQuery.refresh();
- },
- child: SafeArea(
- child: Scaffold(
- appBar: PreferredSize(
- preferredSize: const Size.fromHeight(50),
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 8.0),
- child: ColoredBox(
- color: theme.scaffoldBackgroundColor,
- child: SearchBar(
- onChanged: (value) => searchText.value = value,
- leading: const Icon(SpotubeIcons.filter),
- hintText: context.l10n.filter_albums,
- ),
- ),
- ),
- ),
- body: SizedBox.expand(
- child: InterScrollbar(
+ return SafeArea(
+ child: Scaffold(
+ body: RefreshIndicator(
+ onRefresh: () async {
+ ref.invalidate(favoriteAlbumsProvider);
+ },
+ child: InterScrollbar(
+ controller: controller,
+ child: CustomScrollView(
controller: controller,
- child: SingleChildScrollView(
- padding: const EdgeInsets.all(8.0),
- controller: controller,
- child: Skeletonizer(
- enabled: albumsQuery.pages.isEmpty,
- child: Center(
- child: Wrap(
- runSpacing: 20,
- alignment: WrapAlignment.center,
- runAlignment: WrapAlignment.center,
- crossAxisAlignment: WrapCrossAlignment.center,
- children: [
- if (albumsQuery.pages.isEmpty)
- ...List.generate(
- 10,
- (index) => AlbumCard(FakeData.album),
- )
- else if (albums.isEmpty)
- const Row(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [NotFound()],
- ),
- for (final album in albums)
- AlbumCard(
- TypeConversionUtils.simpleAlbum_X_Album(album),
- ),
- if (albums.isNotEmpty && albumsQuery.hasNextPage)
- Waypoint(
- controller: controller,
- isGrid: true,
- onTouchEdge: albumsQuery.fetchNext,
- child: AlbumCard(FakeData.album),
- )
- ],
+ slivers: [
+ SliverAppBar(
+ floating: true,
+ flexibleSpace: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 8.0),
+ child: SearchBar(
+ onChanged: (value) => searchText.value = value,
+ leading: const Icon(SpotubeIcons.filter),
+ hintText: context.l10n.filter_albums,
),
),
),
- ),
+ const SliverGap(10),
+ Skeletonizer.sliver(
+ enabled: albumsQuery.isLoading,
+ child: SliverLayoutBuilder(builder: (context, constrains) {
+ return SliverGrid.builder(
+ itemCount: albums.isEmpty ? 6 : albums.length + 1,
+ gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
+ maxCrossAxisExtent: 200,
+ mainAxisExtent: constrains.smAndDown ? 225 : 250,
+ crossAxisSpacing: 8,
+ mainAxisSpacing: 8,
+ ),
+ itemBuilder: (context, index) {
+ if (albums.isNotEmpty && index == albums.length) {
+ if (albumsQuery.asData?.value.hasMore != true) {
+ return const SizedBox.shrink();
+ }
+
+ return Waypoint(
+ controller: controller,
+ isGrid: true,
+ onTouchEdge: albumsQueryNotifier.fetchMore,
+ child: Skeletonizer(
+ enabled: true,
+ child: AlbumCard(FakeData.albumSimple),
+ ),
+ );
+ }
+
+ return AlbumCard(
+ albums.elementAtOrNull(index) ?? FakeData.albumSimple,
+ );
+ },
+ );
+ }),
+ ),
+ ],
),
),
),
diff --git a/lib/components/library/user_artists.dart b/lib/components/library/user_artists.dart
index 36b8528e..0ef0ff39 100644
--- a/lib/components/library/user_artists.dart
+++ b/lib/components/library/user_artists.dart
@@ -2,6 +2,7 @@ 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:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/fake.dart';
@@ -9,26 +10,27 @@ import 'package:spotube/collections/fake.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart';
import 'package:spotube/components/artist/artist_card.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/waypoint.dart';
+import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/authentication_provider.dart';
-import 'package:spotube/services/queries/queries.dart';
+import 'package:spotube/provider/spotify/spotify.dart';
class UserArtists extends HookConsumerWidget {
- const UserArtists({Key? key}) : super(key: key);
+ const UserArtists({super.key});
@override
Widget build(BuildContext context, ref) {
- final theme = Theme.of(context);
- final auth = ref.watch(AuthenticationNotifier.provider);
+ final auth = ref.watch(authenticationProvider);
- final artistQuery = useQueries.artist.followedByMeAll(ref);
+ final artistQuery = ref.watch(followedArtistsProvider);
+ final artistQueryNotifier = ref.watch(followedArtistsProvider.notifier);
final searchText = useState('');
final filteredArtists = useMemoized(() {
- final artists = artistQuery.data ?? [];
+ final artists = artistQuery.asData?.value.items ?? [];
if (searchText.value.isEmpty) {
return artists.toList();
@@ -42,7 +44,7 @@ class UserArtists extends HookConsumerWidget {
.where((e) => e.$1 > 50)
.map((e) => e.$2)
.toList();
- }, [artistQuery.data, searchText.value]);
+ }, [artistQuery.asData?.value.items, searchText.value]);
final controller = useScrollController();
@@ -50,76 +52,73 @@ class UserArtists extends HookConsumerWidget {
return const AnonymousFallback();
}
- return Scaffold(
- appBar: PreferredSize(
- preferredSize: const Size.fromHeight(50),
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 8.0),
- child: ColoredBox(
- color: theme.scaffoldBackgroundColor,
- child: SearchBar(
- onChanged: (value) => searchText.value = value,
- leading: const Icon(SpotubeIcons.filter),
- hintText: context.l10n.filter_artist,
+ return SafeArea(
+ child: Scaffold(
+ body: RefreshIndicator(
+ onRefresh: () async {
+ ref.invalidate(followedArtistsProvider);
+ },
+ child: InterScrollbar(
+ controller: controller,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 8.0),
+ child: CustomScrollView(
+ controller: controller,
+ slivers: [
+ SliverAppBar(
+ floating: true,
+ flexibleSpace: SearchBar(
+ onChanged: (value) => searchText.value = value,
+ leading: const Icon(SpotubeIcons.filter),
+ hintText: context.l10n.filter_artist,
+ ),
+ ),
+ const SliverGap(10),
+ Skeletonizer.sliver(
+ enabled: artistQuery.isLoading,
+ child: SliverLayoutBuilder(builder: (context, constrains) {
+ return SliverGrid.builder(
+ itemCount: filteredArtists.isEmpty
+ ? 6
+ : filteredArtists.length + 1,
+ gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
+ maxCrossAxisExtent: 200,
+ mainAxisExtent: constrains.smAndDown ? 225 : 250,
+ crossAxisSpacing: 8,
+ mainAxisSpacing: 8,
+ ),
+ itemBuilder: (context, index) {
+ if (filteredArtists.isNotEmpty &&
+ index == filteredArtists.length) {
+ if (artistQuery.asData?.value.hasMore != true) {
+ return const SizedBox.shrink();
+ }
+
+ return Waypoint(
+ controller: controller,
+ isGrid: true,
+ onTouchEdge: artistQueryNotifier.fetchMore,
+ child: Skeletonizer(
+ enabled: true,
+ child: ArtistCard(FakeData.artist),
+ ),
+ );
+ }
+
+ return ArtistCard(
+ filteredArtists.elementAtOrNull(index) ??
+ FakeData.artist,
+ );
+ },
+ );
+ }),
+ ),
+ ],
+ ),
),
),
),
),
- backgroundColor: theme.scaffoldBackgroundColor,
- body: artistQuery.data?.isEmpty == true
- ? Padding(
- padding: const EdgeInsets.all(20),
- child: Row(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- const CircularProgressIndicator(),
- const SizedBox(width: 10),
- Text(context.l10n.loading),
- ],
- ),
- )
- : RefreshIndicator(
- onRefresh: () async {
- await artistQuery.refresh();
- },
- child: InterScrollbar(
- controller: controller,
- child: SingleChildScrollView(
- controller: controller,
- child: SizedBox(
- width: double.infinity,
- child: SafeArea(
- child: Center(
- child: Skeletonizer(
- enabled: artistQuery.isLoading,
- child: Wrap(
- spacing: 15,
- runSpacing: 5,
- children: artistQuery.isLoading
- ? List.generate(
- 10, (index) => ArtistCard(FakeData.artist))
- : filteredArtists.isEmpty
- ? [
- const Row(
- mainAxisAlignment:
- MainAxisAlignment.center,
- children: [
- NotFound(),
- ],
- )
- ]
- : filteredArtists
- .mapIndexed((index, artist) =>
- ArtistCard(artist))
- .toList(),
- ),
- ),
- ),
- ),
- ),
- ),
- ),
- ),
);
}
}
diff --git a/lib/components/library/user_downloads.dart b/lib/components/library/user_downloads.dart
index c8ceee66..3a1162e6 100644
--- a/lib/components/library/user_downloads.dart
+++ b/lib/components/library/user_downloads.dart
@@ -7,7 +7,7 @@ import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/download_manager_provider.dart';
class UserDownloads extends HookConsumerWidget {
- const UserDownloads({Key? key}) : super(key: key);
+ const UserDownloads({super.key});
@override
Widget build(BuildContext context, ref) {
diff --git a/lib/components/library/user_downloads/download_item.dart b/lib/components/library/user_downloads/download_item.dart
index 10dec410..a145fdad 100644
--- a/lib/components/library/user_downloads/download_item.dart
+++ b/lib/components/library/user_downloads/download_item.dart
@@ -4,18 +4,19 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
+import 'package:spotube/components/shared/links/artist_link.dart';
import 'package:spotube/extensions/context.dart';
+import 'package:spotube/extensions/image.dart';
import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/services/download_manager/download_status.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
-import 'package:spotube/utils/type_conversion_utils.dart';
class DownloadItem extends HookConsumerWidget {
final Track track;
const DownloadItem({
- Key? key,
+ super.key,
required this.track,
- }) : super(key: key);
+ });
@override
Widget build(BuildContext context, ref) {
@@ -51,16 +52,15 @@ class DownloadItem extends HookConsumerWidget {
child: UniversalImage(
height: 40,
width: 40,
- path: TypeConversionUtils.image_X_UrlString(
- track.album?.images,
+ path: (track.album?.images).asUrlString(
placeholder: ImagePlaceholder.albumArt,
),
),
),
),
title: Text(track.name ?? ''),
- subtitle: TypeConversionUtils.artists_X_ClickableArtists(
- track.artists ?? [],
+ subtitle: ArtistLink(
+ artists: track.artists ?? [],
mainAxisAlignment: WrapAlignment.start,
),
trailing: isQueryingSourceInfo
diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart
index 095e6e97..c0d63380 100644
--- a/lib/components/library/user_local_tracks.dart
+++ b/lib/components/library/user_local_tracks.dart
@@ -1,50 +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/components/library/local_folder/local_folder_item.dart';
+import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.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/type_conversion_utils.dart';
-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",
-};
+import 'package:spotube/utils/platform.dart';
+// ignore: depend_on_referenced_packages
enum SortBy {
none,
@@ -57,268 +25,77 @@ enum SortBy {
album,
}
-final localTracksProvider = FutureProvider>((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: TypeConversionUtils.localTrack_X_Track(
- 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({Key? key}) : super(key: key);
-
- Future playLocalTracks(
- WidgetRef ref,
- List tracks, {
- LocalTrack? currentTrack,
- }) async {
- final playlist = ref.read(ProxyPlaylistNotifier.provider);
- final playback = ref.read(ProxyPlaylistNotifier.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);
- }
- }
+ const UserLocalTracks({super.key});
@override
Widget build(BuildContext context, ref) {
- final sortBy = useState(SortBy.none);
- final playlist = ref.watch(ProxyPlaylistNotifier.provider);
- final trackSnapshot = ref.watch(localTracksProvider);
- final isPlaylistPlaying =
- playlist.containsTracks(trackSnapshot.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: 10),
- FilledButton(
- onPressed: trackSnapshot.value != null
- ? () async {
- if (trackSnapshot.value?.isNotEmpty == true) {
- if (!isPlaylistPlaying) {
- await playLocalTracks(
- ref,
- trackSnapshot.value!,
- );
- } else {
- // TODO: Remove stop capability
- // playlistNotifier.stop();
- }
- }
- }
- : 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: 10),
- FilledButton(
- child: const Icon(SpotubeIcons.refresh),
- onPressed: () {
- ref.refresh(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} - ${TypeConversionUtils.artists_X_String(e.artists ?? [])}",
- 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.refresh(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(track: FakeData.track, index: index);
- }
-
- final track = filteredTracks[index];
- return TrackTile(
- index: index,
- 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),
+ 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],
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ );
+ });
}
}
diff --git a/lib/components/library/user_playlists.dart b/lib/components/library/user_playlists.dart
index 32e91ed6..069dfad9 100644
--- a/lib/components/library/user_playlists.dart
+++ b/lib/components/library/user_playlists.dart
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart' hide Image;
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:collection/collection.dart';
+import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
@@ -17,24 +18,21 @@ import 'package:spotube/components/shared/waypoint.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/authentication_provider.dart';
-import 'package:spotube/services/queries/queries.dart';
+import 'package:spotube/provider/spotify/spotify.dart';
+import 'package:spotube/utils/platform.dart';
class UserPlaylists extends HookConsumerWidget {
- const UserPlaylists({Key? key}) : super(key: key);
+ const UserPlaylists({super.key});
@override
Widget build(BuildContext context, ref) {
final searchText = useState('');
- final auth = ref.watch(AuthenticationNotifier.provider);
+ final auth = ref.watch(authenticationProvider);
- final playlistsQuery = useQueries.playlist.ofMine(ref);
-
- final pagePlaylists = useMemoized(
- () => playlistsQuery.pages
- .expand((page) => page.items?.toList() ?? []),
- [playlistsQuery.pages],
- );
+ final playlistsQuery = ref.watch(favoritePlaylistsProvider);
+ final playlistsQueryNotifier =
+ ref.watch(favoritePlaylistsProvider.notifier);
final likedTracksPlaylist = useMemoized(
() => PlaylistSimple()
@@ -58,12 +56,12 @@ class UserPlaylists extends HookConsumerWidget {
if (searchText.value.isEmpty) {
return [
likedTracksPlaylist,
- ...pagePlaylists,
+ ...?playlistsQuery.asData?.value.items,
];
}
return [
likedTracksPlaylist,
- ...pagePlaylists,
+ ...?playlistsQuery.asData?.value.items,
]
.map((e) => (weightedRatio(e.name!, searchText.value), e))
.sorted((a, b) => b.$1.compareTo(a.$1))
@@ -71,7 +69,7 @@ class UserPlaylists extends HookConsumerWidget {
.map((e) => e.$2)
.toList();
},
- [pagePlaylists, searchText.value],
+ [playlistsQuery, searchText.value],
);
final controller = useScrollController();
@@ -81,46 +79,46 @@ class UserPlaylists extends HookConsumerWidget {
}
return RefreshIndicator(
- onRefresh: playlistsQuery.refresh,
+ onRefresh: () async {
+ ref.invalidate(favoritePlaylistsProvider);
+ },
child: SafeArea(
child: InterScrollbar(
controller: controller,
child: CustomScrollView(
controller: controller,
slivers: [
- SliverToBoxAdapter(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Padding(
- padding: const EdgeInsets.all(10),
- child: SearchBar(
- onChanged: (value) => searchText.value = value,
- hintText: context.l10n.filter_playlists,
- leading: const Icon(SpotubeIcons.filter),
+ SliverAppBar(
+ floating: true,
+ flexibleSpace: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 8),
+ child: SearchBar(
+ onChanged: (value) => searchText.value = value,
+ hintText: context.l10n.filter_playlists,
+ leading: const Icon(SpotubeIcons.filter),
+ ),
+ ),
+ bottom: PreferredSize(
+ preferredSize:
+ Size.fromHeight(kIsDesktop ? 35 : kToolbarHeight),
+ child: Row(
+ children: [
+ const Gap(10),
+ const PlaylistCreateDialogButton(),
+ const Gap(10),
+ ElevatedButton.icon(
+ icon: const Icon(SpotubeIcons.magic),
+ label: Text(context.l10n.generate_playlist),
+ onPressed: () {
+ GoRouter.of(context).push("/library/generate");
+ },
),
- ),
- Row(
- children: [
- const SizedBox(width: 10),
- const PlaylistCreateDialogButton(),
- const SizedBox(width: 10),
- ElevatedButton.icon(
- icon: const Icon(SpotubeIcons.magic),
- label: Text(context.l10n.generate_playlist),
- onPressed: () {
- GoRouter.of(context).push("/library/generate");
- },
- ),
- const SizedBox(width: 10),
- ],
- ),
- ],
+ const Gap(10),
+ ],
+ ),
),
),
- const SliverToBoxAdapter(
- child: SizedBox(height: 10),
- ),
+ const SliverGap(10),
SliverLayoutBuilder(builder: (context, constrains) {
return SliverGrid.builder(
itemCount: playlists.isEmpty ? 6 : playlists.length + 1,
@@ -132,14 +130,14 @@ class UserPlaylists extends HookConsumerWidget {
),
itemBuilder: (context, index) {
if (playlists.isNotEmpty && index == playlists.length) {
- if (!playlistsQuery.hasNextPage) {
+ if (playlistsQuery.asData?.value.hasMore != true) {
return const SizedBox.shrink();
}
return Waypoint(
controller: controller,
isGrid: true,
- onTouchEdge: playlistsQuery.fetchNext,
+ onTouchEdge: playlistsQueryNotifier.fetchMore,
child: Skeletonizer(
enabled: true,
child: PlaylistCard(FakeData.playlistSimple),
diff --git a/lib/components/lyrics/zoom_controls.dart b/lib/components/lyrics/zoom_controls.dart
index f50ea71d..73beb4ae 100644
--- a/lib/components/lyrics/zoom_controls.dart
+++ b/lib/components/lyrics/zoom_controls.dart
@@ -17,7 +17,7 @@ class ZoomControls extends HookWidget {
final String unit;
const ZoomControls({
- Key? key,
+ super.key,
required this.value,
required this.onChanged,
this.min,
@@ -27,7 +27,7 @@ class ZoomControls extends HookWidget {
this.decreaseIcon = const Icon(SpotubeIcons.zoomOut),
this.direction = Axis.horizontal,
this.unit = "%",
- }) : super(key: key);
+ });
@override
Widget build(BuildContext context) {
diff --git a/lib/components/player/player.dart b/lib/components/player/player.dart
index 458676e3..49341058 100644
--- a/lib/components/player/player.dart
+++ b/lib/components/player/player.dart
@@ -4,7 +4,6 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:spotify/spotify.dart' hide Offset;
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/player/player_actions.dart';
@@ -13,40 +12,44 @@ import 'package:spotube/components/player/player_queue.dart';
import 'package:spotube/components/player/volume_slider.dart';
import 'package:spotube/components/shared/animated_gradient.dart';
import 'package:spotube/components/shared/dialogs/track_details_dialog.dart';
+import 'package:spotube/components/shared/links/artist_link.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/components/shared/panels/sliding_up_panel.dart';
+import 'package:spotube/extensions/artist_simple.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_custom_status_bar_color.dart';
import 'package:spotube/hooks/utils/use_palette_color.dart';
import 'package:spotube/models/local_track.dart';
import 'package:spotube/pages/lyrics/lyrics.dart';
import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
+import 'package:spotube/provider/server/active_sourced_track.dart';
+import 'package:spotube/provider/volume_provider.dart';
import 'package:spotube/services/sourced_track/sources/youtube.dart';
-import 'package:spotube/utils/type_conversion_utils.dart';
+
import 'package:url_launcher/url_launcher_string.dart';
class PlayerView extends HookConsumerWidget {
final PanelController panelController;
final ScrollController scrollController;
const PlayerView({
- Key? key,
+ super.key,
required this.panelController,
required this.scrollController,
- }) : super(key: key);
+ });
@override
Widget build(BuildContext context, ref) {
final theme = Theme.of(context);
- final auth = ref.watch(AuthenticationNotifier.provider);
- final currentTrack = ref.watch(ProxyPlaylistNotifier.provider.select(
- (value) => value.activeTrack,
- ));
- final isLocalTrack = ref.watch(ProxyPlaylistNotifier.provider.select(
- (value) => value.activeTrack is LocalTrack,
- ));
+ final auth = ref.watch(authenticationProvider);
+ final sourcedCurrentTrack = ref.watch(activeSourcedTrackProvider);
+ final currentActiveTrack =
+ ref.watch(proxyPlaylistProvider.select((s) => s.activeTrack));
+ final currentTrack = sourcedCurrentTrack ?? currentActiveTrack;
+ final isLocalTrack = currentTrack is LocalTrack;
final mediaQuery = MediaQuery.of(context);
useEffect(() {
@@ -59,8 +62,7 @@ class PlayerView extends HookConsumerWidget {
}, [mediaQuery.lgAndUp]);
String albumArt = useMemoized(
- () => TypeConversionUtils.image_X_UrlString(
- currentTrack?.album?.images,
+ () => (currentTrack?.album?.images).asUrlString(
placeholder: ImagePlaceholder.albumArt,
),
[currentTrack?.album?.images],
@@ -96,6 +98,7 @@ class PlayerView extends HookConsumerWidget {
final topPadding = MediaQueryData.fromView(View.of(context)).padding.top;
+ // ignore: deprecated_member_use
return WillPopScope(
onWillPop: () async {
await panelController.close();
@@ -149,7 +152,7 @@ class PlayerView extends HookConsumerWidget {
label: Text(context.l10n.song_link),
style: TextButton.styleFrom(
foregroundColor: bodyTextColor,
- padding: EdgeInsets.zero,
+ padding: const EdgeInsets.symmetric(horizontal: 10),
),
onPressed: () {
final url =
@@ -239,19 +242,15 @@ class PlayerView extends HookConsumerWidget {
),
if (isLocalTrack)
Text(
- TypeConversionUtils.artists_X_String<
- Artist>(
- currentTrack?.artists ?? [],
- ),
+ currentTrack.artists?.asString() ?? "",
style: theme.textTheme.bodyMedium!.copyWith(
fontWeight: FontWeight.bold,
color: bodyTextColor,
),
)
else
- TypeConversionUtils
- .artists_X_ClickableArtists(
- currentTrack?.artists ?? [],
+ ArtistLink(
+ artists: currentTrack?.artists ?? [],
textStyle:
theme.textTheme.bodyMedium!.copyWith(
fontWeight: FontWeight.bold,
@@ -307,10 +306,24 @@ class PlayerView extends HookConsumerWidget {
.height *
.7,
),
- builder: (context) {
- return const PlayerQueue(
- floating: false);
- },
+ builder: (context) => Consumer(
+ builder: (context, ref, _) {
+ final playlist = ref.watch(
+ proxyPlaylistProvider,
+ );
+ final playlistNotifier =
+ ref.read(
+ proxyPlaylistProvider
+ .notifier,
+ );
+ return PlayerQueue
+ .fromProxyPlaylistNotifier(
+ floating: false,
+ playlist: playlist,
+ notifier: playlistNotifier,
+ );
+ },
+ ),
);
}
: null),
@@ -368,11 +381,21 @@ class PlayerView extends HookConsumerWidget {
enabledThumbRadius: 8,
),
),
- child: const Padding(
- padding: EdgeInsets.symmetric(horizontal: 16),
- child: VolumeSlider(
- fullWidth: true,
- ),
+ child: Padding(
+ padding:
+ const EdgeInsets.symmetric(horizontal: 16),
+ child: Consumer(builder: (context, ref, _) {
+ final volume = ref.watch(volumeProvider);
+ return VolumeSlider(
+ fullWidth: true,
+ value: volume,
+ onChanged: (value) {
+ ref
+ .read(volumeProvider.notifier)
+ .setVolume(value);
+ },
+ );
+ }),
),
),
],
diff --git a/lib/components/player/player_actions.dart b/lib/components/player/player_actions.dart
index 7a248aa5..d28c3900 100644
--- a/lib/components/player/player_actions.dart
+++ b/lib/components/player/player_actions.dart
@@ -3,12 +3,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:spotify/spotify.dart' hide Offset;
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/player/sibling_tracks_sheet.dart';
import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart';
import 'package:spotube/components/shared/heart_button.dart';
-import 'package:spotube/extensions/constrains.dart';
+import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/duration.dart';
import 'package:spotube/models/local_track.dart';
@@ -17,7 +16,6 @@ import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/sleep_timer_provider.dart';
-import 'package:spotube/utils/type_conversion_utils.dart';
class PlayerActions extends HookConsumerWidget {
final MainAxisAlignment mainAxisAlignment;
@@ -29,14 +27,13 @@ class PlayerActions extends HookConsumerWidget {
this.floatingQueue = true,
this.showQueue = true,
this.extraActions,
- Key? key,
- }) : super(key: key);
+ super.key,
+ });
final logger = getLogger(PlayerActions);
@override
Widget build(BuildContext context, ref) {
- final mediaQuery = MediaQuery.of(context);
- final playlist = ref.watch(ProxyPlaylistNotifier.provider);
+ final playlist = ref.watch(proxyPlaylistProvider);
final isLocalTrack = playlist.activeTrack is LocalTrack;
ref.watch(downloadManagerProvider);
final downloader = ref.watch(downloadManagerProvider.notifier);
@@ -49,19 +46,17 @@ class PlayerActions extends HookConsumerWidget {
]);
final localTracks = [] /* ref.watch(localTracksProvider).value */;
- final auth = ref.watch(AuthenticationNotifier.provider);
- final sleepTimer = ref.watch(SleepTimerNotifier.provider);
- final sleepTimerNotifier = ref.watch(SleepTimerNotifier.notifier);
+ final auth = ref.watch(authenticationProvider);
+ final sleepTimer = ref.watch(sleepTimerProvider);
+ final sleepTimerNotifier = ref.watch(sleepTimerProvider.notifier);
final isDownloaded = useMemoized(() {
return localTracks.any(
(element) =>
element.name == playlist.activeTrack?.name &&
element.album?.name == playlist.activeTrack?.album?.name &&
- TypeConversionUtils.artists_X_String(
- element.artists ?? []) ==
- TypeConversionUtils.artists_X_String(
- playlist.activeTrack?.artists ?? []),
+ element.artists?.asString() ==
+ playlist.activeTrack?.artists?.asString(),
) ==
true;
}, [localTracks, playlist.activeTrack]);
diff --git a/lib/components/player/player_controls.dart b/lib/components/player/player_controls.dart
index 1000af18..7683de19 100644
--- a/lib/components/player/player_controls.dart
+++ b/lib/components/player/player_controls.dart
@@ -21,8 +21,8 @@ class PlayerControls extends HookConsumerWidget {
PlayerControls({
this.palette,
this.compact = false,
- Key? key,
- }) : super(key: key);
+ super.key,
+ });
final logger = getLogger(PlayerControls);
@@ -43,8 +43,8 @@ class PlayerControls extends HookConsumerWidget {
SeekIntent: SeekAction(),
},
[]);
- final playlist = ref.watch(ProxyPlaylistNotifier.provider);
- final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
+ final playlist = ref.watch(proxyPlaylistProvider);
+ final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
final playing =
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
@@ -256,20 +256,16 @@ class PlayerControls extends HookConsumerWidget {
onPressed: playlist.isFetching == true
? null
: () async {
- switch (await audioPlayer.loopMode) {
- case PlaybackLoopMode.all:
- audioPlayer
- .setLoopMode(PlaybackLoopMode.one);
- break;
- case PlaybackLoopMode.one:
- audioPlayer
- .setLoopMode(PlaybackLoopMode.none);
- break;
- case PlaybackLoopMode.none:
- audioPlayer
- .setLoopMode(PlaybackLoopMode.all);
- break;
- }
+ audioPlayer.setLoopMode(
+ switch (loopMode) {
+ PlaybackLoopMode.all =>
+ PlaybackLoopMode.one,
+ PlaybackLoopMode.one =>
+ PlaybackLoopMode.none,
+ PlaybackLoopMode.none =>
+ PlaybackLoopMode.all,
+ },
+ );
},
);
}),
diff --git a/lib/components/player/player_overlay.dart b/lib/components/player/player_overlay.dart
index 2d63811e..168e022d 100644
--- a/lib/components/player/player_overlay.dart
+++ b/lib/components/player/player_overlay.dart
@@ -19,16 +19,15 @@ class PlayerOverlay extends HookConsumerWidget {
const PlayerOverlay({
required this.albumArt,
- Key? key,
- }) : super(key: key);
+ super.key,
+ });
@override
Widget build(BuildContext context, ref) {
- final canShow = ref.watch(
- ProxyPlaylistNotifier.provider.select((s) => s.active != null),
- );
- final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
- final playlist = ref.watch(ProxyPlaylistNotifier.provider);
+ final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
+ final playlist = ref.watch(proxyPlaylistProvider);
+ final canShow = playlist.activeTrack != null;
+
final playing =
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
@@ -115,7 +114,7 @@ class PlayerOverlay extends HookConsumerWidget {
width: double.infinity,
color: Colors.transparent,
child: PlayerTrackDetails(
- albumArt: albumArt,
+ track: playlist.activeTrack,
color: textColor,
),
),
diff --git a/lib/components/player/player_queue.dart b/lib/components/player/player_queue.dart
index 2784fb5f..1665b3dd 100644
--- a/lib/components/player/player_queue.dart
+++ b/lib/components/player/player_queue.dart
@@ -5,30 +5,55 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
+import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:scroll_to_index/scroll_to_index.dart';
+import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.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/track_tile/track_tile.dart';
+import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart';
+import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
-import 'package:spotube/utils/type_conversion_utils.dart';
class PlayerQueue extends HookConsumerWidget {
final bool floating;
+ final ProxyPlaylist playlist;
+
+ final Future Function(Track track) onJump;
+ final Future Function(String trackId) onRemove;
+ final Future Function(int oldIndex, int newIndex) onReorder;
+ final Future Function() onStop;
+
const PlayerQueue({
this.floating = true,
- Key? key,
- }) : super(key: key);
+ required this.playlist,
+ required this.onJump,
+ required this.onRemove,
+ required this.onReorder,
+ required this.onStop,
+ super.key,
+ });
+
+ PlayerQueue.fromProxyPlaylistNotifier({
+ this.floating = true,
+ required this.playlist,
+ required ProxyPlaylistNotifier notifier,
+ super.key,
+ }) : onJump = notifier.jumpToTrack,
+ onRemove = notifier.removeTrack,
+ onReorder = notifier.moveTrack,
+ onStop = notifier.stop;
@override
Widget build(BuildContext context, ref) {
- final playlist = ref.watch(ProxyPlaylistNotifier.provider);
- final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
+ final mediaQuery = MediaQuery.of(context);
+
final controller = useAutoScrollController();
final searchText = useState('');
@@ -44,7 +69,6 @@ class PlayerQueue extends HookConsumerWidget {
topRight: Radius.circular(10),
);
final theme = Theme.of(context);
- final mediaQuery = MediaQuery.of(context);
final headlineColor = theme.textTheme.headlineSmall?.color;
final filteredTracks = useMemoized(
@@ -55,7 +79,7 @@ class PlayerQueue extends HookConsumerWidget {
return tracks
.map((e) => (
weightedRatio(
- '${e.name!} - ${TypeConversionUtils.artists_X_String(e.artists!)}',
+ '${e.name!} - ${e.artists?.asString() ?? ""}',
searchText.value,
),
e
@@ -83,201 +107,203 @@ class PlayerQueue extends HookConsumerWidget {
return const NotFound(vertical: true);
}
- return ClipRRect(
- borderRadius: borderRadius,
- clipBehavior: Clip.hardEdge,
- child: BackdropFilter(
- filter: ImageFilter.blur(
- sigmaX: 15,
- sigmaY: 15,
- ),
- child: Container(
- padding: const EdgeInsets.only(
- top: 5.0,
- ),
- decoration: BoxDecoration(
- color: theme.colorScheme.surfaceVariant.withOpacity(0.5),
- borderRadius: borderRadius,
- ),
- child: CallbackShortcuts(
- bindings: {
- LogicalKeySet(LogicalKeyboardKey.escape): () {
- if (!isSearching.value) {
- Navigator.of(context).pop();
- }
- isSearching.value = false;
- searchText.value = '';
- }
- },
- child: Column(
- children: [
- if (!floating)
- Container(
- height: 5,
- width: 100,
- margin: const EdgeInsets.only(bottom: 5, top: 2),
- decoration: BoxDecoration(
- color: headlineColor,
- borderRadius: BorderRadius.circular(20),
- ),
- ),
- Row(
- crossAxisAlignment: CrossAxisAlignment.center,
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- if (mediaQuery.mdAndUp || !isSearching.value) ...[
- const SizedBox(width: 10),
- Text(
- context.l10n.tracks_in_queue(tracks.length),
- style: TextStyle(
- color: headlineColor,
- fontWeight: FontWeight.bold,
- fontSize: 18,
- ),
- ),
- const Spacer(),
- ],
- if (mediaQuery.mdAndUp || isSearching.value)
- TextField(
- onChanged: (value) {
- searchText.value = value;
- },
- decoration: InputDecoration(
- hintText: context.l10n.search,
- isDense: true,
- prefixIcon: mediaQuery.smAndDown
- ? IconButton(
- icon: const Icon(
- Icons.arrow_back_ios_new_outlined,
- ),
- onPressed: () {
- isSearching.value = false;
- searchText.value = '';
- },
- style: IconButton.styleFrom(
- padding: EdgeInsets.zero,
- minimumSize: const Size.square(20),
- ),
- )
- : const Icon(SpotubeIcons.filter),
- constraints: BoxConstraints(
- maxHeight: 40,
- maxWidth: mediaQuery.smAndDown
- ? mediaQuery.size.width - 40
- : 300,
- ),
- ),
- )
- else
- IconButton.filledTonal(
- icon: const Icon(SpotubeIcons.filter),
- onPressed: () {
- isSearching.value = !isSearching.value;
- },
- ),
- if (mediaQuery.mdAndUp || !isSearching.value) ...[
- const SizedBox(width: 10),
- FilledButton(
- style: FilledButton.styleFrom(
- backgroundColor:
- theme.scaffoldBackgroundColor.withOpacity(0.5),
- foregroundColor: theme.textTheme.headlineSmall?.color,
- ),
- child: Row(
- children: [
- const Icon(SpotubeIcons.playlistRemove),
- const SizedBox(width: 5),
- Text(context.l10n.clear_all),
- ],
- ),
- onPressed: () {
- playlistNotifier.stop();
- Navigator.of(context).pop();
- },
- ),
- const SizedBox(width: 10),
- ],
- ],
- ),
- const SizedBox(height: 10),
- if (!isSearching.value && searchText.value.isEmpty)
- Flexible(
- child: ReorderableListView.builder(
- onReorder: (oldIndex, newIndex) {
- playlistNotifier.moveTrack(oldIndex, newIndex);
- },
- scrollController: controller,
- itemCount: tracks.length,
- shrinkWrap: true,
- buildDefaultDragHandles: false,
- onReorderStart: (index) {
- HapticFeedback.selectionClick();
- },
- onReorderEnd: (index) {
- HapticFeedback.selectionClick();
- },
- itemBuilder: (context, i) {
- final track = tracks.elementAt(i);
- return AutoScrollTag(
- key: ValueKey(i),
- controller: controller,
- index: i,
- child: Padding(
- padding:
- const EdgeInsets.symmetric(horizontal: 8.0),
- child: TrackTile(
- index: i,
- track: track,
- onTap: () async {
- if (playlist.activeTrack?.id == track.id) {
- return;
- }
- await playlistNotifier.jumpToTrack(track);
- },
- leadingActions: [
- ReorderableDragStartListener(
- index: i,
- child: const Icon(SpotubeIcons.dragHandle),
- ),
- ],
+ return LayoutBuilder(
+ builder: (context, constrains) {
+ return ClipRRect(
+ borderRadius: borderRadius,
+ clipBehavior: Clip.hardEdge,
+ child: BackdropFilter(
+ filter: ImageFilter.blur(
+ sigmaX: 15,
+ sigmaY: 15,
+ ),
+ child: Container(
+ padding: const EdgeInsets.only(
+ top: 5.0,
+ ),
+ decoration: BoxDecoration(
+ color:
+ theme.colorScheme.surfaceContainerHighest.withOpacity(0.5),
+ borderRadius: borderRadius,
+ ),
+ child: CallbackShortcuts(
+ bindings: {
+ LogicalKeySet(LogicalKeyboardKey.escape): () {
+ if (!isSearching.value) {
+ Navigator.of(context).pop();
+ }
+ isSearching.value = false;
+ searchText.value = '';
+ }
+ },
+ child: InterScrollbar(
+ controller: controller,
+ child: CustomScrollView(
+ controller: controller,
+ slivers: [
+ if (!floating)
+ SliverToBoxAdapter(
+ child: Center(
+ child: Container(
+ height: 5,
+ width: 100,
+ margin: const EdgeInsets.only(bottom: 5, top: 2),
+ decoration: BoxDecoration(
+ color: headlineColor,
+ borderRadius: BorderRadius.circular(20),
+ ),
),
),
- );
- },
- ),
- )
- else
- Flexible(
- child: InterScrollbar(
- controller: controller,
- child: ListView.builder(
- controller: controller,
+ ),
+ SliverAppBar(
+ floating: true,
+ pinned: false,
+ snap: false,
+ backgroundColor: Colors.transparent,
+ elevation: 0,
+ automaticallyImplyLeading: false,
+ title: BackdropFilter(
+ filter: ImageFilter.blur(
+ sigmaX: 10,
+ sigmaY: 10,
+ ),
+ child: SizedBox(
+ height: kToolbarHeight,
+ child: mediaQuery.mdAndUp || !isSearching.value
+ ? Align(
+ alignment: Alignment.centerLeft,
+ child: Text(
+ context.l10n
+ .tracks_in_queue(tracks.length),
+ style: TextStyle(
+ color: headlineColor,
+ fontWeight: FontWeight.bold,
+ fontSize: 18,
+ ),
+ ),
+ )
+ : null,
+ ),
+ ),
+ actions: [
+ if (mediaQuery.mdAndUp || isSearching.value)
+ TextField(
+ onChanged: (value) {
+ searchText.value = value;
+ },
+ decoration: InputDecoration(
+ hintText: context.l10n.search,
+ isDense: true,
+ prefixIcon: mediaQuery.smAndDown
+ ? IconButton(
+ icon: const Icon(
+ Icons.arrow_back_ios_new_outlined,
+ ),
+ onPressed: () {
+ isSearching.value = false;
+ searchText.value = '';
+ },
+ style: IconButton.styleFrom(
+ padding: EdgeInsets.zero,
+ minimumSize: const Size.square(20),
+ ),
+ )
+ : const Icon(SpotubeIcons.filter),
+ constraints: BoxConstraints(
+ maxHeight: 40,
+ maxWidth: mediaQuery.smAndDown
+ ? mediaQuery.size.width - 40
+ : 300,
+ ),
+ ),
+ )
+ else
+ IconButton.filledTonal(
+ icon: const Icon(SpotubeIcons.filter),
+ onPressed: () {
+ isSearching.value = !isSearching.value;
+ },
+ ),
+ if (mediaQuery.mdAndUp || !isSearching.value) ...[
+ const SizedBox(width: 10),
+ FilledButton(
+ style: FilledButton.styleFrom(
+ backgroundColor: theme.scaffoldBackgroundColor
+ .withOpacity(0.5),
+ foregroundColor:
+ theme.textTheme.headlineSmall?.color,
+ ),
+ child: Row(
+ children: [
+ const Icon(SpotubeIcons.playlistRemove),
+ const SizedBox(width: 5),
+ Text(context.l10n.clear_all),
+ ],
+ ),
+ onPressed: () {
+ onStop();
+ Navigator.of(context).pop();
+ },
+ ),
+ const SizedBox(width: 10),
+ ],
+ ],
+ ),
+ const SliverGap(10),
+ SliverReorderableList(
+ onReorder: onReorder,
itemCount: filteredTracks.length,
+ onReorderStart: (index) {
+ HapticFeedback.selectionClick();
+ },
+ onReorderEnd: (index) {
+ HapticFeedback.selectionClick();
+ },
itemBuilder: (context, i) {
final track = filteredTracks.elementAt(i);
- return Padding(
- padding:
- const EdgeInsets.symmetric(horizontal: 8.0),
- child: TrackTile(
- index: i,
- track: track,
- onTap: () async {
- if (playlist.activeTrack?.id == track.id) {
- return;
- }
- await playlistNotifier.jumpToTrack(track);
- },
+ return AutoScrollTag(
+ key: ValueKey(i),
+ controller: controller,
+ index: i,
+ child: Material(
+ color: Colors.transparent,
+ child: TrackTile(
+ playlist: playlist,
+ index: i,
+ track: track,
+ onTap: () async {
+ if (playlist.activeTrack?.id == track.id) {
+ return;
+ }
+ await onJump(track);
+ },
+ leadingActions: [
+ if (!isSearching.value &&
+ searchText.value.isEmpty)
+ Padding(
+ padding: const EdgeInsets.only(left: 8.0),
+ child: ReorderableDragStartListener(
+ index: i,
+ child: const Icon(
+ SpotubeIcons.dragHandle,
+ ),
+ ),
+ ),
+ ],
+ ),
),
);
},
),
- ),
+ const SliverGap(100),
+ ],
),
- ],
+ ),
+ ),
),
),
- ),
- ),
+ );
+ },
);
}
}
diff --git a/lib/components/player/player_track_details.dart b/lib/components/player/player_track_details.dart
index 66cb9ef5..4746fe51 100644
--- a/lib/components/player/player_track_details.dart
+++ b/lib/components/player/player_track_details.dart
@@ -4,23 +4,24 @@ import 'package:spotify/spotify.dart';
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
+import 'package:spotube/components/shared/links/artist_link.dart';
import 'package:spotube/components/shared/links/link_text.dart';
+import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/constrains.dart';
+import 'package:spotube/extensions/image.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/utils/service_utils.dart';
-import 'package:spotube/utils/type_conversion_utils.dart';
class PlayerTrackDetails extends HookConsumerWidget {
- final String? albumArt;
final Color? color;
- const PlayerTrackDetails({Key? key, this.albumArt, this.color})
- : super(key: key);
+ final Track? track;
+ const PlayerTrackDetails({super.key, this.color, this.track});
@override
Widget build(BuildContext context, ref) {
final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
- final playback = ref.watch(ProxyPlaylistNotifier.provider);
+ final playback = ref.watch(proxyPlaylistProvider);
return Row(
children: [
@@ -34,7 +35,8 @@ class PlayerTrackDetails extends HookConsumerWidget {
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: UniversalImage(
- path: albumArt ?? "",
+ path: (track?.album?.images)
+ .asUrlString(placeholder: ImagePlaceholder.albumArt),
placeholder: Assets.albumPlaceholder.path,
),
),
@@ -55,9 +57,7 @@ class PlayerTrackDetails extends HookConsumerWidget {
),
),
Text(
- TypeConversionUtils.artists_X_String(
- playback.activeTrack?.artists ?? [],
- ),
+ playback.activeTrack?.artists?.asString() ?? "",
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall!.copyWith(color: color),
)
@@ -76,8 +76,8 @@ class PlayerTrackDetails extends HookConsumerWidget {
overflow: TextOverflow.ellipsis,
style: TextStyle(fontWeight: FontWeight.bold, color: color),
),
- TypeConversionUtils.artists_X_ClickableArtists(
- playback.activeTrack?.artists ?? [],
+ ArtistLink(
+ artists: playback.activeTrack?.artists ?? [],
onRouteChange: (route) {
ServiceUtils.push(context, route);
},
diff --git a/lib/components/player/sibling_tracks_sheet.dart b/lib/components/player/sibling_tracks_sheet.dart
index 58b1ca8c..0575d8eb 100644
--- a/lib/components/player/sibling_tracks_sheet.dart
+++ b/lib/components/player/sibling_tracks_sheet.dart
@@ -4,17 +4,18 @@ import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:spotify/spotify.dart' hide Offset;
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
+import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/duration.dart';
import 'package:spotube/hooks/utils/use_debounce.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
+import 'package:spotube/provider/server/active_sourced_track.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
import 'package:spotube/services/sourced_track/models/source_info.dart';
@@ -24,7 +25,6 @@ import 'package:spotube/services/sourced_track/sources/jiosaavn.dart';
import 'package:spotube/services/sourced_track/sources/piped.dart';
import 'package:spotube/services/sourced_track/sources/youtube.dart';
import 'package:spotube/utils/service_utils.dart';
-import 'package:spotube/utils/type_conversion_utils.dart';
final sourceInfoToIconMap = {
YoutubeSourceInfo: const Icon(SpotubeIcons.youtube, color: Color(0xFFFF0000)),
@@ -45,29 +45,30 @@ final sourceInfoToIconMap = {
class SiblingTracksSheet extends HookConsumerWidget {
final bool floating;
const SiblingTracksSheet({
- Key? key,
+ super.key,
this.floating = true,
- }) : super(key: key);
+ });
@override
Widget build(BuildContext context, ref) {
final theme = Theme.of(context);
- final playlist = ref.watch(ProxyPlaylistNotifier.provider);
- final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
+ final playlist = ref.watch(proxyPlaylistProvider);
final preferences = ref.watch(userPreferencesProvider);
final isSearching = useState(false);
final searchMode = useState(preferences.searchMode);
+ final activeTrackNotifier = ref.watch(activeSourcedTrackProvider.notifier);
+ final activeTrack =
+ ref.watch(activeSourcedTrackProvider) ?? playlist.activeTrack;
final title = ServiceUtils.getTitle(
- playlist.activeTrack?.name ?? "",
- artists:
- playlist.activeTrack?.artists?.map((e) => e.name!).toList() ?? [],
+ activeTrack?.name ?? "",
+ artists: activeTrack?.artists?.map((e) => e.name!).toList() ?? [],
onlyCleanArtist: true,
).trim();
final defaultSearchTerm =
- "$title - ${TypeConversionUtils.artists_X_String(playlist.activeTrack?.artists ?? [])}";
+ "$title - ${activeTrack?.artists?.asString() ?? ""}";
final searchController = useTextEditingController(
text: defaultSearchTerm,
);
@@ -91,8 +92,7 @@ class SiblingTracksSheet extends HookConsumerWidget {
return siblingType.info;
}));
- final activeSourceInfo =
- (playlist.activeTrack! as SourcedTrack).sourceInfo;
+ final activeSourceInfo = (activeTrack! as SourcedTrack).sourceInfo;
return results
..removeWhere((element) => element.id == activeSourceInfo.id)
@@ -112,8 +112,7 @@ class SiblingTracksSheet extends HookConsumerWidget {
return siblingType.info;
}),
);
- final activeSourceInfo =
- (playlist.activeTrack! as SourcedTrack).sourceInfo;
+ final activeSourceInfo = (activeTrack! as SourcedTrack).sourceInfo;
return searchResults
..removeWhere((element) => element.id == activeSourceInfo.id)
..insert(
@@ -124,18 +123,18 @@ class SiblingTracksSheet extends HookConsumerWidget {
}, [
searchTerm,
searchMode.value,
- playlist.activeTrack,
+ activeTrack,
preferences.audioSource,
]);
final siblings = useMemoized(
() => playlist.isFetching == false
? [
- (playlist.activeTrack as SourcedTrack).sourceInfo,
- ...(playlist.activeTrack as SourcedTrack).siblings,
+ (activeTrack as SourcedTrack).sourceInfo,
+ ...activeTrack.siblings,
]
: [],
- [playlist.isFetching, playlist.activeTrack],
+ [playlist.isFetching, activeTrack],
);
final borderRadius = floating
@@ -146,12 +145,11 @@ class SiblingTracksSheet extends HookConsumerWidget {
);
useEffect(() {
- if (playlist.activeTrack is SourcedTrack &&
- (playlist.activeTrack as SourcedTrack).siblings.isEmpty) {
- playlistNotifier.populateSibling();
+ if (activeTrack is SourcedTrack && activeTrack.siblings.isEmpty) {
+ activeTrackNotifier.populateSibling();
}
return null;
- }, [playlist.activeTrack]);
+ }, [activeTrack]);
final itemBuilder = useCallback(
(SourceInfo sourceInfo) {
@@ -178,20 +176,18 @@ class SiblingTracksSheet extends HookConsumerWidget {
),
enabled: playlist.isFetching != true,
selected: playlist.isFetching != true &&
- sourceInfo.id ==
- (playlist.activeTrack as SourcedTrack).sourceInfo.id,
+ sourceInfo.id == (activeTrack as SourcedTrack).sourceInfo.id,
selectedTileColor: theme.popupMenuTheme.color,
onTap: () {
if (playlist.isFetching == false &&
- sourceInfo.id !=
- (playlist.activeTrack as SourcedTrack).sourceInfo.id) {
- playlistNotifier.swapSibling(sourceInfo);
+ sourceInfo.id != (activeTrack as SourcedTrack).sourceInfo.id) {
+ activeTrackNotifier.swapSibling(sourceInfo);
Navigator.of(context).pop();
}
},
);
},
- [playlist.isFetching, playlist.activeTrack, siblings],
+ [playlist.isFetching, activeTrack, siblings],
);
final mediaQuery = MediaQuery.of(context);
@@ -212,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,
diff --git a/lib/components/player/volume_slider.dart b/lib/components/player/volume_slider.dart
index 75445125..8483143b 100644
--- a/lib/components/player/volume_slider.dart
+++ b/lib/components/player/volume_slider.dart
@@ -1,39 +1,46 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
-
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart';
-import 'package:spotube/provider/volume_provider.dart';
class VolumeSlider extends HookConsumerWidget {
final bool fullWidth;
+
+ final double value;
+ final ValueChanged onChanged;
+
const VolumeSlider({
- Key? key,
+ super.key,
this.fullWidth = false,
- }) : super(key: key);
+ required this.value,
+ required this.onChanged,
+ });
@override
Widget build(BuildContext context, ref) {
- final volume = ref.watch(volumeProvider);
- final volumeNotifier = ref.watch(volumeProvider.notifier);
-
var slider = Listener(
onPointerSignal: (event) async {
if (event is PointerScrollEvent) {
if (event.scrollDelta.dy > 0) {
- final value = volume - .2;
- volumeNotifier.setVolume(value < 0 ? 0 : value);
+ final newValue = value - .2;
+ onChanged(newValue < 0 ? 0 : newValue);
} else {
- final value = volume + .2;
- volumeNotifier.setVolume(value > 1 ? 1 : value);
+ final newValue = value + .2;
+ onChanged(newValue > 1 ? 1 : newValue);
}
}
},
- child: Slider(
- min: 0,
- max: 1,
- value: volume,
- onChanged: volumeNotifier.setVolume,
+ 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(
@@ -42,20 +49,20 @@ class VolumeSlider extends HookConsumerWidget {
children: [
IconButton(
icon: Icon(
- volume == 0
+ value == 0
? SpotubeIcons.volumeMute
- : volume <= 0.2
+ : value <= 0.2
? SpotubeIcons.volumeLow
- : volume <= 0.6
+ : value <= 0.6
? SpotubeIcons.volumeMedium
: SpotubeIcons.volumeHigh,
size: 16,
),
onPressed: () {
- if (volume == 0) {
- volumeNotifier.setVolume(1);
+ if (value == 0) {
+ onChanged(1);
} else {
- volumeNotifier.setVolume(0);
+ onChanged(0);
}
},
),
diff --git a/lib/components/playlist/playlist_card.dart b/lib/components/playlist/playlist_card.dart
index f429a0ab..9f26f739 100644
--- a/lib/components/playlist/playlist_card.dart
+++ b/lib/components/playlist/playlist_card.dart
@@ -1,81 +1,81 @@
-import 'package:fl_query/fl_query.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
+import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
import 'package:spotube/components/shared/playbutton_card.dart';
-import 'package:spotube/extensions/infinite_query.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_provider.dart';
+import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
-import 'package:spotube/services/queries/queries.dart';
import 'package:spotube/utils/service_utils.dart';
-import 'package:spotube/utils/type_conversion_utils.dart';
class PlaylistCard extends HookConsumerWidget {
final PlaylistSimple playlist;
const PlaylistCard(
this.playlist, {
- Key? key,
- }) : super(key: key);
+ super.key,
+ });
@override
Widget build(BuildContext context, ref) {
- final playlistQueue = ref.watch(ProxyPlaylistNotifier.provider);
- final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
+ 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;
- final queryClient = QueryClient.of(context);
- final tracks = useState?>(null);
bool isPlaylistPlaying = useMemoized(
() => playlistQueue.containsCollection(playlist.id!),
[playlistQueue, playlist.id],
);
final updating = useState(false);
- final spotify = ref.watch(spotifyProvider);
- final me = useQueries.user.me(ref);
+ final me = ref.watch(meProvider);
- Future> fetchAllTracks() async {
+ Future> fetchInitialTracks() async {
if (playlist.id == 'user-liked-tracks') {
- return await queryClient.fetchQuery(
- "user-liked-tracks",
- () => useQueries.playlist.likedTracks(spotify),
- ) ??
- [];
+ return await ref.read(likedTracksProvider.future);
}
- final query = queryClient.createInfiniteQuery, dynamic, int>(
- "playlist-tracks/${playlist.id}",
- (page) => useQueries.playlist.tracksOf(page, spotify, playlist.id!),
- initialPage: 0,
- nextPage: useQueries.playlist.tracksOfQueryNextPage,
- );
+ final result =
+ await ref.read(playlistTracksProvider(playlist.id!).future);
- return await query.fetchAllTracks(
- getAllTracks: () async {
- final res =
- await spotify.playlists.getTracksByPlaylistId(playlist.id!).all();
- return res.toList();
- },
- );
+ return result.items;
+ }
+
+ Future> fetchAllTracks() async {
+ final initialTracks = await fetchInitialTracks();
+
+ if (playlist.id == 'user-liked-tracks') {
+ return initialTracks;
+ }
+
+ return ref.read(playlistTracksProvider(playlist.id!).notifier).fetchAll();
}
return PlaybuttonCard(
margin: const EdgeInsets.symmetric(horizontal: 10),
title: playlist.name!,
description: playlist.description,
- imageUrl: TypeConversionUtils.image_X_UrlString(
- playlist.images,
+ imageUrl: playlist.images.asUrlString(
placeholder: ImagePlaceholder.collection,
),
isPlaying: isPlaylistPlaying,
isLoading:
(isPlaylistPlaying && playlistQueue.isFetching) || updating.value,
- isOwner: playlist.owner?.id == me.data?.id && me.data?.id != null,
+ 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,
);
},
@@ -88,13 +88,30 @@ class PlaylistCard extends HookConsumerWidget {
return audioPlayer.resume();
}
- List fetchedTracks = await fetchAllTracks();
+ final fetchedInitialTracks = await fetchInitialTracks();
- if (fetchedTracks.isEmpty) return;
+ if (fetchedInitialTracks.isEmpty || !context.mounted) return;
- await playlistNotifier.load(fetchedTracks, autoPlay: true);
- playlistNotifier.addCollection(playlist.id!);
- tracks.value = fetchedTracks;
+ final isRemoteDevice = await showSelectDeviceDialog(context, ref);
+ if (isRemoteDevice) {
+ final remotePlayback = ref.read(connectProvider.notifier);
+ final allTracks = await fetchAllTracks();
+ await remotePlayback.load(
+ WebSocketLoadEventData.playlist(
+ tracks: allTracks,
+ collection: playlist,
+ ),
+ );
+ } else {
+ 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) {
updating.value = false;
@@ -106,21 +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!);
- tracks.value = fetchedTracks;
+ historyNotifier.addPlaylists([playlist]);
if (context.mounted) {
final snackbar = SnackBar(
- content: Text("Added ${tracks.value?.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!));
},
),
);
diff --git a/lib/components/playlist/playlist_create_dialog.dart b/lib/components/playlist/playlist_create_dialog.dart
index 2e11a209..bac98b64 100644
--- a/lib/components/playlist/playlist_create_dialog.dart
+++ b/lib/components/playlist/playlist_create_dialog.dart
@@ -5,6 +5,7 @@ import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:form_validator/form_validator.dart';
+import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:spotify/spotify.dart';
@@ -13,21 +14,19 @@ 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/provider/spotify/spotify.dart';
import 'package:spotube/provider/spotify_provider.dart';
-import 'package:spotube/services/mutations/mutations.dart';
-import 'package:spotube/services/mutations/playlist.dart';
-import 'package:spotube/services/queries/queries.dart';
-import 'package:spotube/utils/type_conversion_utils.dart';
class PlaylistCreateDialog extends HookConsumerWidget {
/// Track ids to add to the playlist
final List trackIds;
final String? playlistId;
PlaylistCreateDialog({
- Key? key,
+ super.key,
this.trackIds = const [],
this.playlistId,
- }) : super(key: key);
+ });
final formKey = GlobalKey();
@@ -37,13 +36,16 @@ class PlaylistCreateDialog extends HookConsumerWidget {
child: Scaffold(
backgroundColor: Colors.transparent,
body: HookBuilder(builder: (context) {
- final userPlaylists = useQueries.playlist.ofMine(ref);
+ final userPlaylists = ref.watch(favoritePlaylistsProvider);
+ final playlist = ref.watch(playlistProvider(playlistId ?? ""));
+ final playlistNotifier =
+ ref.watch(playlistProvider(playlistId ?? "").notifier);
+
final updatingPlaylist = useMemoized(
- () => userPlaylists.pages
- .expand((p) => p.items ?? [])
+ () => userPlaylists.asData?.value.items
.firstWhereOrNull((playlist) => playlist.id == playlistId),
[
- userPlaylists.pages,
+ userPlaylists.asData?.value.items,
playlistId,
],
);
@@ -84,28 +86,10 @@ class PlaylistCreateDialog extends HookConsumerWidget {
}
}, [scaffold, l10n, theme]);
- final playlistCreateMutation = useMutations.playlist.create(
- ref,
- trackIds: trackIds,
- onData: (value) {
- Navigator.pop(context);
- },
- onError: onError,
- );
-
- final playlistUpdateMutation = useMutations.playlist.update(
- ref,
- playlistId: playlistId,
- onData: (value) {
- Navigator.pop(context);
- },
- onError: onError,
- );
-
Future onCreate() async {
if (!formKey.currentState!.validate()) return;
- final PlaylistCRUDVariables payload = (
+ final PlaylistInput payload = (
playlistName: playlistName.text,
collaborative: collaborative.value,
public: public.value,
@@ -118,9 +102,14 @@ class PlaylistCreateDialog extends HookConsumerWidget {
);
if (isUpdatingPlaylist) {
- await playlistUpdateMutation.mutate(payload);
+ await playlistNotifier.modify(payload, onError);
} else {
- await playlistCreateMutation.mutate(payload);
+ await playlistNotifier.create(payload, onError);
+ }
+
+ if (context.mounted &&
+ !ref.read(playlistProvider(playlistId ?? "")).hasError) {
+ context.pop();
}
}
@@ -138,7 +127,7 @@ class PlaylistCreateDialog extends HookConsumerWidget {
},
),
FilledButton(
- onPressed: onCreate,
+ onPressed: playlist.isLoading ? null : onCreate,
child: Text(
isUpdatingPlaylist
? context.l10n.update
@@ -174,8 +163,7 @@ class PlaylistCreateDialog extends HookConsumerWidget {
children: [
UniversalImage(
path: field.value?.path ??
- TypeConversionUtils.image_X_UrlString(
- updatingPlaylist?.images,
+ (updatingPlaylist?.images).asUrlString(
placeholder: ImagePlaceholder.collection,
),
height: 200,
@@ -275,7 +263,7 @@ class PlaylistCreateDialog extends HookConsumerWidget {
}
class PlaylistCreateDialogButton extends HookConsumerWidget {
- const PlaylistCreateDialogButton({Key? key}) : super(key: key);
+ const PlaylistCreateDialogButton({super.key});
showPlaylistDialog(BuildContext context, SpotifyApi spotify) {
showDialog(
diff --git a/lib/components/root/bottom_player.dart b/lib/components/root/bottom_player.dart
index 617e760b..b99318df 100644
--- a/lib/components/root/bottom_player.dart
+++ b/lib/components/root/bottom_player.dart
@@ -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';
@@ -14,24 +13,25 @@ import 'package:spotube/components/player/player_controls.dart';
import 'package:spotube/components/player/volume_slider.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/extensions/image.dart';
import 'package:spotube/models/logger.dart';
import 'package:flutter/material.dart';
import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
+import 'package:spotube/provider/volume_provider.dart';
import 'package:spotube/utils/platform.dart';
-import 'package:spotube/utils/type_conversion_utils.dart';
+import 'package:window_manager/window_manager.dart';
class BottomPlayer extends HookConsumerWidget {
- BottomPlayer({Key? key}) : super(key: key);
+ BottomPlayer({super.key});
final logger = getLogger(BottomPlayer);
@override
Widget build(BuildContext context, ref) {
- final auth = ref.watch(AuthenticationNotifier.provider);
- final playlist = ref.watch(ProxyPlaylistNotifier.provider);
+ final auth = ref.watch(authenticationProvider);
+ final playlist = ref.watch(proxyPlaylistProvider);
final layoutMode =
ref.watch(userPreferencesProvider.select((s) => s.layoutMode));
@@ -39,8 +39,7 @@ class BottomPlayer extends HookConsumerWidget {
String albumArt = useMemoized(
() => playlist.activeTrack?.album?.images?.isNotEmpty == true
- ? TypeConversionUtils.image_X_UrlString(
- playlist.activeTrack?.album?.images,
+ ? (playlist.activeTrack?.album?.images).asUrlString(
index: (playlist.activeTrack?.album?.images?.length ?? 1) - 1,
placeholder: ImagePlaceholder.albumArt,
)
@@ -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,14 +60,18 @@ 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!,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
- Expanded(child: PlayerTrackDetails(albumArt: albumArt)),
+ Expanded(
+ child: PlayerTrackDetails(track: playlist.activeTrack),
+ ),
// controls
Flexible(
flex: 3,
@@ -93,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 {
@@ -122,10 +119,20 @@ class BottomPlayer extends HookConsumerWidget {
Container(
height: 40,
constraints: const BoxConstraints(maxWidth: 250),
- child: const VolumeSlider(),
+ padding: const EdgeInsets.only(right: 10),
+ child: Consumer(builder: (context, ref, _) {
+ final volume = ref.watch(volumeProvider);
+ return VolumeSlider(
+ fullWidth: true,
+ value: volume,
+ onChanged: (value) {
+ ref.read(volumeProvider.notifier).setVolume(value);
+ },
+ );
+ }),
)
],
- )
+ ),
],
),
),
diff --git a/lib/components/root/sidebar.dart b/lib/components/root/sidebar.dart
index a55ef947..4fa14021 100644
--- a/lib/components/root/sidebar.dart
+++ b/lib/components/root/sidebar.dart
@@ -1,5 +1,6 @@
import 'package:collection/collection.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:flutter/material.dart';
@@ -8,31 +9,30 @@ import 'package:sidebarx/sidebarx.dart';
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/collections/side_bar_tiles.dart';
import 'package:spotube/collections/spotube_icons.dart';
+import 'package:spotube/components/connect/connect_device.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/hooks/utils/use_brightness_value.dart';
+import 'package:spotube/extensions/image.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';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
-import 'package:spotube/services/queries/queries.dart';
import 'package:spotube/utils/platform.dart';
-import 'package:spotube/utils/type_conversion_utils.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,
- Key? key,
- }) : super(key: key);
+ super.key,
+ });
static Widget brandLogo() {
return Container(
@@ -44,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;
@@ -57,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;
@@ -103,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);
@@ -116,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,
);
},
@@ -163,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),
@@ -195,7 +185,7 @@ class Sidebar extends HookConsumerWidget {
}
class SidebarHeader extends HookWidget {
- const SidebarHeader({Key? key}) : super(key: key);
+ const SidebarHeader({super.key});
@override
Widget build(BuildContext context) {
@@ -234,71 +224,83 @@ class SidebarHeader extends HookWidget {
class SidebarFooter extends HookConsumerWidget {
const SidebarFooter({
- Key? key,
- }) : super(key: key);
+ super.key,
+ });
@override
Widget build(BuildContext context, ref) {
final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
- final me = useQueries.user.me(ref);
- final data = me.data;
+ final me = ref.watch(meProvider);
+ final data = me.asData?.value;
- final avatarImg = TypeConversionUtils.image_X_UrlString(
- data?.images,
+ final avatarImg = (data?.images).asUrlString(
index: (data?.images?.length ?? 1) - 1,
placeholder: ImagePlaceholder.artist,
);
- final auth = ref.watch(AuthenticationNotifier.provider);
+ final auth = ref.watch(authenticationProvider);
if (mediaQuery.mdAndDown) {
return IconButton(
icon: const Icon(SpotubeIcons.settings),
- onPressed: () => Sidebar.goToSettings(context),
+ onPressed: () => ServiceUtils.navigateNamed(context, SettingsPage.name),
);
}
return Container(
padding: const EdgeInsets.only(left: 12),
width: 250,
- child: Row(
- mainAxisSize: MainAxisSize.min,
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ child: Column(
children: [
- if (auth != null && data == null)
- const CircularProgressIndicator()
- else if (data != null)
- Flexible(
- child: Row(
- children: [
- CircleAvatar(
- backgroundImage: UniversalImage.imageProvider(avatarImg),
- onBackgroundImageError: (exception, stackTrace) =>
- Assets.userPlaceholder.image(
- height: 16,
- width: 16,
+ const ConnectDeviceButton.sidebar(),
+ const Gap(10),
+ Row(
+ mainAxisSize: MainAxisSize.min,
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ if (auth != null && data == null)
+ const CircularProgressIndicator()
+ else if (data != null)
+ Flexible(
+ child: InkWell(
+ onTap: () {
+ ServiceUtils.pushNamed(context, ProfilePage.name);
+ },
+ borderRadius: BorderRadius.circular(30),
+ child: Row(
+ children: [
+ CircleAvatar(
+ backgroundImage:
+ UniversalImage.imageProvider(avatarImg),
+ onBackgroundImageError: (exception, stackTrace) =>
+ Assets.userPlaceholder.image(
+ height: 16,
+ width: 16,
+ ),
+ ),
+ const SizedBox(width: 10),
+ Flexible(
+ child: Text(
+ data.displayName ?? context.l10n.guest,
+ maxLines: 1,
+ softWrap: false,
+ overflow: TextOverflow.fade,
+ style: theme.textTheme.bodyMedium
+ ?.copyWith(fontWeight: FontWeight.bold),
+ ),
+ ),
+ ],
),
),
- const SizedBox(width: 10),
- Flexible(
- child: Text(
- data.displayName ?? context.l10n.guest,
- maxLines: 1,
- softWrap: false,
- overflow: TextOverflow.fade,
- style: theme.textTheme.bodyMedium
- ?.copyWith(fontWeight: FontWeight.bold),
- ),
- ),
- ],
+ ),
+ IconButton(
+ icon: const Icon(SpotubeIcons.settings),
+ onPressed: () {
+ ServiceUtils.pushNamed(context, SettingsPage.name);
+ },
),
- ),
- IconButton(
- icon: const Icon(SpotubeIcons.settings),
- onPressed: () {
- Sidebar.goToSettings(context);
- },
+ ],
),
],
),
diff --git a/lib/components/root/spotube_navigation_bar.dart b/lib/components/root/spotube_navigation_bar.dart
index 0853c60c..3d0c7c75 100644
--- a/lib/components/root/spotube_navigation_bar.dart
+++ b/lib/components/root/spotube_navigation_bar.dart
@@ -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((ref) => 50);
class SpotubeNavigationBar extends HookConsumerWidget {
- final int? selectedIndex;
- final void Function(int) onSelectedIndexChanged;
-
const SpotubeNavigationBar({
- required this.selectedIndex,
- required this.onSelectedIndexChanged,
- Key? key,
- }) : super(key: key);
+ 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(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);
},
),
),
diff --git a/lib/components/root/update_dialog.dart b/lib/components/root/update_dialog.dart
new file mode 100644
index 00000000..e15903c6
--- /dev/null
+++ b/lib/components/root/update_dialog.dart
@@ -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,
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/components/settings/color_scheme_picker_dialog.dart b/lib/components/settings/color_scheme_picker_dialog.dart
index e0c3d618..579f5a29 100644
--- a/lib/components/settings/color_scheme_picker_dialog.dart
+++ b/lib/components/settings/color_scheme_picker_dialog.dart
@@ -8,9 +8,9 @@ import 'package:system_theme/system_theme.dart';
class SpotubeColor extends Color {
final String name;
- const SpotubeColor(int color, {required this.name}) : super(color);
+ const SpotubeColor(super.color, {required this.name});
- const SpotubeColor.from(int value, {required this.name}) : super(value);
+ const SpotubeColor.from(super.value, {required this.name});
factory SpotubeColor.fromString(String string) {
final slices = string.split(":");
@@ -44,7 +44,7 @@ final Set colorsMap = {
};
class ColorSchemePickerDialog extends HookConsumerWidget {
- const ColorSchemePickerDialog({Key? key}) : super(key: key);
+ const ColorSchemePickerDialog({super.key});
@override
Widget build(BuildContext context, ref) {
@@ -119,8 +119,8 @@ class ColorTile extends StatelessWidget {
this.onPressed,
this.tooltip = "",
this.isCompact = false,
- Key? key,
- }) : super(key: key);
+ super.key,
+ });
factory ColorTile.compact({
required Color color,
@@ -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,
];
diff --git a/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart b/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart
index 21f56a22..ce7d3b8c 100644
--- a/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart
+++ b/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart
@@ -187,7 +187,7 @@ class AdaptivePopSheetList extends StatelessWidget {
icon: icon ?? const Icon(SpotubeIcons.moreVertical),
tooltip: tooltip,
style: theme.iconButtonTheme.style?.copyWith(
- shape: MaterialStatePropertyAll(
+ shape: WidgetStatePropertyAll(
RoundedRectangleBorder(
borderRadius: borderRadius,
),
diff --git a/lib/components/shared/adaptive/adaptive_popup_menu_button.dart b/lib/components/shared/adaptive/adaptive_popup_menu_button.dart
index 45f22825..02fced52 100644
--- a/lib/components/shared/adaptive/adaptive_popup_menu_button.dart
+++ b/lib/components/shared/adaptive/adaptive_popup_menu_button.dart
@@ -12,13 +12,13 @@ class Action extends StatelessWidget {
final bool isExpanded;
final Color? backgroundColor;
const Action({
- Key? key,
+ super.key,
required this.icon,
required this.text,
required this.onPressed,
this.isExpanded = true,
this.backgroundColor,
- }) : super(key: key);
+ });
@override
Widget build(BuildContext context) {
diff --git a/lib/components/shared/adaptive/adaptive_select_tile.dart b/lib/components/shared/adaptive/adaptive_select_tile.dart
index 58666e46..3f6d2700 100644
--- a/lib/components/shared/adaptive/adaptive_select_tile.dart
+++ b/lib/components/shared/adaptive/adaptive_select_tile.dart
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/constrains.dart';
class AdaptiveSelectTile extends HookWidget {
@@ -38,11 +39,22 @@ class AdaptiveSelectTile extends HookWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
- final rawControl = DropdownButton(
- items: options,
- value: value,
- onChanged: onChanged,
- menuMaxHeight: mediaQuery.size.height * 0.6,
+ final rawControl = DecoratedBox(
+ decoration: BoxDecoration(
+ color: theme.colorScheme.secondaryContainer,
+ borderRadius: BorderRadius.circular(10),
+ ),
+ child: DropdownButton(
+ items: options,
+ value: value,
+ onChanged: onChanged,
+ menuMaxHeight: mediaQuery.size.height * 0.6,
+ underline: const SizedBox.shrink(),
+ padding: const EdgeInsets.symmetric(horizontal: 10),
+ borderRadius: BorderRadius.circular(10),
+ icon: const Icon(SpotubeIcons.angleDown),
+ dropdownColor: theme.colorScheme.secondaryContainer,
+ ),
);
final controlPlaceholder = useMemoized(
() => options
diff --git a/lib/components/shared/animated_gradient.dart b/lib/components/shared/animated_gradient.dart
index b6485f6b..aaba2ff9 100644
--- a/lib/components/shared/animated_gradient.dart
+++ b/lib/components/shared/animated_gradient.dart
@@ -3,7 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
class AnimateGradient extends HookWidget {
const AnimateGradient({
- Key? key,
+ super.key,
required this.primaryColors,
required this.secondaryColors,
this.child,
@@ -17,8 +17,7 @@ class AnimateGradient extends HookWidget {
this.reverse = true,
}) : assert(primaryColors.length >= 2),
assert(primaryColors.length == secondaryColors.length),
- _controller = controller,
- super(key: key);
+ _controller = controller;
/// [controller]: pass this to have a fine control over the [Animation]
final AnimationController? _controller;
diff --git a/lib/components/shared/bordered_text.dart b/lib/components/shared/bordered_text.dart
index 627b2a3c..f25f2208 100644
--- a/lib/components/shared/bordered_text.dart
+++ b/lib/components/shared/bordered_text.dart
@@ -79,7 +79,7 @@ class BorderedText extends StatelessWidget {
strutStyle: child.strutStyle,
textAlign: child.textAlign,
textDirection: child.textDirection,
- textScaleFactor: child.textScaleFactor,
+ textScaler: child.textScaler,
),
child,
],
diff --git a/lib/components/shared/compact_search.dart b/lib/components/shared/compact_search.dart
index 70815291..d37cb673 100644
--- a/lib/components/shared/compact_search.dart
+++ b/lib/components/shared/compact_search.dart
@@ -11,12 +11,12 @@ class CompactSearch extends HookWidget {
final Color? iconColor;
const CompactSearch({
- Key? key,
+ super.key,
this.onChanged,
this.placeholder = "Search...",
this.icon = SpotubeIcons.search,
this.iconColor,
- }) : super(key: key);
+ });
@override
Widget build(BuildContext context) {
diff --git a/lib/components/shared/dialogs/confirm_download_dialog.dart b/lib/components/shared/dialogs/confirm_download_dialog.dart
index c371e803..486310a7 100644
--- a/lib/components/shared/dialogs/confirm_download_dialog.dart
+++ b/lib/components/shared/dialogs/confirm_download_dialog.dart
@@ -5,7 +5,7 @@ import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
class ConfirmDownloadDialog extends StatelessWidget {
- const ConfirmDownloadDialog({Key? key}) : super(key: key);
+ const ConfirmDownloadDialog({super.key});
@override
Widget build(BuildContext context) {
@@ -82,7 +82,7 @@ class ConfirmDownloadDialog extends StatelessWidget {
class BulletPoint extends StatelessWidget {
final String text;
- const BulletPoint(this.text, {Key? key}) : super(key: key);
+ const BulletPoint(this.text, {super.key});
@override
Widget build(BuildContext context) {
diff --git a/lib/components/shared/dialogs/piped_down_dialog.dart b/lib/components/shared/dialogs/piped_down_dialog.dart
index 6220adeb..b1717a2a 100644
--- a/lib/components/shared/dialogs/piped_down_dialog.dart
+++ b/lib/components/shared/dialogs/piped_down_dialog.dart
@@ -5,7 +5,7 @@ import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
class PipedDownDialog extends HookConsumerWidget {
- const PipedDownDialog({Key? key}) : super(key: key);
+ const PipedDownDialog({super.key});
@override
Widget build(BuildContext context, ref) {
diff --git a/lib/components/shared/dialogs/playlist_add_track_dialog.dart b/lib/components/shared/dialogs/playlist_add_track_dialog.dart
index 51b77c76..5d493a68 100644
--- a/lib/components/shared/dialogs/playlist_add_track_dialog.dart
+++ b/lib/components/shared/dialogs/playlist_add_track_dialog.dart
@@ -1,4 +1,3 @@
-import 'package:fl_query_hooks/fl_query_hooks.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
@@ -8,9 +7,8 @@ import 'package:spotify/spotify.dart';
import 'package:spotube/components/playlist/playlist_create_dialog.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/context.dart';
-import 'package:spotube/provider/spotify_provider.dart';
-import 'package:spotube/services/queries/queries.dart';
-import 'package:spotube/utils/type_conversion_utils.dart';
+import 'package:spotube/extensions/image.dart';
+import 'package:spotube/provider/spotify/spotify.dart';
class PlaylistAddTrackDialog extends HookConsumerWidget {
/// The id of the playlist this dialog was opened from
@@ -19,33 +17,40 @@ class PlaylistAddTrackDialog extends HookConsumerWidget {
const PlaylistAddTrackDialog({
required this.tracks,
required this.openFromPlaylist,
- Key? key,
- }) : super(key: key);
+ super.key,
+ });
@override
Widget build(BuildContext context, ref) {
final ThemeData(:textTheme) = Theme.of(context);
- final spotify = ref.watch(spotifyProvider);
- final userPlaylists = useQueries.playlist.ofMineAll(ref);
+ final userPlaylists = ref.watch(favoritePlaylistsProvider);
+ final favoritePlaylistsNotifier =
+ ref.watch(favoritePlaylistsProvider.notifier);
- final me = useQueries.user.me(ref);
+ final me = ref.watch(meProvider);
final filteredPlaylists = useMemoized(
() =>
- userPlaylists.data
- ?.where(
+ userPlaylists.asData?.value.items
+ .where(
(playlist) =>
playlist.owner?.id != null &&
- playlist.owner!.id == me.data?.id &&
+ playlist.owner!.id == me.asData?.value.id &&
playlist.id != openFromPlaylist,
)
.toList() ??
[],
- [userPlaylists.data, me.data?.id, openFromPlaylist],
+ [userPlaylists.asData?.value, me.asData?.value.id, openFromPlaylist],
);
final playlistsCheck = useState({});
- final queryClient = useQueryClient();
+
+ useEffect(() {
+ if (userPlaylists.asData?.value != null) {
+ favoritePlaylistsNotifier.fetchAll();
+ }
+ return null;
+ }, [userPlaylists.asData?.value]);
Future onAdd() async {
final selectedPlaylists = playlistsCheck.value.entries
@@ -54,21 +59,12 @@ class PlaylistAddTrackDialog extends HookConsumerWidget {
await Future.wait(
selectedPlaylists.map(
- (playlistId) => spotify.playlists.addTracks(
- tracks
- .map(
- (track) => track.uri!,
- )
- .toList(),
- playlistId),
+ (playlistId) => favoritePlaylistsNotifier.addTracks(
+ playlistId,
+ tracks.map((e) => e.id!).toList(),
+ ),
),
).then((_) => Navigator.pop(context, true));
-
- await queryClient.refreshQueries(
- selectedPlaylists
- .map((playlistId) => "playlist-tracks/$playlistId")
- .toList(),
- );
}
return AlertDialog(
@@ -109,8 +105,7 @@ class PlaylistAddTrackDialog extends HookConsumerWidget {
return CheckboxListTile(
secondary: CircleAvatar(
backgroundImage: UniversalImage.imageProvider(
- TypeConversionUtils.image_X_UrlString(
- playlist.images,
+ playlist.images.asUrlString(
placeholder: ImagePlaceholder.collection,
),
),
diff --git a/lib/components/shared/dialogs/replace_downloaded_dialog.dart b/lib/components/shared/dialogs/replace_downloaded_dialog.dart
index 77721041..00461d34 100644
--- a/lib/components/shared/dialogs/replace_downloaded_dialog.dart
+++ b/lib/components/shared/dialogs/replace_downloaded_dialog.dart
@@ -8,8 +8,7 @@ final replaceDownloadedFileState = StateProvider((ref) => null);
class ReplaceDownloadedDialog extends ConsumerWidget {
final Track track;
- const ReplaceDownloadedDialog({required this.track, Key? key})
- : super(key: key);
+ const ReplaceDownloadedDialog({required this.track, super.key});
@override
Widget build(BuildContext context, ref) {
diff --git a/lib/components/shared/dialogs/select_device_dialog.dart b/lib/components/shared/dialogs/select_device_dialog.dart
new file mode 100644
index 00000000..cd8dedb7
--- /dev/null
+++ b/lib/components/shared/dialogs/select_device_dialog.dart
@@ -0,0 +1,70 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:spotube/extensions/context.dart';
+import 'package:spotube/provider/connect/clients.dart';
+
+class SelectDeviceDialog extends HookConsumerWidget {
+ const SelectDeviceDialog({super.key});
+
+ @override
+ Widget build(BuildContext context, ref) {
+ final isRemoteService = useState(false);
+
+ final connectClients = ref.watch(connectClientsProvider);
+ final remoteService = connectClients.asData!.value.resolvedService!;
+
+ return AlertDialog(
+ title: const Text("Choose the device:"),
+ insetPadding: const EdgeInsets.all(16),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Text(
+ "There are multiple device connected.\n"
+ "Choose the device you want this action to take place",
+ ),
+ RadioListTile.adaptive(
+ title: Text(remoteService.name),
+ value: true,
+ groupValue: isRemoteService.value,
+ onChanged: (value) {
+ isRemoteService.value = value!;
+ },
+ ),
+ RadioListTile.adaptive(
+ title: const Text("This Device"),
+ value: false,
+ groupValue: isRemoteService.value,
+ onChanged: (value) {
+ isRemoteService.value = !value!;
+ },
+ ),
+ ],
+ ),
+ actions: [
+ TextButton(
+ onPressed: () {
+ Navigator.of(context).pop(isRemoteService.value);
+ },
+ child: Text(context.l10n.select),
+ ),
+ ],
+ );
+ }
+}
+
+Future showSelectDeviceDialog(BuildContext context, WidgetRef ref) async {
+ final connectClients = ref.read(connectClientsProvider);
+
+ if (connectClients.asData?.value.resolvedService == null) {
+ return false;
+ }
+
+ final isRemote = await showDialog(
+ context: context,
+ builder: (context) => const SelectDeviceDialog(),
+ );
+
+ return isRemote ?? false;
+}
diff --git a/lib/components/shared/dialogs/track_details_dialog.dart b/lib/components/shared/dialogs/track_details_dialog.dart
index 8634776f..da2a140b 100644
--- a/lib/components/shared/dialogs/track_details_dialog.dart
+++ b/lib/components/shared/dialogs/track_details_dialog.dart
@@ -2,20 +2,20 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
+import 'package:spotube/components/shared/links/artist_link.dart';
import 'package:spotube/components/shared/links/hyper_link.dart';
import 'package:spotube/components/shared/links/link_text.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
-import 'package:spotube/utils/type_conversion_utils.dart';
import 'package:spotube/extensions/duration.dart';
class TrackDetailsDialog extends HookWidget {
final Track track;
const TrackDetailsDialog({
- Key? key,
+ super.key,
required this.track,
- }) : super(key: key);
+ });
@override
Widget build(BuildContext context) {
@@ -24,8 +24,8 @@ class TrackDetailsDialog extends HookWidget {
final detailsMap = {
context.l10n.title: track.name!,
- context.l10n.artist: TypeConversionUtils.artists_X_ClickableArtists(
- track.artists ?? [],
+ context.l10n.artist: ArtistLink(
+ artists: track.artists ?? [],
mainAxisAlignment: WrapAlignment.start,
textStyle: const TextStyle(color: Colors.blue),
),
diff --git a/lib/components/shared/expandable_search/expandable_search.dart b/lib/components/shared/expandable_search/expandable_search.dart
index 75ac6841..157e180f 100644
--- a/lib/components/shared/expandable_search/expandable_search.dart
+++ b/lib/components/shared/expandable_search/expandable_search.dart
@@ -10,12 +10,12 @@ class ExpandableSearchField extends StatelessWidget {
final FocusNode searchFocus;
const ExpandableSearchField({
- Key? key,
+ super.key,
required this.isFiltering,
required this.onChangeFiltering,
required this.searchController,
required this.searchFocus,
- }) : super(key: key);
+ });
@override
Widget build(BuildContext context) {
@@ -60,12 +60,12 @@ class ExpandableSearchButton extends StatelessWidget {
final ValueChanged? onPressed;
const ExpandableSearchButton({
- Key? key,
+ super.key,
required this.isFiltering,
required this.searchFocus,
this.icon = const Icon(SpotubeIcons.filter),
this.onPressed,
- }) : super(key: key);
+ });
@override
Widget build(BuildContext context) {
diff --git a/lib/components/shared/fallbacks/anonymous_fallback.dart b/lib/components/shared/fallbacks/anonymous_fallback.dart
index aea7bf38..5ced6bb6 100644
--- a/lib/components/shared/fallbacks/anonymous_fallback.dart
+++ b/lib/components/shared/fallbacks/anonymous_fallback.dart
@@ -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';
@@ -8,13 +9,13 @@ import 'package:spotube/utils/service_utils.dart';
class AnonymousFallback extends ConsumerWidget {
final Widget? child;
const AnonymousFallback({
- Key? key,
+ super.key,
this.child,
- }) : super(key: key);
+ });
@override
Widget build(BuildContext context, ref) {
- final isLoggedIn = ref.watch(AuthenticationNotifier.provider) != null;
+ final isLoggedIn = ref.watch(authenticationProvider) != null;
if (isLoggedIn && child != null) return child!;
return Center(
@@ -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),
)
],
),
diff --git a/lib/components/shared/fallbacks/not_found.dart b/lib/components/shared/fallbacks/not_found.dart
index f45573ad..5a74f672 100644
--- a/lib/components/shared/fallbacks/not_found.dart
+++ b/lib/components/shared/fallbacks/not_found.dart
@@ -3,7 +3,7 @@ import 'package:spotube/collections/assets.gen.dart';
class NotFound extends StatelessWidget {
final bool vertical;
- const NotFound({Key? key, this.vertical = false}) : super(key: key);
+ const NotFound({super.key, this.vertical = false});
@override
Widget build(BuildContext context) {
diff --git a/lib/components/shared/heart_button.dart b/lib/components/shared/heart_button.dart
index 81ccffdb..c296d7a9 100644
--- a/lib/components/shared/heart_button.dart
+++ b/lib/components/shared/heart_button.dart
@@ -1,5 +1,3 @@
-import 'package:fl_query/fl_query.dart';
-import 'package:fl_query_hooks/fl_query_hooks.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -8,8 +6,7 @@ import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/scrobbler_provider.dart';
-import 'package:spotube/services/mutations/mutations.dart';
-import 'package:spotube/services/queries/queries.dart';
+import 'package:spotube/provider/spotify/spotify.dart';
class HeartButton extends HookConsumerWidget {
final bool isLiked;
@@ -23,12 +20,12 @@ class HeartButton extends HookConsumerWidget {
this.color,
this.tooltip,
this.icon,
- Key? key,
- }) : super(key: key);
+ super.key,
+ });
@override
Widget build(BuildContext context, ref) {
- final auth = ref.watch(AuthenticationNotifier.provider);
+ final auth = ref.watch(authenticationProvider);
if (auth == null) return const SizedBox.shrink();
@@ -60,90 +57,50 @@ class HeartButton extends HookConsumerWidget {
typedef UseTrackToggleLike = ({
bool isLiked,
- Mutation toggleTrackLike,
- Query me,
+ Future Function(Track track) toggleTrackLike,
});
UseTrackToggleLike useTrackToggleLike(Track track, WidgetRef ref) {
- final me = useQueries.user.me(ref);
-
- final savedTracks = useQueries.playlist.likedTracksQuery(ref);
+ final savedTracks = ref.watch(likedTracksProvider);
+ final savedTracksNotifier = ref.watch(likedTracksProvider.notifier);
final isLiked = useMemoized(
- () => savedTracks.data?.any((element) => element.id == track.id) ?? false,
- [savedTracks.data, track.id],
+ () =>
+ savedTracks.asData?.value.any((element) => element.id == track.id) ??
+ false,
+ [savedTracks.asData?.value, track.id],
);
- final mounted = useIsMounted();
-
final scrobblerNotifier = ref.read(scrobblerProvider.notifier);
- final toggleTrackLike = useMutations.track.toggleFavorite(
- ref,
- track.id!,
- onMutate: (isLiked) {
- if (isLiked) {
- savedTracks.setData(
- savedTracks.data
- ?.where((element) => element.id != track.id)
- .toList() ??
- [],
- );
- } else {
- savedTracks.setData(
- [
- ...?savedTracks.data,
- track,
- ],
- );
- }
- return isLiked;
- },
- onData: (isLiked, recoveryData) async {
- await savedTracks.refresh();
- if (isLiked) {
+ return (
+ isLiked: isLiked,
+ toggleTrackLike: (track) async {
+ await savedTracksNotifier.toggleFavorite(track);
+
+ if (!isLiked) {
await scrobblerNotifier.love(track);
} else {
await scrobblerNotifier.unlove(track);
}
},
- onError: (payload, isLiked) {
- if (!mounted()) return;
-
- if (isLiked != true) {
- savedTracks.setData(
- savedTracks.data
- ?.where((element) => element.id != track.id)
- .toList() ??
- [],
- );
- } else {
- savedTracks.setData(
- [
- ...?savedTracks.data,
- track,
- ],
- );
- }
- },
);
-
- return (isLiked: isLiked, toggleTrackLike: toggleTrackLike, me: me);
}
class TrackHeartButton extends HookConsumerWidget {
final Track track;
const TrackHeartButton({
- Key? key,
+ super.key,
required this.track,
- }) : super(key: key);
+ });
@override
Widget build(BuildContext context, ref) {
- final savedTracks = useQueries.playlist.likedTracksQuery(ref);
- final (:me, :isLiked, :toggleTrackLike) = useTrackToggleLike(track, ref);
+ final savedTracks = ref.watch(likedTracksProvider);
+ final me = ref.watch(meProvider);
+ final (:isLiked, :toggleTrackLike) = useTrackToggleLike(track, ref);
- if (me.isLoading || !me.hasData) {
+ if (me.isLoading) {
return const CircularProgressIndicator();
}
@@ -152,104 +109,9 @@ class TrackHeartButton extends HookConsumerWidget {
? context.l10n.remove_from_favorites
: context.l10n.save_as_favorite,
isLiked: isLiked,
- onPressed: savedTracks.hasData
+ onPressed: savedTracks.asData?.value != null
? () {
- toggleTrackLike.mutate(isLiked);
- }
- : null,
- );
- }
-}
-
-class PlaylistHeartButton extends HookConsumerWidget {
- final PlaylistSimple playlist;
- final IconData? icon;
- final ValueChanged? onData;
-
- const PlaylistHeartButton({
- required this.playlist,
- Key? key,
- this.icon,
- this.onData,
- }) : super(key: key);
-
- @override
- Widget build(BuildContext context, ref) {
- final me = useQueries.user.me(ref);
-
- final isLikedQuery = useQueries.playlist.doesUserFollow(
- ref,
- playlist.id!,
- me.data?.id ?? '',
- );
-
- final togglePlaylistLike = useMutations.playlist.toggleFavorite(
- ref,
- playlist.id!,
- refreshQueries: [
- isLikedQuery.key,
- ],
- onData: onData,
- );
-
- if (me.isLoading || !me.hasData) {
- return const CircularProgressIndicator();
- }
-
- return HeartButton(
- isLiked: isLikedQuery.data ?? false,
- tooltip: isLikedQuery.data ?? false
- ? context.l10n.remove_from_favorites
- : context.l10n.save_as_favorite,
- color: Colors.white,
- icon: icon,
- onPressed: isLikedQuery.hasData
- ? () {
- togglePlaylistLike.mutate(isLikedQuery.data!);
- }
- : null,
- );
- }
-}
-
-class AlbumHeartButton extends HookConsumerWidget {
- final AlbumSimple album;
-
- const AlbumHeartButton({
- required this.album,
- Key? key,
- }) : super(key: key);
-
- @override
- Widget build(BuildContext context, ref) {
- final client = useQueryClient();
- final me = useQueries.user.me(ref);
-
- final albumIsSaved = useQueries.album.isSavedForMe(ref, album.id!);
- final isLiked = albumIsSaved.data ?? false;
-
- final toggleAlbumLike = useMutations.album.toggleFavorite(
- ref,
- album.id!,
- refreshQueries: [albumIsSaved.key],
- onData: (_, __) async {
- await client.refreshInfiniteQueryAllPages("current-user-albums");
- },
- );
-
- if (me.isLoading || !me.hasData) {
- return const CircularProgressIndicator();
- }
-
- return HeartButton(
- isLiked: isLiked,
- tooltip: isLiked
- ? context.l10n.remove_from_favorites
- : context.l10n.save_as_favorite,
- color: Colors.white,
- onPressed: albumIsSaved.hasData
- ? () {
- toggleAlbumLike.mutate(isLiked);
+ toggleTrackLike(track);
}
: null,
);
diff --git a/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart b/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart
index dc9d30da..291950bb 100644
--- a/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart
+++ b/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart
@@ -17,20 +17,22 @@ class HorizontalPlaybuttonCardView extends HookWidget {
final VoidCallback onFetchMore;
final bool isLoadingNextPage;
final bool hasNextPage;
+ final Widget? titleTrailing;
- const HorizontalPlaybuttonCardView({
+ HorizontalPlaybuttonCardView({
required this.title,
required this.items,
required this.hasNextPage,
required this.onFetchMore,
required this.isLoadingNextPage,
- Key? key,
- }) : assert(
- items is List ||
- items is List ||
- items is List,
- ),
- super(key: key);
+ this.titleTrailing,
+ super.key,
+ }) : assert(
+ items.every(
+ (item) =>
+ item is PlaylistSimple || item is Artist || item is AlbumSimple,
+ ),
+ );
@override
Widget build(BuildContext context) {
@@ -49,9 +51,15 @@ class HorizontalPlaybuttonCardView extends HookWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- DefaultTextStyle(
- style: textTheme.titleMedium!,
- child: title,
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ DefaultTextStyle(
+ style: textTheme.titleMedium!,
+ child: title,
+ ),
+ if (titleTrailing != null) titleTrailing!,
+ ],
),
SizedBox(
height: height,
@@ -85,11 +93,11 @@ class HorizontalPlaybuttonCardView extends HookWidget {
itemBuilder: (context, index) {
final item = items[index];
- return switch (item.runtimeType) {
- PlaylistSimple =>
+ return switch (item) {
+ PlaylistSimple() =>
PlaylistCard(item as PlaylistSimple),
- Album => AlbumCard(item as Album),
- Artist => Padding(
+ AlbumSimple() => AlbumCard(item as AlbumSimple),
+ Artist() => Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12.0),
child: ArtistCard(item as Artist),
diff --git a/lib/components/shared/hover_builder.dart b/lib/components/shared/hover_builder.dart
index ec60848e..7793e744 100644
--- a/lib/components/shared/hover_builder.dart
+++ b/lib/components/shared/hover_builder.dart
@@ -7,8 +7,8 @@ class HoverBuilder extends HookWidget {
const HoverBuilder({
required this.builder,
this.permanentState,
- Key? key,
- }) : super(key: key);
+ super.key,
+ });
@override
Widget build(BuildContext context) {
diff --git a/lib/components/shared/image/universal_image.dart b/lib/components/shared/image/universal_image.dart
index 04c62478..d8902e63 100644
--- a/lib/components/shared/image/universal_image.dart
+++ b/lib/components/shared/image/universal_image.dart
@@ -20,8 +20,8 @@ class UniversalImage extends HookWidget {
this.placeholder,
this.fit,
this.scale = 1,
- Key? key,
- }) : super(key: key);
+ super.key,
+ });
static ImageProvider imageProvider(
String path, {
diff --git a/lib/components/shared/inter_scrollbar/inter_scrollbar.dart b/lib/components/shared/inter_scrollbar/inter_scrollbar.dart
index 2b3ce319..8a86b643 100644
--- a/lib/components/shared/inter_scrollbar/inter_scrollbar.dart
+++ b/lib/components/shared/inter_scrollbar/inter_scrollbar.dart
@@ -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,
diff --git a/lib/components/shared/links/anchor_button.dart b/lib/components/shared/links/anchor_button.dart
index b1b1cfea..c6f0b889 100644
--- a/lib/components/shared/links/anchor_button.dart
+++ b/lib/components/shared/links/anchor_button.dart
@@ -11,13 +11,13 @@ class AnchorButton extends HookWidget {
const AnchorButton(
this.text, {
- Key? key,
+ super.key,
this.onTap,
this.textAlign,
this.overflow,
this.maxLines,
this.style = const TextStyle(),
- }) : super(key: key);
+ });
@override
Widget build(BuildContext context) {
@@ -29,7 +29,7 @@ class AnchorButton extends HookWidget {
onTapUp: (event) => tap.value = false,
onTap: onTap,
child: MouseRegion(
- cursor: MaterialStateMouseCursor.clickable,
+ cursor: WidgetStateMouseCursor.clickable,
child: Text(
text,
style: style.copyWith(
diff --git a/lib/components/shared/links/artist_link.dart b/lib/components/shared/links/artist_link.dart
new file mode 100644
index 00000000..5236a061
--- /dev/null
+++ b/lib/components/shared/links/artist_link.dart
@@ -0,0 +1,61 @@
+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 {
+ final List artists;
+ final WrapCrossAlignment crossAxisAlignment;
+ final WrapAlignment mainAxisAlignment;
+ final TextStyle textStyle;
+ final void Function(String route)? onRouteChange;
+
+ const ArtistLink({
+ super.key,
+ required this.artists,
+ this.crossAxisAlignment = WrapCrossAlignment.center,
+ this.mainAxisAlignment = WrapAlignment.center,
+ this.textStyle = const TextStyle(),
+ this.onRouteChange,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return Wrap(
+ crossAxisAlignment: crossAxisAlignment,
+ alignment: mainAxisAlignment,
+ children: artists
+ .asMap()
+ .entries
+ .map(
+ (artist) => Builder(builder: (context) {
+ if (artist.value.name == null) {
+ return Text("Spotify", style: textStyle);
+ }
+ return AnchorButton(
+ (artist.key != artists.length - 1)
+ ? "${artist.value.name}, "
+ : artist.value.name!,
+ onTap: () {
+ if (onRouteChange != null) {
+ onRouteChange?.call("/artist/${artist.value.id}");
+ } else {
+ ServiceUtils.pushNamed(
+ context,
+ ArtistPage.name,
+ pathParameters: {
+ "id": artist.value.id!,
+ },
+ );
+ }
+ },
+ overflow: TextOverflow.ellipsis,
+ style: textStyle,
+ );
+ }),
+ )
+ .toList(),
+ );
+ }
+}
diff --git a/lib/components/shared/links/hyper_link.dart b/lib/components/shared/links/hyper_link.dart
index fd31298e..f84517b4 100644
--- a/lib/components/shared/links/hyper_link.dart
+++ b/lib/components/shared/links/hyper_link.dart
@@ -13,12 +13,12 @@ class Hyperlink extends StatelessWidget {
const Hyperlink(
this.text,
this.url, {
- Key? key,
+ super.key,
this.textAlign,
this.overflow,
this.style = const TextStyle(),
this.maxLines,
- }) : super(key: key);
+ });
@override
Widget build(BuildContext context) {
diff --git a/lib/components/shared/links/link_text.dart b/lib/components/shared/links/link_text.dart
index d7b00b72..db7b6358 100644
--- a/lib/components/shared/links/link_text.dart
+++ b/lib/components/shared/links/link_text.dart
@@ -15,14 +15,14 @@ class LinkText extends StatelessWidget {
const LinkText(
this.text,
this.route, {
- Key? key,
+ super.key,
this.textAlign,
this.extra,
this.overflow,
this.style = const TextStyle(),
this.maxLines,
this.push = false,
- }) : super(key: key);
+ });
@override
Widget build(BuildContext context) {
diff --git a/lib/components/shared/page_window_title_bar.dart b/lib/components/shared/page_window_title_bar.dart
index 9aa2d4a8..573c7c47 100644
--- a/lib/components/shared/page_window_title_bar.dart
+++ b/lib/components/shared/page_window_title_bar.dart
@@ -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 {
@@ -26,8 +27,10 @@ class PageWindowTitleBar extends StatefulHookConsumerWidget
final double? titleWidth;
final Widget? title;
+ final bool _sliver;
+
const PageWindowTitleBar({
- Key? key,
+ super.key,
this.actions,
this.title,
this.toolbarOpacity = 1,
@@ -42,7 +45,38 @@ class PageWindowTitleBar extends StatefulHookConsumerWidget
this.titleTextStyle,
this.titleWidth,
this.toolbarTextStyle,
- }) : super(key: key);
+ }) : _sliver = false,
+ pinned = false,
+ floating = false,
+ snap = false,
+ stretch = false;
+
+ final bool pinned;
+ final bool floating;
+ final bool snap;
+ final bool stretch;
+
+ const PageWindowTitleBar.sliver({
+ super.key,
+ this.actions,
+ this.title,
+ this.backgroundColor,
+ this.actionsIconTheme,
+ this.automaticallyImplyLeading = false,
+ this.centerTitle,
+ this.foregroundColor,
+ this.leading,
+ this.leadingWidth,
+ this.titleSpacing,
+ this.titleTextStyle,
+ this.titleWidth,
+ this.toolbarTextStyle,
+ this.pinned = false,
+ this.floating = false,
+ this.snap = false,
+ this.stretch = false,
+ }) : _sliver = true,
+ toolbarOpacity = 1;
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
@@ -56,7 +90,7 @@ class _PageWindowTitleBarState extends ConsumerState {
final systemTitleBar =
ref.read(userPreferencesProvider.select((s) => s.systemTitleBar));
if (kIsDesktop && !systemTitleBar) {
- DesktopTools.window.startDragging();
+ windowManager.startDragging();
}
}
@@ -64,6 +98,44 @@ class _PageWindowTitleBarState extends ConsumerState {
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
+ if (widget._sliver) {
+ return SliverLayoutBuilder(
+ builder: (context, constraints) {
+ final hasFullscreen =
+ mediaQuery.size.width == constraints.crossAxisExtent;
+ final hasLeadingOrCanPop =
+ widget.leading != null || Navigator.canPop(context);
+
+ return SliverPadding(
+ padding: EdgeInsets.only(
+ left: kIsMacOS && hasFullscreen && hasLeadingOrCanPop ? 65 : 0,
+ ),
+ sliver: SliverAppBar(
+ leading: widget.leading,
+ automaticallyImplyLeading: widget.automaticallyImplyLeading,
+ actions: [
+ ...?widget.actions,
+ WindowTitleBarButtons(foregroundColor: widget.foregroundColor),
+ ],
+ backgroundColor: widget.backgroundColor,
+ foregroundColor: widget.foregroundColor,
+ actionsIconTheme: widget.actionsIconTheme,
+ centerTitle: widget.centerTitle,
+ titleSpacing: widget.titleSpacing,
+ leadingWidth: widget.leadingWidth,
+ toolbarTextStyle: widget.toolbarTextStyle,
+ titleTextStyle: widget.titleTextStyle,
+ title: widget.title,
+ pinned: widget.pinned,
+ floating: widget.floating,
+ snap: widget.snap,
+ stretch: widget.stretch,
+ ),
+ );
+ },
+ );
+ }
+
return LayoutBuilder(builder: (context, constrains) {
final hasFullscreen = mediaQuery.size.width == constrains.maxWidth;
final hasLeadingOrCanPop =
@@ -74,11 +146,7 @@ class _PageWindowTitleBarState extends ConsumerState {
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,
@@ -97,6 +165,10 @@ class _PageWindowTitleBarState extends ConsumerState {
toolbarTextStyle: widget.toolbarTextStyle,
titleTextStyle: widget.titleTextStyle,
title: widget.title,
+ scrolledUnderElevation: 0,
+ shadowColor: Colors.transparent,
+ forceMaterialTransparency: true,
+ elevation: 0,
),
),
);
@@ -107,9 +179,9 @@ class _PageWindowTitleBarState extends ConsumerState {
class WindowTitleBarButtons extends HookConsumerWidget {
final Color? foregroundColor;
const WindowTitleBarButtons({
- Key? key,
+ super.key,
this.foregroundColor,
- }) : super(key: key);
+ });
@override
Widget build(BuildContext context, ref) {
@@ -118,12 +190,12 @@ class WindowTitleBarButtons extends HookConsumerWidget {
const type = ThemeType.auto;
Future onClose() async {
- await DesktopTools.window.close();
+ await windowManager.close();
}
useEffect(() {
if (kIsDesktop) {
- DesktopTools.window.isMaximized().then((value) {
+ windowManager.isMaximized().then((value) {
isMaximized.value = value;
});
}
@@ -138,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,
@@ -160,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;
},
)
@@ -175,7 +247,7 @@ class WindowTitleBarButtons extends HookConsumerWidget {
RestoreWindowButton(
colors: colors,
onPressed: () {
- DesktopTools.window.unmaximize();
+ windowManager.unmaximize();
isMaximized.value = false;
},
),
@@ -195,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;
}
},
@@ -277,14 +349,13 @@ class WindowButton extends StatelessWidget {
final VoidCallback? onPressed;
WindowButton(
- {Key? key,
+ {super.key,
WindowButtonColors? colors,
this.builder,
@required this.iconBuilder,
this.padding,
this.onPressed,
- this.animate = false})
- : super(key: key) {
+ this.animate = false}) {
this.colors = colors ?? _defaultButtonColors;
}
@@ -350,49 +421,30 @@ class WindowButton extends StatelessWidget {
class MinimizeWindowButton extends WindowButton {
MinimizeWindowButton(
- {Key? key,
- WindowButtonColors? colors,
- VoidCallback? onPressed,
- bool? animate})
+ {super.key, super.colors, super.onPressed, bool? animate})
: super(
- key: key,
- colors: colors,
animate: animate ?? false,
iconBuilder: (buttonContext) =>
MinimizeIcon(color: buttonContext.iconColor),
- onPressed: onPressed,
);
}
class MaximizeWindowButton extends WindowButton {
MaximizeWindowButton(
- {Key? key,
- WindowButtonColors? colors,
- VoidCallback? onPressed,
- bool? animate})
+ {super.key, super.colors, super.onPressed, bool? animate})
: super(
- key: key,
- colors: colors,
animate: animate ?? false,
iconBuilder: (buttonContext) =>
MaximizeIcon(color: buttonContext.iconColor),
- onPressed: onPressed,
);
}
class RestoreWindowButton extends WindowButton {
- RestoreWindowButton(
- {Key? key,
- WindowButtonColors? colors,
- VoidCallback? onPressed,
- bool? animate})
+ RestoreWindowButton({super.key, super.colors, super.onPressed, bool? animate})
: super(
- key: key,
- colors: colors,
animate: animate ?? false,
iconBuilder: (buttonContext) =>
RestoreIcon(color: buttonContext.iconColor),
- onPressed: onPressed,
);
}
@@ -404,17 +456,12 @@ final _defaultCloseButtonColors = WindowButtonColors(
class CloseWindowButton extends WindowButton {
CloseWindowButton(
- {Key? key,
- WindowButtonColors? colors,
- VoidCallback? onPressed,
- bool? animate})
+ {super.key, WindowButtonColors? colors, super.onPressed, bool? animate})
: super(
- key: key,
colors: colors ?? _defaultCloseButtonColors,
animate: animate ?? false,
iconBuilder: (buttonContext) =>
CloseIcon(color: buttonContext.iconColor),
- onPressed: onPressed,
);
}
@@ -423,7 +470,7 @@ class CloseWindowButton extends WindowButton {
/// Close
class CloseIcon extends StatelessWidget {
final Color color;
- const CloseIcon({Key? key, required this.color}) : super(key: key);
+ const CloseIcon({super.key, required this.color});
@override
Widget build(BuildContext context) => Align(
alignment: Alignment.topLeft,
@@ -444,13 +491,13 @@ class CloseIcon extends StatelessWidget {
/// Maximize
class MaximizeIcon extends StatelessWidget {
final Color color;
- const MaximizeIcon({Key? key, required this.color}) : super(key: key);
+ const MaximizeIcon({super.key, required this.color});
@override
Widget build(BuildContext context) => _AlignedPaint(_MaximizePainter(color));
}
class _MaximizePainter extends _IconPainter {
- _MaximizePainter(Color color) : super(color);
+ _MaximizePainter(super.color);
@override
void paint(Canvas canvas, Size size) {
Paint p = getPaint(color);
@@ -462,15 +509,15 @@ class _MaximizePainter extends _IconPainter {
class RestoreIcon extends StatelessWidget {
final Color color;
const RestoreIcon({
- Key? key,
+ super.key,
required this.color,
- }) : super(key: key);
+ });
@override
Widget build(BuildContext context) => _AlignedPaint(_RestorePainter(color));
}
class _RestorePainter extends _IconPainter {
- _RestorePainter(Color color) : super(color);
+ _RestorePainter(super.color);
@override
void paint(Canvas canvas, Size size) {
Paint p = getPaint(color);
@@ -487,13 +534,13 @@ class _RestorePainter extends _IconPainter {
/// Minimize
class MinimizeIcon extends StatelessWidget {
final Color color;
- const MinimizeIcon({Key? key, required this.color}) : super(key: key);
+ const MinimizeIcon({super.key, required this.color});
@override
Widget build(BuildContext context) => _AlignedPaint(_MinimizePainter(color));
}
class _MinimizePainter extends _IconPainter {
- _MinimizePainter(Color color) : super(color);
+ _MinimizePainter(super.color);
@override
void paint(Canvas canvas, Size size) {
Paint p = getPaint(color);
@@ -512,7 +559,7 @@ abstract class _IconPainter extends CustomPainter {
}
class _AlignedPaint extends StatelessWidget {
- const _AlignedPaint(this.painter, {Key? key}) : super(key: key);
+ const _AlignedPaint(this.painter);
final CustomPainter painter;
@override
@@ -547,9 +594,9 @@ T? _ambiguate(T? value) => value;
class MouseStateBuilder extends StatefulWidget {
final MouseStateBuilderCB builder;
final VoidCallback? onPressed;
- const MouseStateBuilder({Key? key, required this.builder, this.onPressed})
- : super(key: key);
+ const MouseStateBuilder({super.key, required this.builder, this.onPressed});
@override
+ // ignore: library_private_types_in_public_api
_MouseStateBuilderState createState() => _MouseStateBuilderState();
}
diff --git a/lib/components/shared/panels/controller.dart b/lib/components/shared/panels/controller.dart
index a573c06c..65c2444e 100644
--- a/lib/components/shared/panels/controller.dart
+++ b/lib/components/shared/panels/controller.dart
@@ -1,4 +1,4 @@
-part of panels;
+part of './sliding_up_panel.dart';
class PanelController extends ChangeNotifier {
SlidingUpPanelState? _panelState;
diff --git a/lib/components/shared/panels/helpers.dart b/lib/components/shared/panels/helpers.dart
index 2e754bdf..6d0dde31 100644
--- a/lib/components/shared/panels/helpers.dart
+++ b/lib/components/shared/panels/helpers.dart
@@ -1,4 +1,4 @@
-part of panels;
+part of "./sliding_up_panel.dart";
/// if you want to prevent the panel from being dragged using the widget,
/// wrap the widget with this
@@ -47,8 +47,7 @@ class ForceDraggableWidgetRenderBox extends RenderPointerListener {
/// To make [ForceDraggableWidget] work in [Scrollable] widgets
class PanelScrollPhysics extends ScrollPhysics {
final PanelController controller;
- const PanelScrollPhysics({required this.controller, ScrollPhysics? parent})
- : super(parent: parent);
+ const PanelScrollPhysics({required this.controller, super.parent});
@override
PanelScrollPhysics applyTo(ScrollPhysics? ancestor) {
return PanelScrollPhysics(
diff --git a/lib/components/shared/panels/sliding_up_panel.dart b/lib/components/shared/panels/sliding_up_panel.dart
index 137d5eb7..e99fe261 100644
--- a/lib/components/shared/panels/sliding_up_panel.dart
+++ b/lib/components/shared/panels/sliding_up_panel.dart
@@ -146,7 +146,7 @@ class SlidingUpPanel extends StatefulWidget {
final BoxDecoration? panelDecoration;
const SlidingUpPanel(
- {Key? key,
+ {super.key,
this.body,
this.collapsed,
this.minHeight = 100.0,
@@ -176,8 +176,7 @@ class SlidingUpPanel extends StatefulWidget {
this.panelBuilder})
: assert(panelBuilder != null),
assert(0 <= backdropOpacity && backdropOpacity <= 1.0),
- assert(snapPoint == null || 0 < snapPoint && snapPoint < 1.0),
- super(key: key);
+ assert(snapPoint == null || 0 < snapPoint && snapPoint < 1.0);
@override
SlidingUpPanelState createState() => SlidingUpPanelState();
diff --git a/lib/components/shared/playbutton_card.dart b/lib/components/shared/playbutton_card.dart
index a8a75d30..807628b3 100644
--- a/lib/components/shared/playbutton_card.dart
+++ b/lib/components/shared/playbutton_card.dart
@@ -43,8 +43,8 @@ class PlaybuttonCard extends HookWidget {
this.onAddToQueuePressed,
this.onTap,
this.isOwner = false,
- Key? key,
- }) : super(key: key);
+ super.key,
+ });
@override
Widget build(BuildContext context) {
@@ -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(
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),
),
diff --git a/lib/components/shared/shimmers/shimmer_lyrics.dart b/lib/components/shared/shimmers/shimmer_lyrics.dart
index b225c008..03816202 100644
--- a/lib/components/shared/shimmers/shimmer_lyrics.dart
+++ b/lib/components/shared/shimmers/shimmer_lyrics.dart
@@ -5,7 +5,7 @@ import 'package:gap/gap.dart';
import 'package:skeletonizer/skeletonizer.dart';
class ShimmerLyrics extends HookWidget {
- const ShimmerLyrics({Key? key}) : super(key: key);
+ const ShimmerLyrics({super.key});
@override
Widget build(BuildContext context) {
diff --git a/lib/components/shared/sort_tracks_dropdown.dart b/lib/components/shared/sort_tracks_dropdown.dart
index ab35b2e3..be72d689 100644
--- a/lib/components/shared/sort_tracks_dropdown.dart
+++ b/lib/components/shared/sort_tracks_dropdown.dart
@@ -11,8 +11,8 @@ class SortTracksDropdown extends StatelessWidget {
const SortTracksDropdown({
this.onChanged,
this.value,
- Key? key,
- }) : super(key: key);
+ super.key,
+ });
@override
Widget build(BuildContext context) {
diff --git a/lib/components/shared/themed_button_tab_bar.dart b/lib/components/shared/themed_button_tab_bar.dart
index d5798189..c245e5f4 100644
--- a/lib/components/shared/themed_button_tab_bar.dart
+++ b/lib/components/shared/themed_button_tab_bar.dart
@@ -5,7 +5,8 @@ import 'package:spotube/hooks/utils/use_brightness_value.dart';
class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget {
final List tabs;
- const ThemedButtonsTabBar({Key? key, required this.tabs}) : super(key: key);
+ 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(
diff --git a/lib/components/shared/track_tile/track_options.dart b/lib/components/shared/track_tile/track_options.dart
index a094259d..4b383c47 100644
--- a/lib/components/shared/track_tile/track_options.dart
+++ b/lib/components/shared/track_tile/track_options.dart
@@ -1,6 +1,5 @@
import 'dart:io';
-import 'package:fl_query/fl_query.dart';
import 'package:flutter/material.dart' hide Page;
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@@ -9,24 +8,25 @@ 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';
import 'package:spotube/components/shared/dialogs/track_details_dialog.dart';
import 'package:spotube/components/shared/heart_button.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
+import 'package:spotube/components/shared/links/artist_link.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
+import 'package:spotube/extensions/image.dart';
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';
-import 'package:spotube/services/mutations/mutations.dart';
-import 'package:spotube/services/queries/search.dart';
-import 'package:spotube/utils/type_conversion_utils.dart';
+
import 'package:url_launcher/url_launcher_string.dart';
enum TrackOptionValue {
@@ -53,13 +53,13 @@ class TrackOptions extends HookConsumerWidget {
final ObjectRef?>? showMenuCbRef;
final Widget? icon;
const TrackOptions({
- Key? key,
+ super.key,
required this.track,
this.showMenuCbRef,
this.userPlaylist = false,
this.playlistId,
this.icon,
- }) : super(key: key);
+ });
void actionShare(BuildContext context, Track track) {
final data = "https://open.spotify.com/track/${track.id}";
@@ -95,25 +95,14 @@ class TrackOptions extends HookConsumerWidget {
WidgetRef ref,
Track track,
) async {
- final playback = ref.read(ProxyPlaylistNotifier.notifier);
- final playlist = ref.read(ProxyPlaylistNotifier.provider);
+ final playback = ref.read(proxyPlaylistProvider.notifier);
+ final playlist = ref.read(proxyPlaylistProvider);
final spotify = ref.read(spotifyProvider);
final query = "${track.name} Radio";
- final pages = await QueryClient.of(context)
- .fetchInfiniteQueryJob, dynamic, int, SearchParams>(
- job: SearchQueries.queryJob(query),
- args: (
- spotify: spotify,
- searchType: SearchType.playlist,
- query: query,
- ),
- ) ??
- [];
+ final pages =
+ await spotify.search.get(query, types: [SearchType.playlist]).first();
- final radios = pages
- .expand((e) => e.items?.toList() ?? [])
- .toList()
- .cast();
+ final radios = pages.map((e) => e.items).toList().cast();
final artists = track.artists!.map((e) => e.name);
@@ -170,12 +159,13 @@ class TrackOptions extends HookConsumerWidget {
final router = GoRouter.of(context);
final ThemeData(:colorScheme) = Theme.of(context);
- final playlist = ref.watch(ProxyPlaylistNotifier.provider);
- final playback = ref.watch(ProxyPlaylistNotifier.notifier);
- final auth = ref.watch(AuthenticationNotifier.provider);
+ final playlist = ref.watch(proxyPlaylistProvider);
+ final playback = ref.watch(proxyPlaylistProvider.notifier);
+ final auth = ref.watch(authenticationProvider);
ref.watch(downloadManagerProvider);
final downloadManager = ref.watch(downloadManagerProvider.notifier);
- final blacklist = ref.watch(BlackListNotifier.provider);
+ final blacklist = ref.watch(blacklistProvider);
+ final me = ref.watch(meProvider);
final favorites = useTrackToggleLike(track, ref);
@@ -190,10 +180,8 @@ class TrackOptions extends HookConsumerWidget {
);
final removingTrack = useState(null);
- final removeTrack = useMutations.playlist.removeTrackOf(
- ref,
- playlistId ?? "",
- );
+ final favoritePlaylistsNotifier =
+ ref.watch(favoritePlaylistsProvider.notifier);
final isInQueue = useMemoized(() {
if (playlist.activeTrack == null) return false;
@@ -209,6 +197,8 @@ class TrackOptions extends HookConsumerWidget {
return downloadManager.getProgressNotifier(spotubeTrack);
});
+ final isLocalTrack = track is LocalTrack;
+
final adaptivePopSheetList = AdaptivePopSheetList(
onSelected: (value) async {
switch (value) {
@@ -220,7 +210,7 @@ class TrackOptions extends HookConsumerWidget {
break;
case TrackOptionValue.delete:
await File((track as LocalTrack).path).delete();
- ref.refresh(localTracksProvider);
+ ref.invalidate(localTracksProvider);
break;
case TrackOptionValue.addToQueue:
await playback.addTrack(track);
@@ -257,22 +247,23 @@ class TrackOptions extends HookConsumerWidget {
);
break;
case TrackOptionValue.favorite:
- favorites.toggleTrackLike.mutate(favorites.isLiked);
+ favorites.toggleTrackLike(track);
break;
case TrackOptionValue.addToPlaylist:
actionAddToPlaylist(context, track);
break;
case TrackOptionValue.removeFromPlaylist:
removingTrack.value = track.uri;
- removeTrack.mutate(track.uri!);
+ favoritePlaylistsNotifier
+ .removeTracks(playlistId ?? "", [track.id!]);
break;
case TrackOptionValue.blacklist:
if (isBlackListed) {
- ref.read(BlackListNotifier.provider.notifier).remove(
+ ref.read(blacklistProvider.notifier).remove(
BlacklistedElement.track(track.id!, track.name!),
);
} else {
- ref.read(BlackListNotifier.provider.notifier).add(
+ ref.read(blacklistProvider.notifier).add(
BlacklistedElement.track(track.id!, track.name!),
);
}
@@ -307,8 +298,8 @@ class TrackOptions extends HookConsumerWidget {
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: UniversalImage(
- path: TypeConversionUtils.image_X_UrlString(track.album!.images,
- placeholder: ImagePlaceholder.albumArt),
+ path: track.album!.images
+ .asUrlString(placeholder: ImagePlaceholder.albumArt),
fit: BoxFit.cover,
),
),
@@ -321,127 +312,124 @@ class TrackOptions extends HookConsumerWidget {
),
subtitle: Align(
alignment: Alignment.centerLeft,
- child: TypeConversionUtils.artists_X_ClickableArtists(
- track.artists!,
- ),
+ child: ArtistLink(artists: track.artists!),
),
),
],
- 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 (favorites.me.hasData)
- 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: (removeTrack.isMutating || !removeTrack.hasData) &&
- removingTrack.value == track.uri
- ? const CircularProgressIndicator()
- : 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
diff --git a/lib/components/shared/track_tile/track_tile.dart b/lib/components/shared/track_tile/track_tile.dart
index d268c783..e3aea4de 100644
--- a/lib/components/shared/track_tile/track_tile.dart
+++ b/lib/components/shared/track_tile/track_tile.dart
@@ -9,14 +9,16 @@ import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/hover_builder.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
+import 'package:spotube/components/shared/links/artist_link.dart';
import 'package:spotube/components/shared/links/link_text.dart';
import 'package:spotube/components/shared/track_tile/track_options.dart';
+import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/duration.dart';
+import 'package:spotube/extensions/image.dart';
import 'package:spotube/models/local_track.dart';
import 'package:spotube/provider/blacklist_provider.dart';
-import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
-import 'package:spotube/utils/type_conversion_utils.dart';
+import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
class TrackTile extends HookConsumerWidget {
/// [index] will not be shown if null
@@ -28,28 +30,29 @@ class TrackTile extends HookConsumerWidget {
final VoidCallback? onLongPress;
final bool userPlaylist;
final String? playlistId;
+ final ProxyPlaylist playlist;
final List? leadingActions;
const TrackTile({
- Key? key,
+ super.key,
this.index,
required this.track,
this.selected = false,
+ required this.playlist,
this.onTap,
this.onLongPress,
this.onChanged,
this.userPlaylist = false,
this.playlistId,
this.leadingActions,
- }) : super(key: key);
+ });
@override
Widget build(BuildContext context, ref) {
- final playlist = ref.watch(ProxyPlaylistNotifier.provider);
final theme = Theme.of(context);
- final blacklist = ref.watch(BlackListNotifier.provider);
+ final blacklist = ref.watch(blacklistProvider);
final isBlackListed = useMemoized(
() => blacklist.contains(
@@ -63,10 +66,10 @@ class TrackTile extends HookConsumerWidget {
final showOptionCbRef = useRef?>(null);
- final isPlaying = track.id == playlist.activeTrack?.id;
-
final isLoading = useState(false);
+ final isPlaying = playlist.activeTrack?.id == track.id;
+
final isSelected = isPlaying || isLoading.value;
return LayoutBuilder(builder: (context, constrains) {
@@ -135,8 +138,7 @@ class TrackTile extends HookConsumerWidget {
child: AspectRatio(
aspectRatio: 1,
child: UniversalImage(
- path: TypeConversionUtils.image_X_UrlString(
- track.album?.images,
+ path: (track.album?.images).asUrlString(
placeholder: ImagePlaceholder.albumArt,
),
fit: BoxFit.cover,
@@ -193,20 +195,27 @@ 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) {
- LocalTrack => Text(
+ child: switch (track) {
+ LocalTrack() => Text(
track.album!.name!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
@@ -230,16 +239,12 @@ class TrackTile extends HookConsumerWidget {
alignment: Alignment.centerLeft,
child: track is LocalTrack
? Text(
- TypeConversionUtils.artists_X_String(
- track.artists ?? [],
- ),
+ track.artists?.asString() ?? '',
)
: ClipRect(
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 40),
- child: TypeConversionUtils.artists_X_ClickableArtists(
- track.artists ?? [],
- ),
+ child: ArtistLink(artists: track.artists ?? []),
),
),
),
diff --git a/lib/components/shared/tracks_view/sections/body/track_view_body.dart b/lib/components/shared/tracks_view/sections/body/track_view_body.dart
index 33c8fa82..c3605f33 100644
--- a/lib/components/shared/tracks_view/sections/body/track_view_body.dart
+++ b/lib/components/shared/tracks_view/sections/body/track_view_body.dart
@@ -8,23 +8,28 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/fake.dart';
+import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
import 'package:spotube/components/shared/expandable_search/expandable_search.dart';
import 'package:spotube/components/shared/track_tile/track_tile.dart';
import 'package:spotube/components/shared/tracks_view/sections/body/track_view_body_headers.dart';
import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_playlist.dart';
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';
class TrackViewBodySection extends HookConsumerWidget {
- const TrackViewBodySection({Key? key}) : super(key: key);
+ const TrackViewBodySection({super.key});
@override
Widget build(BuildContext context, ref) {
- final playlist = ref.watch(ProxyPlaylistNotifier.provider);
- final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
+ 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));
@@ -89,6 +94,7 @@ class TrackViewBodySection extends HookConsumerWidget {
loadingBuilder: (context) => Skeletonizer(
enabled: true,
child: TrackTile(
+ playlist: playlist,
track: FakeData.track,
index: 0,
),
@@ -98,13 +104,18 @@ class TrackViewBodySection extends HookConsumerWidget {
child: Column(
children: List.generate(
10,
- (index) => TrackTile(track: FakeData.track, index: index),
+ (index) => TrackTile(
+ track: FakeData.track,
+ index: index,
+ playlist: playlist,
+ ),
),
),
),
itemBuilder: (context, index) {
final track = tracks[index];
return TrackTile(
+ playlist: playlist,
track: track,
index: index,
selected: trackViewState.selectedTrackIds.contains(track.id!),
@@ -125,16 +136,50 @@ class TrackViewBodySection extends HookConsumerWidget {
return;
}
- if (isActive || playlist.tracks.contains(track)) {
- await playlistNotifier.jumpToTrack(track);
+ final isRemoteDevice =
+ await showSelectDeviceDialog(context, ref);
+
+ if (isRemoteDevice) {
+ final remotePlayback = ref.read(connectProvider.notifier);
+ final remoteQueue = ref.read(queueProvider);
+ if (remoteQueue.collections.contains(props.collectionId) ||
+ remoteQueue.tracks.any((s) => s.id == track.id)) {
+ await playlistNotifier.jumpToTrack(track);
+ } else {
+ final tracks = await props.pagination.onFetchAll();
+ await remotePlayback.load(
+ 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 {
- final tracks = await props.pagination.onFetchAll();
- await playlistNotifier.load(
- tracks,
- initialIndex: index,
- autoPlay: true,
- );
- playlistNotifier.addCollection(props.collectionId);
+ if (isActive || playlist.tracks.contains(track)) {
+ await playlistNotifier.jumpToTrack(track);
+ } else {
+ final tracks = await props.pagination.onFetchAll();
+ await playlistNotifier.load(
+ tracks,
+ initialIndex: index,
+ autoPlay: true,
+ );
+ playlistNotifier.addCollection(props.collectionId);
+ if (props.collection is AlbumSimple) {
+ historyNotifier
+ .addAlbums([props.collection as AlbumSimple]);
+ } else {
+ historyNotifier
+ .addPlaylists([props.collection as PlaylistSimple]);
+ }
+ }
}
},
);
diff --git a/lib/components/shared/tracks_view/sections/body/track_view_body_headers.dart b/lib/components/shared/tracks_view/sections/body/track_view_body_headers.dart
index 7e4522a0..3a1538a3 100644
--- a/lib/components/shared/tracks_view/sections/body/track_view_body_headers.dart
+++ b/lib/components/shared/tracks_view/sections/body/track_view_body_headers.dart
@@ -13,10 +13,10 @@ class TrackViewBodyHeaders extends HookConsumerWidget {
final FocusNode searchFocus;
const TrackViewBodyHeaders({
- Key? key,
+ super.key,
required this.isFiltering,
required this.searchFocus,
- }) : super(key: key);
+ });
@override
Widget build(BuildContext context, ref) {
diff --git a/lib/components/shared/tracks_view/sections/body/track_view_options.dart b/lib/components/shared/tracks_view/sections/body/track_view_options.dart
index 583c9107..c2adf38b 100644
--- a/lib/components/shared/tracks_view/sections/body/track_view_options.dart
+++ b/lib/components/shared/tracks_view/sections/body/track_view_options.dart
@@ -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,12 +9,13 @@ 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';
class TrackViewBodyOptions extends HookConsumerWidget {
- const TrackViewBodyOptions({Key? key}) : super(key: key);
+ const TrackViewBodyOptions({super.key});
@override
Widget build(BuildContext context, ref) {
@@ -22,7 +24,8 @@ class TrackViewBodyOptions extends HookConsumerWidget {
ref.watch(downloadManagerProvider);
final downloader = ref.watch(downloadManagerProvider.notifier);
- final playlistNotifier = ref.watch(ProxyPlaylistNotifier.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;
}
diff --git a/lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart b/lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart
index ca3c6706..2f87ccc8 100644
--- a/lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart
+++ b/lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart
@@ -1,18 +1,18 @@
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:spotube/services/queries/queries.dart';
+import 'package:spotube/provider/spotify/spotify.dart';
bool useIsUserPlaylist(WidgetRef ref, String playlistId) {
- final userPlaylistsQuery = useQueries.playlist.ofMineAll(ref);
- final me = useQueries.user.me(ref);
+ final userPlaylistsQuery = ref.watch(favoritePlaylistsProvider);
+ final me = ref.watch(meProvider);
return useMemoized(
() =>
- userPlaylistsQuery.data?.any((e) =>
+ userPlaylistsQuery.asData?.value.items.any((e) =>
e.id == playlistId &&
- me.data != null &&
- e.owner?.id == me.data?.id) ??
+ me.asData?.value != null &&
+ e.owner?.id == me.asData?.value.id) ??
false,
- [userPlaylistsQuery.data, playlistId, me.data],
+ [userPlaylistsQuery.asData?.value, playlistId, me.asData?.value],
);
}
diff --git a/lib/components/shared/tracks_view/sections/header/flexible_header.dart b/lib/components/shared/tracks_view/sections/header/flexible_header.dart
index 19241dc6..d6e71e8f 100644
--- a/lib/components/shared/tracks_view/sections/header/flexible_header.dart
+++ b/lib/components/shared/tracks_view/sections/header/flexible_header.dart
@@ -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,9 +12,10 @@ 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({Key? key}) : super(key: key);
+ const TrackViewFlexHeader({super.key});
@override
Widget build(BuildContext context, ref) {
@@ -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(
diff --git a/lib/components/shared/tracks_view/sections/header/header_actions.dart b/lib/components/shared/tracks_view/sections/header/header_actions.dart
index 75aa3f61..8c1c8e15 100644
--- a/lib/components/shared/tracks_view/sections/header/header_actions.dart
+++ b/lib/components/shared/tracks_view/sections/header/header_actions.dart
@@ -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,17 +10,19 @@ 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 {
- const TrackViewHeaderActions({Key? key}) : super(key: key);
+ const TrackViewHeaderActions({super.key});
@override
Widget build(BuildContext context, ref) {
final props = InheritedTrackView.of(context);
- final playlist = ref.watch(ProxyPlaylistNotifier.provider);
- final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
+ final playlist = ref.watch(proxyPlaylistProvider);
+ final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
+ final historyNotifier = ref.watch(playbackHistoryProvider.notifier);
final isActive = playlist.collections.contains(props.collectionId);
@@ -27,7 +30,7 @@ class TrackViewHeaderActions extends HookConsumerWidget {
final scaffoldMessenger = ScaffoldMessenger.of(context);
- final auth = ref.watch(AuthenticationNotifier.provider);
+ final auth = ref.watch(authenticationProvider);
return Row(
mainAxisSize: MainAxisSize.min,
@@ -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)
diff --git a/lib/components/shared/tracks_view/sections/header/header_buttons.dart b/lib/components/shared/tracks_view/sections/header/header_buttons.dart
index bae47f12..5cc442cf 100644
--- a/lib/components/shared/tracks_view/sections/header/header_buttons.dart
+++ b/lib/components/shared/tracks_view/sections/header/header_buttons.dart
@@ -5,9 +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';
@@ -15,16 +20,17 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
final PaletteColor color;
final bool compact;
const TrackViewHeaderButtons({
- Key? key,
+ super.key,
required this.color,
this.compact = false,
- }) : super(key: key);
+ });
@override
Widget build(BuildContext context, ref) {
final props = InheritedTrackView.of(context);
- final playlist = ref.watch(ProxyPlaylistNotifier.provider);
- final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
+ final playlist = ref.watch(proxyPlaylistProvider);
+ final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
+ final historyNotifier = ref.watch(playbackHistoryProvider.notifier);
final isActive = playlist.collections.contains(props.collectionId);
@@ -41,15 +47,46 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
try {
isLoading.value = true;
- final allTracks = await props.pagination.onFetchAll();
+ final initialTracks = props.tracks;
+ if (!context.mounted) return;
- await playlistNotifier.load(
- allTracks,
- autoPlay: true,
- initialIndex: Random().nextInt(allTracks.length),
- );
- await audioPlayer.setShuffle(true);
- playlistNotifier.addCollection(props.collectionId);
+ final isRemoteDevice = await showSelectDeviceDialog(context, ref);
+ if (isRemoteDevice) {
+ final allTracks = await props.pagination.onFetchAll();
+ final remotePlayback = ref.read(connectProvider.notifier);
+ await remotePlayback.load(
+ 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(
+ initialTracks,
+ autoPlay: true,
+ 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;
}
@@ -59,10 +96,40 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
try {
isLoading.value = true;
- final allTracks = await props.pagination.onFetchAll();
+ final initialTracks = props.tracks;
- await playlistNotifier.load(allTracks, autoPlay: true);
- playlistNotifier.addCollection(props.collectionId);
+ 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(
+ 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(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;
}
diff --git a/lib/components/shared/tracks_view/track_view.dart b/lib/components/shared/tracks_view/track_view.dart
index 4103573c..03d628a8 100644
--- a/lib/components/shared/tracks_view/track_view.dart
+++ b/lib/components/shared/tracks_view/track_view.dart
@@ -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,9 +8,10 @@ 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({Key? key}) : super(key: key);
+ const TrackView({super.key});
@override
Widget build(BuildContext context, ref) {
@@ -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,
diff --git a/lib/components/shared/tracks_view/track_view_props.dart b/lib/components/shared/tracks_view/track_view_props.dart
index 21bbaec7..b0a00ae2 100644
--- a/lib/components/shared/tracks_view/track_view_props.dart
+++ b/lib/components/shared/tracks_view/track_view_props.dart
@@ -1,6 +1,5 @@
import 'dart:async';
-import 'package:fl_query/fl_query.dart';
import 'package:flutter/material.dart' hide Page;
import 'package:spotify/spotify.dart';
@@ -19,19 +18,6 @@ class PaginationProps {
required this.onRefresh,
});
- factory PaginationProps.fromQuery(
- InfiniteQuery, dynamic, int> query, {
- required Future> Function() onFetchAll,
- }) {
- return PaginationProps(
- hasNextPage: query.hasNextPage,
- isLoading: query.isLoadingNextPage,
- onFetchMore: query.fetchNext,
- onFetchAll: onFetchAll,
- onRefresh: query.refreshAll,
- );
- }
-
@override
operator ==(Object other) {
return other is PaginationProps &&
@@ -53,7 +39,7 @@ class PaginationProps {
}
class InheritedTrackView extends InheritedWidget {
- final String collectionId;
+ final Object collection;
final String title;
final String? description;
final String image;
@@ -69,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,
@@ -79,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) {
@@ -92,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;
}
diff --git a/lib/components/shared/waypoint.dart b/lib/components/shared/waypoint.dart
index abd9f98d..cf00e29b 100644
--- a/lib/components/shared/waypoint.dart
+++ b/lib/components/shared/waypoint.dart
@@ -11,17 +11,15 @@ class Waypoint extends HookWidget {
final bool isGrid;
const Waypoint({
- Key? key,
+ super.key,
required this.controller,
this.isGrid = false,
this.onTouchEdge,
this.child,
- }) : super(key: key);
+ });
@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(
diff --git a/lib/components/stats/common/album_item.dart b/lib/components/stats/common/album_item.dart
new file mode 100644
index 00000000..00b1cbfe
--- /dev/null
+++ b/lib/components/stats/common/album_item.dart
@@ -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,
+ );
+ },
+ );
+ }
+}
diff --git a/lib/components/stats/common/artist_item.dart b/lib/components/stats/common/artist_item.dart
new file mode 100644
index 00000000..9282d4e1
--- /dev/null
+++ b/lib/components/stats/common/artist_item.dart
@@ -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!},
+ );
+ },
+ );
+ }
+}
diff --git a/lib/components/stats/common/playlist_item.dart b/lib/components/stats/common/playlist_item.dart
new file mode 100644
index 00000000..b07311ab
--- /dev/null
+++ b/lib/components/stats/common/playlist_item.dart
@@ -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,
+ );
+ },
+ );
+ }
+}
diff --git a/lib/components/stats/common/track_item.dart b/lib/components/stats/common/track_item.dart
new file mode 100644
index 00000000..6ba6b886
--- /dev/null
+++ b/lib/components/stats/common/track_item.dart
@@ -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!,
+ },
+ );
+ },
+ );
+ }
+}
diff --git a/lib/components/stats/summary/summary.dart b/lib/components/stats/summary/summary.dart
new file mode 100644
index 00000000..61f3bd6c
--- /dev/null
+++ b/lib/components/stats/summary/summary.dart
@@ -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);
+ },
+ ),
+ ]),
+ );
+ }),
+ );
+ }
+}
diff --git a/lib/components/stats/summary/summary_card.dart b/lib/components/stats/summary/summary_card.dart
new file mode 100644
index 00000000..243c50e8
--- /dev/null
+++ b/lib/components/stats/summary/summary_card.dart
@@ -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,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/components/stats/top/albums.dart b/lib/components/stats/top/albums.dart
new file mode 100644
index 00000000..51bcf5b0
--- /dev/null
+++ b/lib/components/stats/top/albums.dart
@@ -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",
+ ),
+ );
+ },
+ );
+ }
+}
diff --git a/lib/components/stats/top/artists.dart b/lib/components/stats/top/artists.dart
new file mode 100644
index 00000000..d6d0c98d
--- /dev/null
+++ b/lib/components/stats/top/artists.dart
@@ -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"),
+ );
+ },
+ );
+ }
+}
diff --git a/lib/components/stats/top/top.dart b/lib/components/stats/top/top.dart
new file mode 100644
index 00000000..df1275e8
--- /dev/null
+++ b/lib/components/stats/top/top.dart
@@ -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(),
+ };
+ },
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/components/stats/top/tracks.dart b/lib/components/stats/top/tracks.dart
new file mode 100644
index 00000000..bffa4ecd
--- /dev/null
+++ b/lib/components/stats/top/tracks.dart
@@ -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",
+ ),
+ );
+ },
+ );
+ }
+}
diff --git a/lib/extensions/album_simple.dart b/lib/extensions/album_simple.dart
index 00db4dca..5678390c 100644
--- a/lib/extensions/album_simple.dart
+++ b/lib/extensions/album_simple.dart
@@ -1,18 +1,21 @@
import 'package:spotify/spotify.dart';
-extension AlbumJson on AlbumSimple {
- Map toJson() {
- return {
- "albumType": albumType?.name,
- "id": id,
- "name": name,
- "images": images
- ?.map((image) => {
- "height": image.height,
- "url": image.url,
- "width": image.width,
- })
- .toList(),
- };
+extension AlbumExtensions on AlbumSimple {
+ Album toAlbum() {
+ Album album = Album();
+ album.albumType = albumType;
+ album.artists = artists;
+ album.availableMarkets = availableMarkets;
+ album.externalUrls = externalUrls;
+ album.href = href;
+ album.id = id;
+ album.images = images;
+ album.name = name;
+ album.releaseDate = releaseDate;
+ album.releaseDatePrecision = releaseDatePrecision;
+ album.tracks = tracks;
+ album.type = type;
+ album.uri = uri;
+ return album;
}
}
diff --git a/lib/extensions/artist_simple.dart b/lib/extensions/artist_simple.dart
index caf2e510..7997355d 100644
--- a/lib/extensions/artist_simple.dart
+++ b/lib/extensions/artist_simple.dart
@@ -1,13 +1,7 @@
import 'package:spotify/spotify.dart';
-extension ArtistJson on ArtistSimple {
- Map toJson() {
- return {
- "href": href,
- "id": id,
- "name": name,
- "type": type,
- "uri": uri,
- };
+extension ArtistExtension on List {
+ String asString() {
+ return map((e) => e.name?.replaceAll(",", " ")).join(", ");
}
}
diff --git a/lib/extensions/image.dart b/lib/extensions/image.dart
new file mode 100644
index 00000000..ee78653a
--- /dev/null
+++ b/lib/extensions/image.dart
@@ -0,0 +1,34 @@
+import 'package:spotify/spotify.dart';
+import 'package:spotube/collections/assets.gen.dart';
+import 'package:spotube/utils/primitive_utils.dart';
+import 'package:collection/collection.dart';
+
+enum ImagePlaceholder {
+ albumArt,
+ artist,
+ collection,
+ online,
+}
+
+extension SpotifyImageExtensions on List? {
+ String asUrlString({
+ int index = 1,
+ required ImagePlaceholder placeholder,
+ }) {
+ final String placeholderUrl = {
+ ImagePlaceholder.albumArt: Assets.albumPlaceholder.path,
+ ImagePlaceholder.artist: Assets.userPlaceholder.path,
+ ImagePlaceholder.collection: Assets.placeholder.path,
+ ImagePlaceholder.online:
+ "https://avatars.dicebear.com/api/bottts/${PrimitiveUtils.uuid.v4()}.png",
+ }[placeholder]!;
+
+ final sortedImage = this?.sorted((a, b) => a.width!.compareTo(b.width!));
+
+ return sortedImage != null && sortedImage.isNotEmpty
+ ? sortedImage[
+ index > sortedImage.length - 1 ? sortedImage.length - 1 : index]
+ .url!
+ : placeholderUrl;
+ }
+}
diff --git a/lib/extensions/infinite_query.dart b/lib/extensions/infinite_query.dart
deleted file mode 100644
index 2181ab3c..00000000
--- a/lib/extensions/infinite_query.dart
+++ /dev/null
@@ -1,34 +0,0 @@
-import 'package:fl_query/fl_query.dart';
-import 'package:spotify/spotify.dart';
-
-extension FetchAllTracks on InfiniteQuery, dynamic, int> {
- Future> fetchAllTracks({
- required Future> Function() getAllTracks,
- }) async {
- if (pages.isNotEmpty && !hasNextPage) {
- return pages.expand((page) => page).toList();
- }
- final tracks = await getAllTracks();
-
- final numOfPages = (tracks.length / 20).round();
-
- final Map> pagedTracks = {};
-
- for (var i = 0; i < numOfPages; i++) {
- if (i == numOfPages - 1) {
- final pageTracks = tracks.sublist(i * 20);
- pagedTracks[i] = pageTracks;
- break;
- }
-
- final pageTracks = tracks.sublist(i * 20, (i + 1) * 20);
- pagedTracks[i] = pageTracks;
- }
-
- for (final group in pagedTracks.entries) {
- setPageData(group.key, group.value);
- }
-
- return tracks.toList();
- }
-}
diff --git a/lib/extensions/track.dart b/lib/extensions/track.dart
index 51498b33..02c0c492 100644
--- a/lib/extensions/track.dart
+++ b/lib/extensions/track.dart
@@ -1,32 +1,70 @@
+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 TrackJson on Track {
- Map toJson() {
- return TrackJson.trackToJson(this);
- }
+extension TrackExtensions on Track {
+ Track fromFile(
+ File file, {
+ Metadata? metadata,
+ String? art,
+ }) {
+ album = Album()
+ ..name = metadata?.album ?? "Unknown"
+ ..images = [if (art != null) Image()..url = art]
+ ..genres = [if (metadata?.genre != null) metadata!.genre!]
+ ..artists = [
+ Artist()
+ ..name = metadata?.albumArtist ?? "Unknown"
+ ..id = metadata?.albumArtist ?? "Unknown"
+ ..type = "artist",
+ ]
+ ..id = metadata?.album
+ ..releaseDate = metadata?.year?.toString();
+ artists = [
+ Artist()
+ ..name = metadata?.artist ?? "Unknown"
+ ..id = metadata?.artist ?? "Unknown"
+ ];
- static Map 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,
- };
+ id = metadata?.title ?? basenameWithoutExtension(file.path);
+ name = metadata?.title ?? basenameWithoutExtension(file.path);
+ type = "track";
+ uri = file.path;
+ durationMs = (metadata?.durationMs?.toInt() ?? 0);
+
+ return this;
+ }
+}
+
+extension TrackSimpleExtensions on TrackSimple {
+ Track asTrack(AlbumSimple album) {
+ Track track = Track();
+ track.name = name;
+ track.album = album;
+ track.artists = artists;
+ track.availableMarkets = availableMarkets;
+ track.discNumber = discNumber;
+ track.durationMs = durationMs;
+ track.explicit = explicit;
+ track.externalUrls = externalUrls;
+ track.href = href;
+ track.id = id;
+ track.isPlayable = isPlayable;
+ track.linkedFrom = linkedFrom;
+ track.name = name;
+ track.previewUrl = previewUrl;
+ track.trackNumber = trackNumber;
+ track.type = type;
+ track.uri = uri;
+ return track;
+ }
+}
+
+extension TracksToMediaExtension on Iterable {
+ List asMediaList() {
+ return map((track) => SpotubeMedia(track)).toList();
}
}
diff --git a/lib/hooks/configurators/use_close_behavior.dart b/lib/hooks/configurators/use_close_behavior.dart
index 05c03fff..3df6a528 100644
--- a/lib/hooks/configurators/use_close_behavior.dart
+++ b/lib/hooks/configurators/use_close_behavior.dart
@@ -1,28 +1,31 @@
import 'dart:io';
-import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/hooks/configurators/use_window_listener.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
import 'package:local_notifier/local_notifier.dart';
+import 'package:spotube/utils/platform.dart';
+import 'package:window_manager/window_manager.dart';
-final closeNotification = DesktopTools.createNotification(
- title: 'Spotube',
- message: 'Running in background. Minimized to System Tray',
- actions: [
- LocalNotificationAction(text: 'Close The App'),
- ],
-)?..onClickAction = (value) {
- exit(0);
- };
+final closeNotification = !kIsDesktop
+ ? null
+ : (LocalNotification(
+ title: 'Spotube',
+ body: 'Running in background. Minimized to System Tray',
+ actions: [
+ LocalNotificationAction(text: 'Close The App'),
+ ],
+ )..onClickAction = (value) {
+ exit(0);
+ });
void useCloseBehavior(WidgetRef ref) {
useWindowListener(
onWindowClose: () async {
final preferences = ref.read(userPreferencesProvider);
if (preferences.closeBehavior == CloseBehavior.minimizeToTray) {
- await DesktopTools.window.hide();
+ await windowManager.hide();
closeNotification?.show();
} else {
exit(0);
diff --git a/lib/hooks/configurators/use_deep_linking.dart b/lib/hooks/configurators/use_deep_linking.dart
index f11a1cff..90d062dc 100644
--- a/lib/hooks/configurators/use_deep_linking.dart
+++ b/lib/hooks/configurators/use_deep_linking.dart
@@ -1,15 +1,13 @@
import 'dart:async';
import 'package:app_links/app_links.dart';
-import 'package:fl_query_hooks/fl_query_hooks.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:spotify/spotify.dart';
import 'package:spotube/collections/routes.dart';
import 'package:spotube/provider/spotify_provider.dart';
import 'package:flutter_sharing_intent/flutter_sharing_intent.dart';
import 'package:flutter_sharing_intent/model/sharing_file.dart';
-import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
+import 'package:spotube/utils/platform.dart';
final appLinks = AppLinks();
final linkStream = appLinks.allStringLinkStream.asBroadcastStream();
@@ -17,8 +15,6 @@ final linkStream = appLinks.allStringLinkStream.asBroadcastStream();
void useDeepLinking(WidgetRef ref) {
// single instance no worries
final spotify = ref.watch(spotifyProvider);
- final queryClient = useQueryClient();
-
final router = ref.watch(routerProvider);
useEffect(() {
@@ -32,10 +28,7 @@ void useDeepLinking(WidgetRef ref) {
case "album":
router.push(
"/album/${url.pathSegments.last}",
- extra: await queryClient.fetchQuery(
- "album/${url.pathSegments.last}",
- () => spotify.albums.get(url.pathSegments.last),
- ),
+ extra: await spotify.albums.get(url.pathSegments.last),
);
break;
case "artist":
@@ -44,10 +37,7 @@ void useDeepLinking(WidgetRef ref) {
case "playlist":
router.push(
"/playlist/${url.pathSegments.last}",
- extra: await queryClient.fetchQuery(
- "playlist/${url.pathSegments.last}",
- () => spotify.playlists.get(url.pathSegments.last),
- ),
+ extra: await spotify.playlists.get(url.pathSegments.last),
);
break;
case "track":
@@ -63,7 +53,7 @@ void useDeepLinking(WidgetRef ref) {
StreamSubscription? mediaStream;
- if (DesktopTools.platform.isMobile) {
+ if (kIsMobile) {
FlutterSharingIntent.instance.getInitialSharing().then(uriListener);
mediaStream =
@@ -78,10 +68,7 @@ void useDeepLinking(WidgetRef ref) {
case "spotify:album":
await router.push(
"/album/$endSegment",
- extra: await queryClient.fetchQuery(
- "album/$endSegment",
- () => spotify.albums.get(endSegment),
- ),
+ extra: await spotify.albums.get(endSegment),
);
break;
case "spotify:artist":
@@ -93,10 +80,7 @@ void useDeepLinking(WidgetRef ref) {
case "spotify:playlist":
await router.push(
"/playlist/$endSegment",
- extra: await queryClient.fetchQuery(
- "playlist/$endSegment",
- () => spotify.playlists.get(endSegment),
- ),
+ extra: await spotify.playlists.get(endSegment),
);
break;
default:
@@ -108,5 +92,5 @@ void useDeepLinking(WidgetRef ref) {
mediaStream?.cancel();
subscription.cancel();
};
- }, [spotify, queryClient]);
+ }, [spotify]);
}
diff --git a/lib/hooks/configurators/use_disable_battery_optimizations.dart b/lib/hooks/configurators/use_disable_battery_optimizations.dart
index a9afef45..4aa51b74 100644
--- a/lib/hooks/configurators/use_disable_battery_optimizations.dart
+++ b/lib/hooks/configurators/use_disable_battery_optimizations.dart
@@ -1,12 +1,12 @@
import 'package:disable_battery_optimization/disable_battery_optimization.dart';
-import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
+
import 'package:spotube/hooks/utils/use_async_effect.dart';
import 'package:spotube/services/kv_store/kv_store.dart';
+import 'package:spotube/utils/platform.dart';
void useDisableBatteryOptimizations() {
useAsyncEffect(() async {
- if (!DesktopTools.platform.isAndroid ||
- KVStoreService.askedForBatteryOptimization) return;
+ if (!kIsAndroid || KVStoreService.askedForBatteryOptimization) return;
await DisableBatteryOptimization.showDisableBatteryOptimizationSettings();
diff --git a/lib/hooks/configurators/use_endless_playback.dart b/lib/hooks/configurators/use_endless_playback.dart
index f5d11829..98f38165 100644
--- a/lib/hooks/configurators/use_endless_playback.dart
+++ b/lib/hooks/configurators/use_endless_playback.dart
@@ -1,5 +1,4 @@
import 'package:catcher_2/catcher_2.dart';
-import 'package:fl_query_hooks/fl_query_hooks.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart';
@@ -8,40 +7,29 @@ import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
-import 'package:spotube/services/queries/search.dart';
void useEndlessPlayback(WidgetRef ref) {
- final auth = ref.watch(AuthenticationNotifier.provider);
- final playback = ref.watch(ProxyPlaylistNotifier.notifier);
- final playlist = ref.watch(ProxyPlaylistNotifier.provider);
+ final auth = ref.watch(authenticationProvider);
+ final playback = ref.watch(proxyPlaylistProvider.notifier);
+ final playlist = ref.watch(proxyPlaylistProvider);
final spotify = ref.watch(spotifyProvider);
final endlessPlayback =
ref.watch(userPreferencesProvider.select((s) => s.endlessPlayback));
- final queryClient = useQueryClient();
-
useEffect(
() {
if (!endlessPlayback || auth == null) return null;
void listener(int index) async {
try {
- final playlist = ref.read(ProxyPlaylistNotifier.provider);
+ final playlist = ref.read(proxyPlaylistProvider);
if (index != playlist.tracks.length - 1) return;
final track = playlist.tracks.last;
final query = "${track.name} Radio";
- final pages = await queryClient.fetchInfiniteQueryJob,
- dynamic, int, SearchParams>(
- job: SearchQueries.queryJob(query),
- args: (
- spotify: spotify,
- searchType: SearchType.playlist,
- query: query
- ),
- ) ??
- [];
+ final pages = await spotify.search
+ .get(query, types: [SearchType.playlist]).first();
final radios = pages
.expand((e) => e.items?.toList() ?? [])
@@ -68,7 +56,7 @@ void useEndlessPlayback(WidgetRef ref) {
await playback.addTracks(
tracks.toList()
..removeWhere((e) {
- final playlist = ref.read(ProxyPlaylistNotifier.provider);
+ final playlist = ref.read(proxyPlaylistProvider);
final isDuplicate = playlist.tracks.any((t) => t.id == e.id);
return e.id == track.id || isDuplicate;
}),
@@ -94,7 +82,6 @@ void useEndlessPlayback(WidgetRef ref) {
[
spotify,
playback,
- queryClient,
playlist.tracks,
endlessPlayback,
auth,
diff --git a/lib/hooks/configurators/use_get_storage_perms.dart b/lib/hooks/configurators/use_get_storage_perms.dart
index 3fcb369b..9cccbfe0 100644
--- a/lib/hooks/configurators/use_get_storage_perms.dart
+++ b/lib/hooks/configurators/use_get_storage_perms.dart
@@ -1,17 +1,18 @@
import 'package:device_info_plus/device_info_plus.dart';
-import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
+
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:permission_handler/permission_handler.dart';
-import 'package:spotube/components/library/user_local_tracks.dart';
import 'package:spotube/hooks/utils/use_async_effect.dart';
+import 'package:spotube/provider/local_tracks/local_tracks_provider.dart';
+import 'package:spotube/utils/platform.dart';
void useGetStoragePermissions(WidgetRef ref) {
- final isMounted = useIsMounted();
+ final context = useContext();
useAsyncEffect(
() async {
- if (!DesktopTools.platform.isMobile) return;
+ if (!kIsMobile) return;
final androidInfo = await DeviceInfoPlugin().androidInfo;
@@ -25,11 +26,11 @@ void useGetStoragePermissions(WidgetRef ref) {
if (hasNoStoragePerm) {
await Permission.storage.request();
- if (isMounted()) ref.refresh(localTracksProvider);
+ if (context.mounted) ref.invalidate(localTracksProvider);
}
if (hasNoAudioPerm) {
await Permission.audio.request();
- if (isMounted()) ref.refresh(localTracksProvider);
+ if (context.mounted) ref.invalidate(localTracksProvider);
}
},
null,
diff --git a/lib/hooks/configurators/use_init_sys_tray.dart b/lib/hooks/configurators/use_init_sys_tray.dart
deleted file mode 100644
index 8080bea6..00000000
--- a/lib/hooks/configurators/use_init_sys_tray.dart
+++ /dev/null
@@ -1,128 +0,0 @@
-import 'dart:io';
-
-import 'package:flutter/material.dart';
-import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
-import 'package:flutter_hooks/flutter_hooks.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:spotube/collections/intents.dart';
-import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
-import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
-import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
-
-void useInitSysTray(WidgetRef ref) {
- final context = useContext();
- final systemTray = useRef(null);
-
- final initializeMenu = useCallback(() async {
- systemTray.value?.destroy();
- final playlist = ref.read(ProxyPlaylistNotifier.provider);
- final playlistQueue = ref.read(ProxyPlaylistNotifier.notifier);
- final preferences = ref.read(userPreferencesProvider);
- if (!preferences.showSystemTrayIcon) {
- await systemTray.value?.destroy();
- systemTray.value = null;
- return;
- }
- final enabled = !playlist.isFetching;
- systemTray.value = await DesktopTools.createSystemTrayMenu(
- title: DesktopTools.platform.isWindows ? "Spotube" : "",
- iconPath: "assets/spotube-logo.png",
- windowsIconPath: "assets/spotube-logo.ico",
- items: [
- MenuItemLabel(
- label: "Show/Hide",
- name: "show-hide",
- onClicked: (item) async {
- if (await DesktopTools.window.isVisible()) {
- await DesktopTools.window.hide();
- } else {
- await DesktopTools.window.show();
- }
- },
- ),
- MenuSeparator(),
- MenuItemLabel(
- label: "Play/Pause",
- name: "play-pause",
- enabled: enabled,
- onClicked: (_) async {
- Actions.maybeInvoke(
- context, PlayPauseIntent(ref)) ??
- PlayPauseAction().invoke(PlayPauseIntent(ref));
- },
- ),
- MenuItemLabel(
- label: "Next",
- name: "next",
- enabled: enabled && (playlist.tracks.length) > 1,
- onClicked: (p0) async {
- await playlistQueue.next();
- },
- ),
- MenuItemLabel(
- label: "Previous",
- name: "previous",
- enabled: enabled && (playlist.tracks.length) > 1,
- onClicked: (p0) async {
- await playlistQueue.previous();
- },
- ),
- MenuSeparator(),
- MenuItemLabel(
- label: "Quit",
- name: "quit",
- onClicked: (item) async {
- exit(0);
- },
- ),
- ],
- onEvent: (event, tray) async {
- if (DesktopTools.platform.isWindows) {
- switch (event) {
- case SystemTrayEvent.click:
- await DesktopTools.window.show();
- break;
- case SystemTrayEvent.rightClick:
- await tray.popUpContextMenu();
- break;
- default:
- }
- } else {
- switch (event) {
- case SystemTrayEvent.rightClick:
- await DesktopTools.window.show();
- break;
- case SystemTrayEvent.click:
- await tray.popUpContextMenu();
- break;
- default:
- }
- }
- },
- );
- }, [ref]);
-
- useReassemble(initializeMenu);
-
- ref.listen(
- ProxyPlaylistNotifier.provider,
- (previous, next) {
- initializeMenu();
- },
- );
- ref.listen(
- userPreferencesProvider.select((s) => s.showSystemTrayIcon),
- (previous, next) {
- initializeMenu();
- },
- );
-
- useEffect(() {
- WidgetsBinding.instance.addPostFrameCallback((_) {
- initializeMenu();
- });
- return () async {
- await systemTray.value?.destroy();
- };
- }, [initializeMenu]);
-}
diff --git a/lib/hooks/configurators/use_update_checker.dart b/lib/hooks/configurators/use_update_checker.dart
deleted file mode 100644
index 1a6a5be5..00000000
--- a/lib/hooks/configurators/use_update_checker.dart
+++ /dev/null
@@ -1,100 +0,0 @@
-import 'dart:async';
-import 'dart:convert';
-
-import 'package:flutter/material.dart';
-import 'package:flutter_hooks/flutter_hooks.dart';
-import 'package:flutter_riverpod/flutter_riverpod.dart';
-import 'package:http/http.dart' as http;
-import 'package:spotube/collections/env.dart';
-
-import 'package:spotube/components/shared/links/anchor_button.dart';
-import 'package:spotube/hooks/controllers/use_package_info.dart';
-import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
-import 'package:url_launcher/url_launcher_string.dart';
-import 'package:version/version.dart';
-
-void useUpdateChecker(WidgetRef ref) {
- final isCheckUpdateEnabled =
- ref.watch(userPreferencesProvider.select((s) => s.checkUpdate));
- final packageInfo = usePackageInfo(
- appName: 'Spotube',
- packageName: 'spotube',
- );
- final Future> Function() checkUpdate = useCallback(
- () async {
- final value = await http.get(
- Uri.parse(
- "https://api.github.com/repos/KRTirtho/spotube/releases/latest"),
- );
- final tagName =
- (jsonDecode(value.body)["tag_name"] as String).replaceAll("v", "");
- final currentVersion = packageInfo.version == "Unknown"
- ? null
- : Version.parse(packageInfo.version);
- final latestVersion =
- tagName == "nightly" ? null : Version.parse(tagName);
- return [currentVersion, latestVersion];
- },
- [packageInfo.version],
- );
-
- final context = useContext();
-
- download(String url) => launchUrlString(
- url,
- mode: LaunchMode.externalApplication,
- );
-
- useEffect(() {
- if (!Env.enableUpdateChecker) return;
- if (!isCheckUpdateEnabled) return null;
- checkUpdate().then((value) {
- final currentVersion = value.first;
- final latestVersion = value.last;
- if (currentVersion == null ||
- latestVersion == null ||
- (latestVersion.isPreRelease && !currentVersion.isPreRelease) ||
- (!latestVersion.isPreRelease && currentVersion.isPreRelease)) return;
- if (latestVersion <= currentVersion) return;
- showDialog(
- context: context,
- barrierDismissible: true,
- barrierColor: Colors.black26,
- builder: (context) {
- const url =
- "https://spotube.krtirtho.dev/other-downloads/stable-downloads";
- return AlertDialog(
- title: const Text("Spotube has an update"),
- actions: [
- FilledButton(
- child: const Text("Download Now"),
- onPressed: () => download(url),
- ),
- ],
- content: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Text("Spotube v${value.last} has been released"),
- Row(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- const Text("Read the latest "),
- AnchorButton(
- "release notes",
- style: const TextStyle(color: Colors.blue),
- onTap: () => launchUrlString(
- url,
- mode: LaunchMode.externalApplication,
- ),
- ),
- ],
- ),
- ],
- ),
- );
- },
- );
- });
- return null;
- }, [packageInfo, isCheckUpdateEnabled]);
-}
diff --git a/lib/hooks/configurators/use_window_listener.dart b/lib/hooks/configurators/use_window_listener.dart
index b91ad413..5977ea8e 100644
--- a/lib/hooks/configurators/use_window_listener.dart
+++ b/lib/hooks/configurators/use_window_listener.dart
@@ -1,6 +1,8 @@
import 'package:flutter/widgets.dart';
-import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
+
import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:spotube/utils/platform.dart';
+import 'package:window_manager/window_manager.dart';
class CallbackWindowListener implements WindowListener {
final VoidCallback? _onWindowClose;
@@ -154,6 +156,8 @@ void useWindowListener({
VoidCallback? onWindowEvent,
}) {
useEffect(() {
+ if (!kIsDesktop) return null;
+
final listener = CallbackWindowListener(
onWindowClose: onWindowClose,
onWindowFocus: onWindowFocus,
@@ -172,9 +176,9 @@ void useWindowListener({
onWindowUndocked: onWindowUndocked,
onWindowEvent: onWindowEvent,
);
- DesktopTools.window.addListener(listener);
+ windowManager.addListener(listener);
return () {
- DesktopTools.window.removeListener(listener);
+ windowManager.removeListener(listener);
};
}, [
onWindowClose,
diff --git a/lib/hooks/controllers/use_auto_scroll_controller.dart b/lib/hooks/controllers/use_auto_scroll_controller.dart
index 8edfb041..0c7119e4 100644
--- a/lib/hooks/controllers/use_auto_scroll_controller.dart
+++ b/lib/hooks/controllers/use_auto_scroll_controller.dart
@@ -39,8 +39,8 @@ class _AutoScrollControllerHook extends Hook {
this.copyTagsFrom,
this.suggestedRowHeight,
this.debugLabel,
- List? keys,
- }) : super(keys: keys);
+ super.keys,
+ });
final double initialScrollOffset;
final bool keepScrollOffset;
diff --git a/lib/hooks/controllers/use_package_info.dart b/lib/hooks/controllers/use_package_info.dart
index 9b142ced..b3c05665 100644
--- a/lib/hooks/controllers/use_package_info.dart
+++ b/lib/hooks/controllers/use_package_info.dart
@@ -44,8 +44,8 @@ class _PackageInfoHook extends Hook {
required this.version,
required this.buildNumber,
this.buildSignature = '',
- List? keys,
- }) : super(keys: keys);
+ super.keys,
+ });
@override
HookState> createState() =>
diff --git a/lib/hooks/controllers/use_sidebarx_controller.dart b/lib/hooks/controllers/use_sidebarx_controller.dart
index 5af921b7..a14c3305 100644
--- a/lib/hooks/controllers/use_sidebarx_controller.dart
+++ b/lib/hooks/controllers/use_sidebarx_controller.dart
@@ -24,8 +24,8 @@ class _SidebarXControllerHook extends Hook {
const _SidebarXControllerHook({
required this.selectedIndex,
this.extended,
- List? keys,
- }) : super(keys: keys);
+ super.keys,
+ });
final int selectedIndex;
final bool? extended;
diff --git a/lib/hooks/spotify/use_spotify_infinite_query.dart b/lib/hooks/spotify/use_spotify_infinite_query.dart
deleted file mode 100644
index 2063b083..00000000
--- a/lib/hooks/spotify/use_spotify_infinite_query.dart
+++ /dev/null
@@ -1,53 +0,0 @@
-import 'dart:async';
-
-import 'package:fl_query/fl_query.dart';
-import 'package:fl_query_hooks/fl_query_hooks.dart';
-import 'package:flutter/material.dart';
-import 'package:flutter_hooks/flutter_hooks.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:spotify/spotify.dart';
-import 'package:spotube/provider/spotify_provider.dart';
-
-InfiniteQuery
- useSpotifyInfiniteQuery(
- String queryKey,
- FutureOr Function(PageType page, SpotifyApi spotify) queryFn, {
- required WidgetRef ref,
- required InfiniteQueryNextPage nextPage,
- required PageType initialPage,
- RetryConfig? retryConfig,
- RefreshConfig? refreshConfig,
- JsonConfig? jsonConfig,
- ValueChanged>? onData,
- ValueChanged>? onError,
- bool enabled = true,
- List? keys,
-}) {
- final spotify = ref.watch(spotifyProvider);
- final query = useInfiniteQuery(
- queryKey,
- (page) => queryFn(page, spotify),
- nextPage: nextPage,
- initialPage: initialPage,
- retryConfig: retryConfig,
- refreshConfig: refreshConfig,
- jsonConfig: jsonConfig,
- onData: onData,
- onError: onError,
- enabled: enabled,
- keys: keys,
- );
-
- useEffect(() {
- return ref.listenManual(
- spotifyProvider,
- (previous, next) {
- if (previous != next) {
- query.refreshAll();
- }
- },
- ).close;
- }, [query]);
-
- return query;
-}
diff --git a/lib/hooks/spotify/use_spotify_mutation.dart b/lib/hooks/spotify/use_spotify_mutation.dart
deleted file mode 100644
index 637f778f..00000000
--- a/lib/hooks/spotify/use_spotify_mutation.dart
+++ /dev/null
@@ -1,36 +0,0 @@
-import 'package:fl_query/fl_query.dart';
-import 'package:fl_query_hooks/fl_query_hooks.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:spotify/spotify.dart';
-import 'package:spotube/provider/spotify_provider.dart';
-
-Mutation
- useSpotifyMutation(
- String mutationKey,
- Future Function(VariablesType variables, SpotifyApi spotify)
- mutationFn, {
- required WidgetRef ref,
- RetryConfig? retryConfig,
- MutationOnDataFn? onData,
- MutationOnErrorFn? onError,
- MutationOnMutationFn? onMutate,
- List? refreshQueries,
- List? refreshInfiniteQueries,
- List? keys,
-}) {
- final spotify = ref.watch(spotifyProvider);
- final mutation =
- useMutation(
- mutationKey,
- (variables) => mutationFn(variables, spotify),
- retryConfig: retryConfig,
- onData: onData,
- onError: onError,
- onMutate: onMutate,
- refreshQueries: refreshQueries,
- refreshInfiniteQueries: refreshInfiniteQueries,
- keys: keys,
- );
-
- return mutation;
-}
diff --git a/lib/hooks/spotify/use_spotify_query.dart b/lib/hooks/spotify/use_spotify_query.dart
deleted file mode 100644
index 0c79de91..00000000
--- a/lib/hooks/spotify/use_spotify_query.dart
+++ /dev/null
@@ -1,52 +0,0 @@
-import 'dart:async';
-
-import 'package:fl_query/fl_query.dart';
-import 'package:fl_query_hooks/fl_query_hooks.dart';
-import 'package:flutter/material.dart';
-import 'package:flutter_hooks/flutter_hooks.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:spotify/spotify.dart';
-import 'package:spotube/provider/spotify_provider.dart';
-
-typedef SpotifyQueryFn = FutureOr Function(
- SpotifyApi spotify);
-
-Query useSpotifyQuery(
- final String queryKey,
- final SpotifyQueryFn queryFn, {
- required WidgetRef ref,
- final DataType? initial,
- final RetryConfig? retryConfig,
- final RefreshConfig? refreshConfig,
- final JsonConfig? jsonConfig,
- final ValueChanged? onData,
- final ValueChanged? onError,
- final bool enabled = true,
-}) {
- final spotify = ref.watch(spotifyProvider);
-
- final query = useQuery(
- queryKey,
- () => queryFn(spotify),
- initial: initial,
- retryConfig: retryConfig,
- refreshConfig: refreshConfig,
- jsonConfig: jsonConfig,
- onData: onData,
- onError: onError,
- enabled: enabled,
- );
-
- useEffect(() {
- return ref.listenManual(
- spotifyProvider,
- (previous, next) {
- if (previous != next) {
- query.refresh();
- }
- },
- ).close;
- }, [query]);
-
- return query;
-}
diff --git a/lib/hooks/utils/use_custom_status_bar_color.dart b/lib/hooks/utils/use_custom_status_bar_color.dart
index d1266fe2..7c5c7b27 100644
--- a/lib/hooks/utils/use_custom_status_bar_color.dart
+++ b/lib/hooks/utils/use_custom_status_bar_color.dart
@@ -19,11 +19,13 @@ void useCustomStatusBarColor(
),
);
+ // ignore: invalid_use_of_visible_for_testing_member
final statusBarColor = SystemChrome.latestStyle?.statusBarColor;
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (automaticSystemUiAdjustment != null) {
+ // ignore: deprecated_member_use
WidgetsBinding.instance.renderView.automaticSystemUiAdjustment =
automaticSystemUiAdjustment;
}
@@ -43,6 +45,7 @@ void useCustomStatusBarColor(
});
return () {
if (automaticSystemUiAdjustment != null) {
+ // ignore: deprecated_member_use
WidgetsBinding.instance.renderView.automaticSystemUiAdjustment = false;
}
};
diff --git a/lib/hooks/utils/use_force_update.dart b/lib/hooks/utils/use_force_update.dart
index 74151a65..268f0f04 100644
--- a/lib/hooks/utils/use_force_update.dart
+++ b/lib/hooks/utils/use_force_update.dart
@@ -2,5 +2,6 @@ import 'package:flutter_hooks/flutter_hooks.dart';
void Function() useForceUpdate() {
final state = useState(null);
+ // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
return () => state.notifyListeners();
}
diff --git a/lib/hooks/utils/use_palette_color.dart b/lib/hooks/utils/use_palette_color.dart
index 9269edd7..e6d8b398 100644
--- a/lib/hooks/utils/use_palette_color.dart
+++ b/lib/hooks/utils/use_palette_color.dart
@@ -14,7 +14,6 @@ PaletteColor usePaletteColor(String imageUrl, WidgetRef ref) {
final context = useContext();
final theme = Theme.of(context);
final paletteColor = ref.watch(_paletteColorState);
- final mounted = useIsMounted();
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) async {
@@ -25,7 +24,7 @@ PaletteColor usePaletteColor(String imageUrl, WidgetRef ref) {
width: 50,
),
);
- if (!mounted()) return;
+ if (!context.mounted) return;
final color = theme.brightness == Brightness.light
? palette.lightMutedColor ?? palette.lightVibrantColor
: palette.darkMutedColor ?? palette.darkVibrantColor;
@@ -41,7 +40,7 @@ PaletteColor usePaletteColor(String imageUrl, WidgetRef ref) {
PaletteGenerator usePaletteGenerator(String imageUrl) {
final palette = useState(PaletteGenerator.fromColors([]));
- final mounted = useIsMounted();
+ final context = useContext();
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) async {
@@ -52,7 +51,7 @@ PaletteGenerator usePaletteGenerator(String imageUrl) {
width: 50,
),
);
- if (!mounted()) return;
+ if (!context.mounted) return;
palette.value = newPalette;
});
diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb
index 41fab083..b474ec7e 100644
--- a/lib/l10n/app_ar.arb
+++ b/lib/l10n/app_ar.arb
@@ -313,5 +313,17 @@
"help_project_grow_description": "Spotube هو مشروع مفتوح المصدر. يمكنك مساعدة هذا المشروع في النمو عن طريق المساهمة في المشروع، أو الإبلاغ عن الأخطاء، أو اقتراح ميزات جديدة.",
"contribute_on_github": "المساهمة على GitHub",
"donate_on_open_collective": "التبرع على Open Collective",
- "browse_anonymously": "تصفح بشكل مجهول"
+ "browse_anonymously": "تصفح بشكل مجهول",
+ "enable_connect": "تمكين الاتصال",
+ "enable_connect_description": "التحكم في Spotube من الأجهزة الأخرى",
+ "devices": "الأجهزة",
+ "select": "اختر",
+ "connect_client_alert": "أنت تتم التحكم بواسطة {client}",
+ "this_device": "هذا الجهاز",
+ "remote": "بعيد",
+ "local_library": "المكتبة المحلية",
+ "add_library_location": "أضف إلى المكتبة",
+ "remove_library_location": "إزالة من المكتبة",
+ "local_tab": "محلي",
+ "stats": "إحصائيات"
}
\ No newline at end of file
diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb
index 353ca617..2cf8dd43 100644
--- a/lib/l10n/app_bn.arb
+++ b/lib/l10n/app_bn.arb
@@ -313,5 +313,17 @@
"help_project_grow_description": "স্পটুব একটি ওপেন সোর্স প্রকল্প। আপনি প্রকল্পে অবদান রাখেন, বাগ রিপোর্ট করেন, বা নতুন বৈশিষ্ট্যগুলি সুপারিশ করেন।",
"contribute_on_github": "গিটহাবে অবদান রাখুন",
"donate_on_open_collective": "ওপেন কলেক্টিভে অনুদান করুন",
- "browse_anonymously": "অজানে ব্রাউজ করুন"
+ "browse_anonymously": "অজানে ব্রাউজ করুন",
+ "enable_connect": "সংযোগ সক্রিয় করুন",
+ "enable_connect_description": "অন্যান্য ডিভাইস থেকে Spotube নিয়ন্ত্রণ করুন",
+ "devices": "ডিভাইস",
+ "select": "নির্বাচন করুন",
+ "connect_client_alert": "আপনি {client} দ্বারা নিয়ন্ত্রিত হচ্ছেন",
+ "this_device": "এই ডিভাইস",
+ "remote": "রিমোট",
+ "local_library": "স্থানীয় লাইব্রেরি",
+ "add_library_location": "লাইব্রেরিতে যোগ করুন",
+ "remove_library_location": "লাইব্রেরি থেকে সরান",
+ "local_tab": "স্থানীয়",
+ "stats": "পরিসংখ্যান"
}
\ No newline at end of file
diff --git a/lib/l10n/app_ca.arb b/lib/l10n/app_ca.arb
index 9848954a..ca4b019a 100644
--- a/lib/l10n/app_ca.arb
+++ b/lib/l10n/app_ca.arb
@@ -313,5 +313,17 @@
"help_project_grow_description": "Spotube és un projecte de codi obert. Podeu ajudar a fer créixer aquest projecte contribuint al projecte, informant d'errors o suggerint noves funcionalitats.",
"contribute_on_github": "Contribueix a GitHub",
"donate_on_open_collective": "Fes una donació a Open Collective",
- "browse_anonymously": "Navega de manera anònima"
+ "browse_anonymously": "Navega de manera anònima",
+ "enable_connect": "Habilita la connexió",
+ "enable_connect_description": "Controla Spotube des d'altres dispositius",
+ "devices": "Dispositius",
+ "select": "Selecciona",
+ "connect_client_alert": "Estàs sent controlat per {client}",
+ "this_device": "Aquest dispositiu",
+ "remote": "Remot",
+ "local_library": "Biblioteca local",
+ "add_library_location": "Afegeix a la biblioteca",
+ "remove_library_location": "Elimina de la biblioteca",
+ "local_tab": "Local",
+ "stats": "Estadístiques"
}
\ No newline at end of file
diff --git a/lib/l10n/app_cs.arb b/lib/l10n/app_cs.arb
new file mode 100644
index 00000000..7191c108
--- /dev/null
+++ b/lib/l10n/app_cs.arb
@@ -0,0 +1,329 @@
+{
+ "guest": "Host",
+ "browse": "Procházet",
+ "search": "Hledat",
+ "library": "Knihovna",
+ "lyrics": "Texty",
+ "settings": "Nastavení",
+ "genre_categories_filter": "Filtrovat kategorie nebo žánry...",
+ "genre": "Žánr",
+ "personalized": "Personalizované",
+ "featured": "Doporučené",
+ "new_releases": "Nově vydané",
+ "songs": "Skladby",
+ "playing_track": "Hraje {track}",
+ "queue_clear_alert": "Toto vymaže aktuální frontu. {track_length} skladeb bude odstraněno\nChcete pokračovat?",
+ "load_more": "Načíst více",
+ "playlists": "Playlisty",
+ "artists": "Umělci",
+ "albums": "Alba",
+ "tracks": "Skladby",
+ "downloads": "Stahování",
+ "filter_playlists": "Filtrovat playlisty...",
+ "liked_tracks": "Oblíbené skladby",
+ "liked_tracks_description": "Všechny vaše oblíbené skladby",
+ "create_playlist": "Vytvořit playlist",
+ "create_a_playlist": "Vytvořit playlist",
+ "update_playlist": "Aktualizovat playlist",
+ "create": "Vytvořit",
+ "cancel": "Zrušit",
+ "update": "Aktualizovat",
+ "playlist_name": "Název playlistu",
+ "name_of_playlist": "Název playlistu",
+ "description": "Popis",
+ "public": "Veřejné",
+ "collaborative": "Společný",
+ "search_local_tracks": "Hledat místní skladby...",
+ "play": "Přehrát",
+ "delete": "Smazat",
+ "none": "Žádné",
+ "sort_a_z": "Seřadit od A-Z",
+ "sort_z_a": "Seřadit od Z-A",
+ "sort_artist": "Seřadit podle umělce",
+ "sort_album": "Seřadit podle alba",
+ "sort_duration": "Seřadit podle délky",
+ "sort_tracks": "Seřadit skladby",
+ "currently_downloading": "Právě se stahuje ({tracks_length})",
+ "cancel_all": "Zrušit vše",
+ "filter_artist": "Filtrovat umělce...",
+ "followers": "{followers} Sledující",
+ "add_artist_to_blacklist": "Přidat umělce na černou listinu",
+ "top_tracks": "Top skladby",
+ "fans_also_like": "Fanoušci mají také rádi",
+ "loading": "Načítání...",
+ "artist": "Umělec",
+ "blacklisted": "Na černé listině",
+ "following": "Sleduje",
+ "follow": "Sledovat",
+ "artist_url_copied": "URL umělce zkopírována do schránky",
+ "added_to_queue": "Přidáno {tracks} skladeb do fronty",
+ "filter_albums": "Filtrovat alba...",
+ "synced": "Synchronizováno",
+ "plain": "Jednoduché",
+ "shuffle": "Zamíchat",
+ "search_tracks": "Hledat skladby...",
+ "released": "Vydáno",
+ "error": "Chyba {error}",
+ "title": "Název",
+ "time": "Čas",
+ "more_actions": "Více akcí",
+ "download_count": "Stáhnout ({count})",
+ "add_count_to_playlist": "Přidat ({count}) do playlistu",
+ "add_count_to_queue": "Přidat ({count}) do fronty",
+ "play_count_next": "Přehrát ({count}) dalších",
+ "album": "Album",
+ "copied_to_clipboard": "Zkopírováno {data} do schránky",
+ "add_to_following_playlists": "Přidat {track} do následujících playlistů",
+ "add": "Přidat",
+ "added_track_to_queue": "Přidána skladba {track} do fronty",
+ "add_to_queue": "Přidat do fronty",
+ "track_will_play_next": "{track} se přehraje jako další",
+ "play_next": "Přehrát další",
+ "removed_track_from_queue": "Odstraněna skladba {track} z fronty",
+ "remove_from_queue": "Odstranit z fronty",
+ "remove_from_favorites": "Odstranit z oblíbených",
+ "save_as_favorite": "Uložit jako oblíbené",
+ "add_to_playlist": "Přidat do playlistu",
+ "remove_from_playlist": "Odstranit z playlistu",
+ "add_to_blacklist": "Přidat na černou listinu",
+ "remove_from_blacklist": "Odstranit z černé listiny",
+ "share": "Sdílet",
+ "mini_player": "Mini přehrávač",
+ "slide_to_seek": "Táhněte pro posunutí vpřed nebo vzad",
+ "shuffle_playlist": "Zamíchat playlist",
+ "unshuffle_playlist": "Zrušit zamíchání playlistu",
+ "previous_track": "Předchozí skladba",
+ "next_track": "Další skladba",
+ "pause_playback": "Pozastavit přehrávání",
+ "resume_playback": "Pokračovat v přehrávání",
+ "loop_track": "Opakovat skladbu",
+ "repeat_playlist": "Opakovat playlist",
+ "queue": "Fronta",
+ "alternative_track_sources": "Alternativní zdroje skladeb",
+ "download_track": "Stáhnout skladbu",
+ "tracks_in_queue": "{tracks} skladeb ve frontě",
+ "clear_all": "Vymazat vše",
+ "show_hide_ui_on_hover": "Zobrazit/Skrýt UI při najetí",
+ "always_on_top": "Vždy nahoře",
+ "exit_mini_player": "Zavřít mini přehrávač",
+ "download_location": "Umístění stahování",
+ "account": "Účet",
+ "login_with_spotify": "Přihlásit se pomocí Spotify účtu",
+ "connect_with_spotify": "Připojit k Spotify",
+ "logout": "Odhlásit se",
+ "logout_of_this_account": "Odhlásit se z tohoto účtu",
+ "language_region": "Jazyk a region",
+ "language": "Jazyk",
+ "system_default": "Systém",
+ "market_place_region": "Region",
+ "recommendation_country": "Země pro doporučení",
+ "appearance": "Vzhled",
+ "layout_mode": "Režim rozložení",
+ "override_layout_settings": "Přepsat režim rozložení",
+ "adaptive": "Adaptivní",
+ "compact": "Kompaktní",
+ "extended": "Rozšířený",
+ "theme": "Téma",
+ "dark": "Tmavé",
+ "light": "Světlé",
+ "system": "Systém",
+ "accent_color": "Barva akcentu",
+ "sync_album_color": "Synchronizovat barvu alba",
+ "sync_album_color_description": "Používá dominantní barvu obalu alba jako barvu akcentu",
+ "playback": "Přehrávání",
+ "audio_quality": "Kvalita zvuku",
+ "high": "Vysoká",
+ "low": "Nízká",
+ "pre_download_play": "Předstáhnout a přehrát",
+ "pre_download_play_description": "Místo streamování audia stáhnout skladbu a přehrát (doporučeno pro uživatele s rychlejším internetem)",
+ "skip_non_music": "Přeskočit nehudební segmenty (SponsorBlock)",
+ "blacklist_description": "Zakázané skladby a umělci",
+ "wait_for_download_to_finish": "Počkejte, až se dokončí stahování",
+ "desktop": "Desktop",
+ "close_behavior": "Chování při zavření",
+ "close": "Zavřít",
+ "minimize_to_tray": "Minimalizovat do lišty",
+ "show_tray_icon": "Zobrazit ikonu v systémové liště",
+ "about": "O aplikaci",
+ "u_love_spotube": "Víme, že milujete Spotube",
+ "check_for_updates": "Zkontrolovat aktualizace",
+ "about_spotube": "O Spotube",
+ "blacklist": "Černá listina",
+ "please_sponsor": "Sponzorovat/darovat",
+ "spotube_description": "Spotube, rychlý, multiplatformní, bezplatný Spotify klient",
+ "version": "Verze",
+ "build_number": "Číslo sestavení",
+ "founder": "Zakladatel",
+ "repository": "Repozitář",
+ "bug_issues": "Chyby+Problémy",
+ "made_with": "Vytvořeno s ❤️ v Bangladéši🇧🇩",
+ "kingkor_roy_tirtho": "Kingkor Roy Tirtho",
+ "copyright": "© 2021-{current_year} Kingkor Roy Tirtho",
+ "license": "Licence",
+ "add_spotify_credentials": "Přidejte své přihlašovací údaje Spotify a začněte",
+ "credentials_will_not_be_shared_disclaimer": "Nebojte, žádné z vašich údajů nebudou shromažďovány ani s nikým sdíleny",
+ "know_how_to_login": "Nevíte, jak na to?",
+ "follow_step_by_step_guide": "Postupujte podle návodu",
+ "spotify_cookie": "Cookie Spotify {name}",
+ "cookie_name_cookie": "Cookie {name}",
+ "fill_in_all_fields": "Vyplňte prosím všechna pole",
+ "submit": "Odeslat",
+ "exit": "Ukončit",
+ "previous": "Předchozí",
+ "next": "Další",
+ "done": "Hotovo",
+ "step_1": "Krok 1",
+ "first_go_to": "Nejprve jděte na",
+ "login_if_not_logged_in": "a přihlašte se nebo se zaregistrujte, pokud nejste přihlášeni",
+ "step_2": "Krok 2",
+ "step_2_steps": "1. Jakmile jste přihlášeni, stiskněte F12 nebo pravé tlačítko myši > Prozkoumat, abyste otevřeli nástroje pro vývojáře prohlížeče.\n2. Poté přejděte na kartu \"Aplikace\" (Chrome, Edge, Brave atd.) nebo kartu \"Úložiště\" (Firefox, Palemoon atd.)\n3. Přejděte do sekce \"Cookies\" a pak do podsekce \"https://accounts.spotify.com\"",
+ "step_3": "Krok 3",
+ "step_3_steps": "Zkopírujte hodnotu cookie \"sp_dc\"",
+ "success_emoji": "Úspěch🥳",
+ "success_message": "Nyní jste úspěšně přihlášeni pomocí svého Spotify účtu. Dobrá práce, kamaráde!",
+ "step_4": "Krok 4",
+ "step_4_steps": "Vložte zkopírovanou hodnotu \"sp_dc\"",
+ "something_went_wrong": "Něco se pokazilo",
+ "piped_instance": "Instance serveru Piped",
+ "piped_description": "Instance serveru Piped, kterou použít pro hledání skladeb",
+ "piped_warning": "Některé z nich nemusí dobře fungovat. Používejte na vlastní riziko",
+ "generate_playlist": "Vygenerovat playlist",
+ "track_exists": "Skladba {track} již existuje",
+ "replace_downloaded_tracks": "Nahradit všechny stažené skladby",
+ "skip_download_tracks": "Přeskočit stahování všech stažených skladeb",
+ "do_you_want_to_replace": "Chcete nahradit existující skladbu??",
+ "replace": "Nahradit",
+ "skip": "Přeskočit",
+ "select_up_to_count_type": "Vyberte až {count} {type}",
+ "select_genres": "Vyberte žánry",
+ "add_genres": "Přidat žánry",
+ "country": "Země",
+ "number_of_tracks_generate": "Počet skladeb k vygenerování",
+ "acousticness": "Akustičnost",
+ "danceability": "Tanečnost",
+ "energy": "Energie",
+ "instrumentalness": "Instrumentálnost",
+ "liveness": "Živost",
+ "loudness": "Hlasitost",
+ "speechiness": "Mluvnost",
+ "valence": "Valence",
+ "popularity": "Popularita",
+ "key": "Klíč",
+ "duration": "Délka (s)",
+ "tempo": "Tempo (BPM)",
+ "mode": "Režim",
+ "time_signature": "Udání taktu",
+ "short": "Krátký",
+ "medium": "Střední",
+ "long": "Dlouhý",
+ "min": "Min",
+ "max": "Max",
+ "target": "Cíl",
+ "moderate": "Mírný",
+ "deselect_all": "Zrušit výběr",
+ "select_all": "Vybrat vše",
+ "are_you_sure": "Jste si jisti?",
+ "generating_playlist": "Generování vašeho vlastního playlistu...",
+ "selected_count_tracks": "Vybráno {count} skladeb",
+ "download_warning": "Pokud stáhnete všechny skladby najednou, pirátíte tím hudbu a škodíte kreativní společnosti hudby. Doufám, že jste si toho vědomi. Vždy se snažte respektovat a podporovat tvrdou práci umělců",
+ "download_ip_ban_warning": "Mimochodem, vaše IP může být na YouTube zablokována kvůli nadměrným požadavkům na stahování. Blokování IP znamená, že nemůžete používat YouTube (i když jste přihlášeni) alespoň 2-3 měsíce ze zařízení s touto IP. A Spotube nenese žádnou odpovědnost, pokud se to někdy stane",
+ "by_clicking_accept_terms": "Kliknutím na 'přijmout' souhlasíte s následujícími podmínkami:",
+ "download_agreement_1": "Vím, že pirátím hudbu. Jsem špatný",
+ "download_agreement_2": "Budu podporovat umělce, kdekoliv to bude možné, a dělám to jen proto, že nemám peníze na koupi jejich umění",
+ "download_agreement_3": "Jsem si naprosto vědom toho, že moje IP může být na YouTube zablokována a nenesu žádnou odpovědnost za nehody způsobené mým současným jednáním",
+ "decline": "Odmítnout",
+ "accept": "Přijmout",
+ "details": "Podrobnosti",
+ "youtube": "YouTube",
+ "channel": "Kanál",
+ "likes": "Líbí se",
+ "dislikes": "Nelíbí se",
+ "views": "Zobrazení",
+ "streamUrl": "URL streamu",
+ "stop": "Zastavit",
+ "sort_newest": "Seřadit od nejnovějších",
+ "sort_oldest": "Seřadit od nejstarších",
+ "sleep_timer": "Časovač spánku",
+ "mins": "{minutes} Minut",
+ "hours": "{hours} Hodin",
+ "hour": "{hours} Hodina",
+ "custom_hours": "Vlastní hodiny",
+ "logs": "Protokoly",
+ "developers": "Vývojáři",
+ "not_logged_in": "Nejste přihlášeni",
+ "search_mode": "Režim hledání",
+ "audio_source": "Zdroj zvuku",
+ "ok": "Ok",
+ "failed_to_encrypt": "Šifrování selhalo",
+ "encryption_failed_warning": "Spotube používá šifrování k bezpečnému ukládání vašich dat. Ale selhalo. Takže se vrátí k nezabezpečenému úložišti\nPokud používáte linux, ujistěte se, že máte nainstalovanou jakoukoli službu k ukládání bezpečnostních pověření (gnome-keyring, kde-wallet, keepassxc atd.)",
+ "querying_info": "Získávání informací...",
+ "piped_api_down": "Piped API je mimo provoz",
+ "piped_down_error_instructions": "Instance Piped {pipedInstance} je momentálně mimo provoz\n\nBuď změňte instanci nebo změňte 'Typ API' na oficiální YouTube API\n\nPo změně se ujistěte, že aplikaci restartujete",
+ "you_are_offline": "Momentálně jste offline",
+ "connection_restored": "Vaše internetové připojení bylo obnoveno",
+ "use_system_title_bar": "Použít systémové záhlaví okna",
+ "crunching_results": "Zpracovávání výsledků...",
+ "search_to_get_results": "Hledejte pro získání výsledků",
+ "use_amoled_mode": "Úplně černé téma",
+ "pitch_dark_theme": "AMOLED režim",
+ "normalize_audio": "Normalizovat audio",
+ "change_cover": "Změnit obal",
+ "add_cover": "Přidat obal",
+ "restore_defaults": "Obnovit výchozí",
+ "download_music_codec": "Kodek pro stahování",
+ "streaming_music_codec": "Kodek pro streamování",
+ "login_with_lastfm": "Přihlásit se pomocí Last.fm",
+ "connect": "Připojit",
+ "disconnect_lastfm": "Odpojit Last.fm",
+ "disconnect": "Odpojit",
+ "username": "Uživatelské jméno",
+ "password": "Heslo",
+ "login": "Přihlásit se",
+ "login_with_your_lastfm": "Přihlásit se pomocí vašeho Last.fm účtu",
+ "scrobble_to_lastfm": "Scrobble na Last.fm",
+ "go_to_album": "Přejít na album",
+ "discord_rich_presence": "Discord Rich Presence",
+ "browse_all": "Procházet vše",
+ "genres": "Žánry",
+ "explore_genres": "Prozkoumat žánry",
+ "friends": "Přátelé",
+ "no_lyrics_available": "Omlouváme se, není možné najít texty pro tuto skladbu",
+ "start_a_radio": "Vytvořit rádio",
+ "how_to_start_radio": "Jak chcete vytvořit rádio?",
+ "replace_queue_question": "Chcete nahradit aktuální frontu nebo k ní přidat?",
+ "endless_playback": "Nekonečné přehrávání",
+ "delete_playlist": "Smazat playlist",
+ "delete_playlist_confirmation": "Jste si jisti, že chcete smazat tento playlist?",
+ "local_tracks": "Místní skladby",
+ "song_link": "Odkaz na skladbu",
+ "skip_this_nonsense": "Přeskočit tenhle nesmysl",
+ "freedom_of_music": "“Svobodná hudba”",
+ "freedom_of_music_palm": "“Svobodná hudba ve vaší dlani”",
+ "get_started": "Začít",
+ "youtube_source_description": "Doporučeno a funguje nejlépe.",
+ "piped_source_description": "Nechcete být sledováni? Stejné jako YouTube, ale respektuje soukromí.",
+ "jiosaavn_source_description": "Nejlepší pro jihoasijský region.",
+ "highest_quality": "Nejvyšší kvalita: {quality}",
+ "select_audio_source": "Vyberte zdroj zvuku",
+ "endless_playback_description": "Automaticky přidávat nové skladby\nna konec fronty",
+ "choose_your_region": "Vyberte svůj region",
+ "choose_your_region_description": "To pomůže Spotube ukázat vám správný obsah\npro vaši lokalitu.",
+ "choose_your_language": "Vyberte svůj jazyk",
+ "help_project_grow": "Pomozte tomuto projektu růst",
+ "help_project_grow_description": "Spotube je open-source projekt. Můžete pomoci tomuto projektu růst tím, že přispějete do projektu, nahlásíte chyby nebo navrhnete nové funkce.",
+ "contribute_on_github": "Přispějte na GitHub",
+ "donate_on_open_collective": "Darujte na Open Collective",
+ "browse_anonymously": "Procházet anonymně",
+ "enable_connect": "Povolit ovládání",
+ "enable_connect_description": "Ovládejte Spotube z jiného zařízení",
+ "devices": "Zařízení",
+ "select": "Vybrat",
+ "connect_client_alert": "Zařízení je ovládáno z {client}",
+ "this_device": "Toto zařízení",
+ "remote": "Ovladač",
+ "local_library": "Místní knihovna",
+ "add_library_location": "Přidat do knihovny",
+ "remove_library_location": "Odebrat z knihovny",
+ "local_tab": "Místní",
+ "stats": "Statistiky"
+}
\ No newline at end of file
diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb
index b058d41a..c455e08a 100644
--- a/lib/l10n/app_de.arb
+++ b/lib/l10n/app_de.arb
@@ -313,5 +313,17 @@
"help_project_grow_description": "Spotube ist ein Open-Source-Projekt. Sie können diesem Projekt helfen, indem Sie zum Projekt beitragen, Fehler melden oder neue Funktionen vorschlagen.",
"contribute_on_github": "Auf GitHub beitragen",
"donate_on_open_collective": "Auf Open Collective spenden",
- "browse_anonymously": "Anonym durchsuchen"
+ "browse_anonymously": "Anonym durchsuchen",
+ "enable_connect": "Verbindung aktivieren",
+ "enable_connect_description": "Spotube von anderen Geräten steuern",
+ "devices": "Geräte",
+ "select": "Auswählen",
+ "connect_client_alert": "Du wirst von {client} gesteuert",
+ "this_device": "Dieses Gerät",
+ "remote": "Fernbedienung",
+ "local_library": "Lokale Bibliothek",
+ "add_library_location": "Zur Bibliothek hinzufügen",
+ "remove_library_location": "Aus der Bibliothek entfernen",
+ "local_tab": "Lokal",
+ "stats": "Statistiken"
}
\ No newline at end of file
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index 8257eac9..04fc8566 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -107,6 +107,9 @@
"always_on_top": "Always on top",
"exit_mini_player": "Exit Mini player",
"download_location": "Download location",
+ "local_library": "Local library",
+ "add_library_location": "Add to library",
+ "remove_library_location": "Remove from library",
"account": "Account",
"login_with_spotify": "Login with your Spotify account",
"connect_with_spotify": "Connect with Spotify",
@@ -295,6 +298,7 @@
"delete_playlist": "Delete Playlist",
"delete_playlist_confirmation": "Are you sure you want to delete this playlist?",
"local_tracks": "Local Tracks",
+ "local_tab": "Local",
"song_link": "Song Link",
"skip_this_nonsense": "Skip this nonsense",
"freedom_of_music": "“Freedom of Music”",
@@ -313,5 +317,13 @@
"help_project_grow_description": "Spotube is an open-source project. You can help this project grow by contributing to the project, reporting bugs, or suggesting new features.",
"contribute_on_github": "Contribute on GitHub",
"donate_on_open_collective": "Donate on Open Collective",
- "browse_anonymously": "Browse Anonymously"
+ "browse_anonymously": "Browse Anonymously",
+ "enable_connect": "Enable Connect",
+ "enable_connect_description": "Control Spotube from other devices",
+ "devices": "Devices",
+ "select": "Select",
+ "connect_client_alert": "You're being controlled by {client}",
+ "this_device": "This Device",
+ "remote": "Remote",
+ "stats": "Stats"
}
\ No newline at end of file
diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb
index 0b4cbb2a..6558c743 100644
--- a/lib/l10n/app_es.arb
+++ b/lib/l10n/app_es.arb
@@ -313,5 +313,17 @@
"help_project_grow_description": "Spotube es un proyecto de código abierto. Puedes ayudar a que este proyecto crezca contribuyendo al proyecto, informando errores o sugiriendo nuevas funciones.",
"contribute_on_github": "Contribuir en GitHub",
"donate_on_open_collective": "Donar en Open Collective",
- "browse_anonymously": "Navegar Anónimamente"
+ "browse_anonymously": "Navegar Anónimamente",
+ "enable_connect": "Habilitar conexión",
+ "enable_connect_description": "Controla Spotube desde otros dispositivos",
+ "devices": "Dispositivos",
+ "select": "Seleccionar",
+ "connect_client_alert": "Estás siendo controlado por {client}",
+ "this_device": "Este dispositivo",
+ "remote": "Remoto",
+ "local_library": "Biblioteca local",
+ "add_library_location": "Añadir a la biblioteca",
+ "remove_library_location": "Eliminar de la biblioteca",
+ "local_tab": "Local",
+ "stats": "Estadísticas"
}
\ No newline at end of file
diff --git a/lib/l10n/app_eu.arb b/lib/l10n/app_eu.arb
new file mode 100644
index 00000000..fb00a925
--- /dev/null
+++ b/lib/l10n/app_eu.arb
@@ -0,0 +1,329 @@
+{
+ "guest": "Gonbidatua",
+ "browse": "Arakatu",
+ "search": "Bilatu",
+ "library": "Liburutegia",
+ "lyrics": "Hitzak",
+ "settings": "Ezarpenak",
+ "genre_categories_filter": "Kategoria edo generoak filtratu...",
+ "genre": "Generoa",
+ "personalized": "Pertsonalizatua",
+ "featured": "Nabarmenduak",
+ "new_releases": "Argitaratze berriak",
+ "songs": "Abestiak",
+ "playing_track": "{track} erreproduzitzen",
+ "queue_clear_alert": "Uneko zerrenda ezabatuko da. {track_length} abesti ezabatuko dira.\nJarraitu nahi duzu?",
+ "load_more": "Gehiago kargatu",
+ "playlists": "Zerrendak",
+ "artists": "Artistak",
+ "albums": "Albumak",
+ "tracks": "Kantak",
+ "downloads": "Deskargak",
+ "filter_playlists": "Zure zerrendak filtratu...",
+ "liked_tracks": "Gustuko Kantak",
+ "liked_tracks_description": "Zure gustuko kanta guztiak",
+ "create_playlist": "Sortu zerrenda",
+ "create_a_playlist": "Sortu zerrenda bat",
+ "update_playlist": "Eguneratu zerrenda",
+ "create": "Sortu",
+ "cancel": "Ezeztatu",
+ "update": "Eguneratu",
+ "playlist_name": "Zerrenda Izena",
+ "name_of_playlist": "Zerrendaren izena",
+ "description": "Deskribapena",
+ "public": "Publikoa",
+ "collaborative": "Kolaboratiboa",
+ "search_local_tracks": "Bilatu kanta lokalak...",
+ "play": "Erreproduzitu",
+ "delete": "Ezabatu",
+ "none": "Batere ez",
+ "sort_a_z": "Ordenatu A-Z",
+ "sort_z_a": "Ordenatu Z-A",
+ "sort_artist": "Ordenatu Artistaren arabera",
+ "sort_album": "Ordenatu Albumaren arabera",
+ "sort_duration": "Ordenar Iraupenaren arabera",
+ "sort_tracks": "Ordenatu Kantak",
+ "currently_downloading": "Oraintxe ({tracks_length}) deskargatzen",
+ "cancel_all": "Ezeztatu dena",
+ "filter_artist": "Filtratu artistak...",
+ "followers": "{followers} Jarraitzaile",
+ "add_artist_to_blacklist": "Gehitu artista zerrenda beltzera",
+ "top_tracks": "Top Kantak",
+ "fans_also_like": "Fan-ek hau ere gustuko dute",
+ "loading": "Kargatzen...",
+ "artist": "Artista",
+ "blacklisted": "Zerrenda beltzean",
+ "following": "Jarraitzen",
+ "follow": "Jarraitu",
+ "artist_url_copied": "Artistaren URL-a arbelera kopiatua",
+ "added_to_queue": "{tracks} kanta zerrendara gehituak",
+ "filter_albums": "Albumak filtratu...",
+ "synced": "Sinkronizatuta",
+ "plain": "Arrunta",
+ "shuffle": "Ausaz",
+ "search_tracks": "Bilatu kantak...",
+ "released": "Argitaratua",
+ "error": "Errorea: {error}",
+ "title": "Izenburua",
+ "time": "Iraupena",
+ "more_actions": "Ekintza gehiago",
+ "download_count": "({count}) deskarga",
+ "add_count_to_playlist": "Gehitu ({count}) zerrendara",
+ "add_count_to_queue": "Gehitu ({count}) ilarara",
+ "play_count_next": "Erreproduzitu hurrengo ({count})-ak",
+ "album": "Albuma",
+ "copied_to_clipboard": "{data} arbelean kopiatua",
+ "add_to_following_playlists": "Gehitu {track} hurrengo erreprodukzio-zerrendetara",
+ "add": "Gehitu",
+ "added_track_to_queue": "{track} zerrendan gehitua",
+ "add_to_queue": "Gehitu zerrendan",
+ "track_will_play_next": "{track} erreproduzituko da ondoren",
+ "play_next": "Hurrengo erreprodukzioa",
+ "removed_track_from_queue": "{track} zerrendatik ezabatua",
+ "remove_from_queue": "Ezabatu ilaratik",
+ "remove_from_favorites": "Ezabatu gogokoetatik",
+ "save_as_favorite": "Gorde gogokoetan",
+ "add_to_playlist": "Gehitu zerrendara",
+ "remove_from_playlist": "Ezabatu zerrendatik",
+ "add_to_blacklist": "Gehitu zerrenda beltzera",
+ "remove_from_blacklist": "Ezabatu zerrenda beltzetik",
+ "share": "Elkarbanatu",
+ "mini_player": "Mini Erreproduzitzailea",
+ "slide_to_seek": "Arrastatu aurrerantz edo atzearantz bilatzeko",
+ "shuffle_playlist": "Erreproduzitu zerrenda ausazko ordenean",
+ "unshuffle_playlist": "Desgaitu ausazko erreprodukzioa",
+ "previous_track": "Aurreko pista",
+ "next_track": "Hurrengo pista",
+ "pause_playback": "Pausatu erreprodukzioa",
+ "resume_playback": "Berrabiarazi erreprodukzioa",
+ "loop_track": "Kanta begiztan",
+ "repeat_playlist": "Errepikatu lista",
+ "queue": "Ilara",
+ "alternative_track_sources": "Kanten iturri alternatiboak",
+ "download_track": "Deskargatu kanta",
+ "tracks_in_queue": "{tracks} kanta zerrendan",
+ "clear_all": "Garbitu dena",
+ "show_hide_ui_on_hover": "Erakutsi/Ezkutatu interfazea kurtsorea pasatzean",
+ "always_on_top": "Beti ikusgai",
+ "exit_mini_player": "Irten mini erreproduzitzailetik",
+ "download_location": "Deskargen kokapena",
+ "account": "Kontua",
+ "login_with_spotify": "Hasi saioa zure Spotify kontuarekin",
+ "connect_with_spotify": "Spotify-rekin konektatu",
+ "logout": "Itxi saioa",
+ "logout_of_this_account": "Itxi kontu honen saioa",
+ "language_region": "Hizkuntza eta Herrialdea",
+ "language": "Hizkuntza",
+ "system_default": "Sisteman lehenetsia",
+ "market_place_region": "Dendaren herrialdea",
+ "recommendation_country": "Gomendio herrialdea",
+ "appearance": "Itxura",
+ "layout_mode": "Diseinu modua",
+ "override_layout_settings": "Responsive diseinu moduaren ezarpenak ezeztatu",
+ "adaptive": "Moldagarria",
+ "compact": "Trinkoa",
+ "extended": "Hedatua",
+ "theme": "Gaia",
+ "dark": "Iluna",
+ "light": "Argia",
+ "system": "Sistema",
+ "accent_color": "Azentu kolorea",
+ "sync_album_color": "Sinkronizatu albumaren kolorea",
+ "sync_album_color_description": "Albumaren artearen kolore nagusia erabili azentu kolore bezala",
+ "playback": "Erreprodukzioa",
+ "audio_quality": "Audioaren kalitatea",
+ "high": "Altua",
+ "low": "Baxua",
+ "pre_download_play": "Aurre-deskargatu eta erreproduzitu",
+ "pre_download_play_description": "Streaming egin beharrean, byte-ak deskargatu eta erreproduzitu (banda-zabalera handia duten erabiltzaileentzat gomendagarria)",
+ "skip_non_music": "Musika ez diren segmentuak baztertu (SponsorBlock)",
+ "blacklist_description": "Zerrenda beltzeko abesti eta artistak",
+ "wait_for_download_to_finish": "Mesedez, itxaron uneko deskarga bukatu arte",
+ "desktop": "Mahaigaina",
+ "close_behavior": "Ixterako Portaera",
+ "close": "Itxi",
+ "minimize_to_tray": "Sistemako erretilura minimizatu",
+ "show_tray_icon": "Erakutsi ikonoa sistemaren erretiluan",
+ "about": "Honi buruz",
+ "u_love_spotube": "Badakigu Spotube maite duzula",
+ "check_for_updates": "Bilatu eguneraketak",
+ "about_spotube": "Spotube-ri buruz",
+ "blacklist": "Zerrenda beltza",
+ "please_sponsor": "Mesedez, babestu/diruz lagundu",
+ "spotube_description": "Spotube, arina, plataforma-anitza eta doakoa den Spotify-ren bezeroa",
+ "version": "Bertsioa",
+ "build_number": "Konpilazio zenbakia",
+ "founder": "Sortzailea",
+ "repository": "Errepositorioa",
+ "bug_issues": "Erroreak eta arazoak",
+ "made_with": "Bangladesh🇧🇩-en ❤️-z egina",
+ "kingkor_roy_tirtho": "Kingkor Roy Tirtho",
+ "copyright": "© 2021-{current_year} Kingkor Roy Tirtho",
+ "license": "Lizentzia",
+ "add_spotify_credentials": "Gehitu zure Spotify kredentzialak hasi ahal izateko",
+ "credentials_will_not_be_shared_disclaimer": "Ez arduratu, zure kredentzialak ez ditugu bilduko edo inorekin elkarbanatuko",
+ "know_how_to_login": "Ez dakizu nola egin?",
+ "follow_step_by_step_guide": "Jarraitu pausoz-pausoko gida",
+ "spotify_cookie": "Spotify-ren {name} cookiea",
+ "cookie_name_cookie": "{name} cookiea",
+ "fill_in_all_fields": "Mesedez, osatu eremu guztiak",
+ "submit": "Bidali",
+ "exit": "Irten",
+ "previous": "Aurrekoa",
+ "next": "Hurrengoa",
+ "done": "Eginda",
+ "step_1": "1. pausua",
+ "first_go_to": "Hasteko, joan hona",
+ "login_if_not_logged_in": "eta hasi saioa/sortu kontua lehendik ez baduzu eginda",
+ "step_2": "2. pausua",
+ "step_2_steps": "1. Saioa hasita duzularik, sakatu F12 edo saguaren eskuineko botoia klikatu > Ikuskatu nabigatzaileko garapen tresnak irekitzeko.\n2. Joan \"Aplikazio\" (Chrome, Edge, Brave, etab.) edo \"Biltegiratzea\" (Firefox, Palemoon, etab.)\n3. Joan \"Cookieak\" atalera eta gero \"https://accounts.spotify.com\" azpiatalera",
+ "step_3": "3. pausua",
+ "step_3_steps": "Kopiatu \"sp_dc\" cookiearen balioa",
+ "success_emoji": "Eginda! 🥳",
+ "success_message": "Ongi hasi duzu zure Spotify kontua. Lan bikaina, lagun!",
+ "step_4": "4. pausua",
+ "step_4_steps": "Itsatsi \"sp_dc\"-tik kopiatutako balioa",
+ "something_went_wrong": "Zerbaitek huts egin du",
+ "piped_instance": "Piped zerbitzariaren instantzia",
+ "piped_description": "Kanten koizidentzietan erabiltzeko Piped zerbitzariaren instantzia",
+ "piped_warning": "Batzuk agian ez dute ongi funtzionatuko, zure ardurapean erabili",
+ "generate_playlist": "Sortu Zerrenda",
+ "track_exists": "{track} kanta dagoeneko badago",
+ "replace_downloaded_tracks": "Ordezkatu deskargatutako kanta guztiak",
+ "skip_download_tracks": "Deskargatutako kanta guztien deskarga baztertu",
+ "do_you_want_to_replace": "Dagoen kanta ordezkatu nahi duzu??",
+ "replace": "Ordezkatu",
+ "skip": "Baztertu",
+ "select_up_to_count_type": "Aukertu {count} {type}",
+ "select_genres": "Aukeratu Generoak",
+ "add_genres": "Gehitu Generoak",
+ "country": "Herrialdea",
+ "number_of_tracks_generate": "Sortzeko kanta kopurua",
+ "acousticness": "Akustikotasuna",
+ "danceability": "Dantzagarritasuna",
+ "energy": "Energia",
+ "instrumentalness": "Instrumentaltasuna",
+ "liveness": "Zuzenean",
+ "loudness": "Ozentasuna",
+ "speechiness": "Hitzaldia",
+ "valence": "Balentzia",
+ "popularity": "Populartasuna",
+ "key": "Tonua",
+ "duration": "Iraupena (s)",
+ "tempo": "Tenpoa (BPM)",
+ "mode": "Modua",
+ "time_signature": "Konpasa",
+ "short": "Motza",
+ "medium": "Ertaina",
+ "long": "Luzea",
+ "min": "Min.",
+ "max": "Max.",
+ "target": "Helburua",
+ "moderate": "Moderatua",
+ "deselect_all": "Desaukeratu dena",
+ "select_all": "Aukeratu dena",
+ "are_you_sure": "Ziur zaude?",
+ "generating_playlist": "Zure pertsonalizatutako zerrenda sortzen...",
+ "selected_count_tracks": "{count} kanta aukeratuta",
+ "download_warning": "Abesti guztiak aldi berean deskargatuz gero, argi dago musika pirateatzen ari zarela eta musikaren gizarte sortzaileari kalte egiten diozula. Honen jakitun izan eta artisten lan gogorra errespetatu eta babestea espero dut",
+ "download_ip_ban_warning": "Bidenabar, baliteke zure IPa YouTuben blokeatzea deskarga eskera gehiegi egiten badituzu. IPa blokeatzeak esan nahi du ezin izango duzula YouTube erabili (nahiz eta saioa hasia izan) gutxienez 2-3 hilabetez IP helbide horretatik. Eta Spotube ez da erantzule izango hori gertatzen bazaizu",
+ "by_clicking_accept_terms": "'Onartu' klikatzean, ondorengo baldintzak onartzen dituzu:",
+ "download_agreement_1": "Badakit musika pirateatzen ari naizela. Gaiztoa naiz",
+ "download_agreement_2": "Ahal dudanean lagunduko diot artistari baina oraingoz ez dut bere artea erosteko dirurik",
+ "download_agreement_3": "Erabat jakitun naiz YouTubek nire IPa blokea dezakeela eta ez diot Spotube-ri edo bere jabe/laguntzaileei erantzukizunik eskatuko nire oraingo jokaerak ekar ditzakeen arazoengatik",
+ "decline": "Baztertu",
+ "accept": "Onartu",
+ "details": "Xehetasunak",
+ "youtube": "YouTube",
+ "channel": "Kanala",
+ "likes": "Gustukoak",
+ "dislikes": "Ez gustukoak",
+ "views": "Ikuspenak",
+ "streamUrl": "Streaming-aren URLa",
+ "stop": "Gelditu",
+ "sort_newest": "Ordenatu gehitu berrienetik",
+ "sort_oldest": "Ordenatu gehitu zaharrenetik",
+ "sleep_timer": "Itzaltzeko tenporizadorea",
+ "mins": "{minutes} minutu",
+ "hours": "{hours} ordu",
+ "hour": "{hours} ordu",
+ "custom_hours": "Ordu pertsonalizatuak",
+ "logs": "Log-ak",
+ "developers": "Garatzaileak",
+ "not_logged_in": "Ez duzu saioa hasi",
+ "search_mode": "Bilaketa modua",
+ "audio_source": "Audio Iturria",
+ "ok": "OK",
+ "failed_to_encrypt": "Errorea zifratzean",
+ "encryption_failed_warning": "Spotube-ek zifratzea darabil datuak modu seguruan biltegiratzeko. Baina huts egin du. Hori dela eta, biltegiratzea ez da segurua izango\nLinux erabiltzen ari bazara, ziurtatu edozein sekretu-zerbitzu (gnome-keyring, kde-wallet, keepassxc etab.) instalatuta duzula",
+ "querying_info": "Informazioa egiaztatzen...",
+ "piped_api_down": "Piped-en APIa ez dago eskuragarri",
+ "piped_down_error_instructions": "Piped-en {pipedInstance} instantzia ez dago martxan une honetan\n\nAldatu instantzia edo aldatu 'API mota' YouTuberen API ofizialera\n\nZiurtatu aplikazioa berrabiarazten duzula aldaketa eta gero",
+ "you_are_offline": "Une honetan konexiorik gabe zaude",
+ "connection_restored": "Internet konexioa berrezarri egin da",
+ "use_system_title_bar": "Erabili sistemako izenburu barra",
+ "crunching_results": "Emaitzak prozesatzen...",
+ "search_to_get_results": "Bilatu emaitzak lortzeko",
+ "use_amoled_mode": "Erabili AMOLED modua",
+ "pitch_dark_theme": "Dart-en gai iluna",
+ "normalize_audio": "Normalizatu audioa",
+ "change_cover": "Aldatu azala",
+ "add_cover": "Gehitu azala",
+ "restore_defaults": "Berrezarri berezko balioak",
+ "download_music_codec": "Deskargatutako musikaren codec-a",
+ "streaming_music_codec": "Streaming musikaren codec-a",
+ "login_with_lastfm": "Hasi saioa Last.fm-n",
+ "connect": "Konektatu",
+ "disconnect_lastfm": "Deskonektatu Last.fm-tik",
+ "disconnect": "Deskonektatu",
+ "username": "Erabiltzaile izena",
+ "password": "Pasahitza",
+ "login": "Hasi saioa",
+ "login_with_your_lastfm": "Hasi saioa Last.fm-ko zure kontuarekin",
+ "scrobble_to_lastfm": "Scrobble Last.fm-ra",
+ "go_to_album": "Albumera joan",
+ "discord_rich_presence": "Discord-en presentzia aberatsa",
+ "browse_all": "Esploratu dena",
+ "genres": "Generoak",
+ "explore_genres": "Esploratu generoak",
+ "friends": "Lagunak",
+ "no_lyrics_available": "Sentitzen dut, ezin dira kanta honen hitzak aurkitu",
+ "start_a_radio": "Hasi Irrati bat",
+ "how_to_start_radio": "Nola hasi nahi duzu irratia?",
+ "replace_queue_question": "Uneko zerrenda ordezkatu nahi duzu edo bertan gehitu?",
+ "endless_playback": "Amaigabeko erreprodukzioa",
+ "delete_playlist": "Ezabatu zerrenda",
+ "delete_playlist_confirmation": "Ziur zaude zerrenda ezabatu nahi duzula?",
+ "local_tracks": "Kanta lokalak",
+ "song_link": "Kantaren lotura",
+ "skip_this_nonsense": "Utzi txorakeria hau",
+ "freedom_of_music": "“Musika Askatasuna”",
+ "freedom_of_music_palm": "“Musika Askatasuna zure eskuetan”",
+ "get_started": "Has gaitezen",
+ "youtube_source_description": "Gomendatua eta hobekien dabilena.",
+ "piped_source_description": "Aske zara? YouTube bezala, baino askeago.",
+ "jiosaavn_source_description": "Asia hegoaldeko herrialdeetarako hoberena.",
+ "highest_quality": "Kalitate Onena: {quality}",
+ "select_audio_source": "Aukeratu Audio Iturria",
+ "endless_playback_description": "Gehitu automatikoki kanta berriak\n ilararen bukaeran",
+ "choose_your_region": "Aukeratu zure herrialdea",
+ "choose_your_region_description": "Honekin Spotube-k zure kokalerakuari dagokion edukia\neskeiniko dizu.",
+ "choose_your_language": "Aukeratu zure hizkuntza",
+ "help_project_grow": "Lagundu proiektu honi hazten",
+ "help_project_grow_description": "Spotube kode irekiko proiektu bat da. Proiektu hau hazten lagundu dezakezu, erroreak jakinaraziz edo ezaugarri berriak proposatuz.",
+ "contribute_on_github": "GitHub-en lagundu",
+ "donate_on_open_collective": "Open Collective-en diruz lagundu",
+ "browse_anonymously": "Nabigatu Anonimoki",
+ "enable_connect": "Gaitu konexioa",
+ "enable_connect_description": "Kontrolatu Spotube beste gailu batzuetatik",
+ "devices": "Gailuak",
+ "select": "Aukeratu",
+ "connect_client_alert": "{client} gailuak kontrolatzen zaitu",
+ "this_device": "Gailu hau",
+ "remote": "Urrunekoa",
+ "local_library": "Liburutegi lokala",
+ "add_library_location": "Gehitu liburutegira",
+ "remove_library_location": "Kendu liburutegitik",
+ "local_tab": "Tokiko",
+ "stats": "Estatistikak"
+}
\ No newline at end of file
diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb
index 629238cc..b939de59 100644
--- a/lib/l10n/app_fa.arb
+++ b/lib/l10n/app_fa.arb
@@ -313,5 +313,17 @@
"help_project_grow_description": "Spotube یک پروژه متن باز است. شما میتوانید با به پروژه کمک کردن، گزارش دادن اشکالات یا پیشنهاد ویژگیهای جدید، به این پروژه کمک کنید.",
"contribute_on_github": "مشارکت در GitHub",
"donate_on_open_collective": "کمک مالی در Open Collective",
- "browse_anonymously": "مرور به صورت ناشناس"
+ "browse_anonymously": "مرور به صورت ناشناس",
+ "enable_connect": "فعالسازی اتصال",
+ "enable_connect_description": "کنترل Spotube از دیگر دستگاهها",
+ "devices": "دستگاهها",
+ "select": "انتخاب",
+ "connect_client_alert": "شما توسط {client} کنترل میشوید",
+ "this_device": "این دستگاه",
+ "remote": "راهدور",
+ "local_library": "کتابخانه محلی",
+ "add_library_location": "اضافه کردن به کتابخانه",
+ "remove_library_location": "حذف از کتابخانه",
+ "local_tab": "محلی",
+ "stats": "آمار"
}
\ No newline at end of file
diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb
new file mode 100644
index 00000000..d0767e95
--- /dev/null
+++ b/lib/l10n/app_fi.arb
@@ -0,0 +1,329 @@
+{
+ "guest": "Vieras",
+ "browse": "Selaa",
+ "search": "Hae",
+ "library": "Kirjasto",
+ "lyrics": "Lyriikat",
+ "settings": "Asetukset",
+ "genre_categories_filter": "Suodata kategorioita tai genrejä",
+ "genre": "Genre",
+ "personalized": "Personoidut",
+ "featured": "Esittelyssä",
+ "new_releases": "Uusi julkaisu",
+ "songs": "Laulut",
+ "playing_track": "Soitetaan {track}",
+ "queue_clear_alert": "Tämä tulee tyhjentämään jonon. {track_length} Kappaleita poistetaan\nHaluatko jatkaa?",
+ "load_more": "Lataa lisää",
+ "playlists": "Soittolistat",
+ "artists": "Artistit",
+ "albums": "Albumit",
+ "tracks": "Kappaleet",
+ "downloads": "Lataukset",
+ "filter_playlists": "Suodata soittolistasi...",
+ "liked_tracks": "Tykätyt kappaleet",
+ "liked_tracks_description": "Kaikki tykättysi kappaleet",
+ "create_playlist": "Luo soittolista",
+ "create_a_playlist": "Luo soittolista",
+ "update_playlist": "Päivitä soittolista",
+ "create": "Luo",
+ "cancel": "Peruuta",
+ "update": "Päivitä",
+ "playlist_name": "Soittolistan nimi",
+ "name_of_playlist": "Soittolistan nimi",
+ "description": "Kuvaus",
+ "public": "Julkinen",
+ "collaborative": "Collaborative",
+ "search_local_tracks": "Hae paikallisia lauluja...",
+ "play": "Soita",
+ "delete": "Poista",
+ "none": "Ei mitään",
+ "sort_a_z": "Suodata A-Z",
+ "sort_z_a": "Suodata Z-A",
+ "sort_artist": "Suodata Artistilta",
+ "sort_album": "Suodata Albumilta",
+ "sort_duration": "Suodata Pituudelta",
+ "sort_tracks": "Suodata Kappaleet",
+ "currently_downloading": "Ladataan ({tracks_length})",
+ "cancel_all": "Peru kaikki",
+ "filter_artist": "Suodata artistit...",
+ "followers": "{followers} Seuraajaa",
+ "add_artist_to_blacklist": "Lisää artisti mustalle listalle",
+ "top_tracks": "Suosituimmat kappaleet",
+ "fans_also_like": "Fanit myös tykkäsivät",
+ "loading": "Ladataan...",
+ "artist": "Artisti",
+ "blacklisted": "Mustalistattu",
+ "following": "Seurataan",
+ "follow": "Seuraa",
+ "artist_url_copied": "Aristin URL kopioitiin leikepöytään",
+ "added_to_queue": "Lisättiin {tracks} kappaletta jonoon",
+ "filter_albums": "Suodata albumit...",
+ "synced": "Synkronoitu",
+ "plain": "Tavallinen",
+ "shuffle": "Sekoita",
+ "search_tracks": "Hae kappaleita...",
+ "released": "Julkaistu",
+ "error": "Virhe {error}",
+ "title": "Otsikko",
+ "time": "Aika",
+ "more_actions": "Lisää toimintoja",
+ "download_count": "Lataa ({count})",
+ "add_count_to_playlist": "Lisää ({count}) Soittolistaasi",
+ "add_count_to_queue": "Lisää ({count}) Jonoon",
+ "play_count_next": "Soita ({count}) seuraavaksi",
+ "album": "Albumi",
+ "copied_to_clipboard": "Kopioitiin {data} leikepöytään",
+ "add_to_following_playlists": "Lisää {track} seuraaviin soittolistoihin",
+ "add": "Lisää",
+ "added_track_to_queue": "Lisättiin {track} jonoon",
+ "add_to_queue": "Lisää jonoon",
+ "track_will_play_next": "{track} Soitetaan seuraavaksi",
+ "play_next": "Soita seuraavaksi",
+ "removed_track_from_queue": "Poistettiin {track} jonosta",
+ "remove_from_queue": "Poista jonosta",
+ "remove_from_favorites": "Poista suosikeista",
+ "save_as_favorite": "Tallenna soittolistana",
+ "add_to_playlist": "Lisää soittolistaan",
+ "remove_from_playlist": "Poista soittolistasta",
+ "add_to_blacklist": "Lisää mustalle listalle",
+ "remove_from_blacklist": "Poista mustalistalta",
+ "share": "Jaa",
+ "mini_player": "Minisoitin",
+ "slide_to_seek": "Liu'uta mennäkseen eteenpäin tai taaksepäin",
+ "shuffle_playlist": "Sekoita soittolista",
+ "unshuffle_playlist": "Poista sekoitus soittolistasta",
+ "previous_track": "Äskeinen kappale",
+ "next_track": "Seuraava kappale",
+ "pause_playback": "Pysäytä soittolistan toisto",
+ "resume_playback": "Jatka soittolistan toistoa",
+ "loop_track": "Uudelleentoista kappale",
+ "repeat_playlist": "Toista soittolista uudelleen",
+ "queue": "Jono",
+ "alternative_track_sources": "Toinen kappale lähde",
+ "download_track": "Lataa kappale",
+ "tracks_in_queue": "{tracks} kappaletta jonossa",
+ "clear_all": "Tyhjennä kaikki",
+ "show_hide_ui_on_hover": "Näytä/Piilota UI leijumalla",
+ "always_on_top": "Aina päällimmäisenä",
+ "exit_mini_player": "Lähde minisoittimesta",
+ "download_location": "Lataus sijainti",
+ "account": "Käyttäjä",
+ "login_with_spotify": "Kirjaudu Spotify-käyttäjällä",
+ "connect_with_spotify": "Yhdistä Spotify:lla",
+ "logout": "Kirjaudu ulos",
+ "logout_of_this_account": "Kirjaudu ulos tältä käyttäjältä",
+ "language_region": "Kieli ja Maa",
+ "language": "Kieli",
+ "system_default": "Järjestelmän oletus",
+ "market_place_region": "Markkina-alue",
+ "recommendation_country": "Suositeltu maa",
+ "appearance": "Ulkomuto",
+ "layout_mode": "Asettelutila",
+ "override_layout_settings": "Jätä reagoiva asettelutila huomioimatta",
+ "adaptive": "Mukautuva",
+ "compact": "Kompakti",
+ "extended": "Laajennettu",
+ "theme": "Teema",
+ "dark": "Tumma",
+ "light": "Vaalea",
+ "system": "Järjestelmä",
+ "accent_color": "Korostusväri",
+ "sync_album_color": "Synkronoi albumin väri",
+ "sync_album_color_description": "Käyttää albumin kansitaiteen vallitsevaa väirä korostuvärinä",
+ "playback": "Toisto",
+ "audio_quality": "Äänenlaatu",
+ "high": "Korkea",
+ "low": "Matala",
+ "pre_download_play": "Esilataa ja soita",
+ "pre_download_play_description": "Audion suoratoiston sijaan, lataa tavut ja soita ne (Suositeltu korkeamman kaistanleveyden käyttäjille)",
+ "skip_non_music": "Ohita ei-musiikki kohdat (SponsorBlock)",
+ "blacklist_description": "Mustalistat kappaleet aja artistit",
+ "wait_for_download_to_finish": "Odota nykyisen latauksen lopetteluun",
+ "desktop": "Työpöytä",
+ "close_behavior": "Sulkemisen käyttäytyminen",
+ "close": "Sulje",
+ "minimize_to_tray": "Minimisoi tehtäväpalkkiin",
+ "show_tray_icon": "Näytä järjestelmäkuvake",
+ "about": "Tietoa",
+ "u_love_spotube": "Tiedämme että rakastat Spotubea",
+ "check_for_updates": "Tarkista päivitykset",
+ "about_spotube": "Tietoa Spotube:sta",
+ "blacklist": "Mustalista",
+ "please_sponsor": "Sponsoroi/Lahjoita, kiitos",
+ "spotube_description": "Spotube, kevyt, cross-platform, vapaa-kaikille spotify clientti",
+ "version": "Versio",
+ "build_number": "Rakennusnumero",
+ "founder": "Perustaja",
+ "repository": "Arkisto",
+ "bug_issues": "Bugit+Ongelmat",
+ "made_with": "Tehty ❤️ Bangladeshista 🇧🇩",
+ "kingkor_roy_tirtho": "Kingkor Roy Tirtho",
+ "copyright": "© 2021-{current_year} Kingkor Roy Tirtho",
+ "license": "Lisenssi",
+ "add_spotify_credentials": "Lisää Spotify-tunnuksesi aloittaaksesi",
+ "credentials_will_not_be_shared_disclaimer": "Älä huoli, tunnuksiasi ei talleteta tai jaeta kenenkään kanssa",
+ "know_how_to_login": "Etkö tiedä miten tehdä tämä?",
+ "follow_step_by_step_guide": "Seuraa askel askeleelta opasta",
+ "spotify_cookie": "Spotify {name} Keksi",
+ "cookie_name_cookie": "{name} Keksi",
+ "fill_in_all_fields": "Täytä kaikki kentät",
+ "submit": "Lähetä",
+ "exit": "Poistu",
+ "previous": "Edellinen",
+ "next": "Seuraava",
+ "done": "Tehty",
+ "step_1": "Vaihe 1",
+ "first_go_to": "Ensiksi, mene",
+ "login_if_not_logged_in": "ja Kirjaudu/Tee tili jos et ole kirjautunut sisään",
+ "step_2": "Vaihe 2",
+ "step_2_steps": "1. Kun olet kirjautunut, paina F12 tai oikeaa hiiren näppäintä > Tarkista ja avaa selaimen kehittäjä työkalut.\n2. Mene sitten \"Application\"-välilehteen (Chrome, Edge, Brave jne..) tai \"Storage\"-välilehteen (Firefox, Palemoon jne..)\n3. Mene \"Cookies\"-osastoon, sitten \"https://accounts.spotify.com\" alakohtaan.",
+ "step_3": "Vaihe 3",
+ "step_3_steps": "Kopioi Keksin \"sp_dc\" arvo",
+ "success_emoji": "Onnistuit🥳",
+ "success_message": "Olet nyt kirjautunut sisään Spotify-käyttäjällesi. Hyvää työtä toveri!",
+ "step_4": "Vaihe 4",
+ "step_4_steps": "Liitä kopioitu \"sp_dc\" arvo",
+ "something_went_wrong": "Jotain meni pieleen",
+ "piped_instance": "Johdettu palvelinesiintymä",
+ "piped_description": "Johdettu palvelinesiintymä Kappale täsmäyksiin",
+ "piped_warning": "Jotkut niistä eivät toimi hyvin, käytä siis omalla vastuullasi",
+ "generate_playlist": "Tuota soittolista",
+ "track_exists": "Kappale {track} on jo olemassa!",
+ "replace_downloaded_tracks": "Korvaa kaikki ladatut kappaleet",
+ "skip_download_tracks": "Ohita ladattujen laulujen lataaminen",
+ "do_you_want_to_replace": "Haluatko korvata olemassa olevan kappaleen??",
+ "replace": "Korvaa",
+ "skip": "Ohita",
+ "select_up_to_count_type": "Valitse enintään {count} {type}",
+ "select_genres": "Valitse Genret",
+ "add_genres": "Lisää Genrejä",
+ "country": "Maa",
+ "number_of_tracks_generate": "Numero tuotettavia kappaleita",
+ "acousticness": "Akustisuus",
+ "danceability": "Tanssittavuus",
+ "energy": "Energia",
+ "instrumentalness": "Instrumentaalisuus",
+ "liveness": "Elävyyttä",
+ "loudness": "Äänekkyys",
+ "speechiness": "Puheisuus",
+ "valence": "Valenssi",
+ "popularity": "Suosio",
+ "key": "Sävellaji",
+ "duration": "Pituus (s)",
+ "tempo": "Tempo (BPM)",
+ "mode": "Tila",
+ "time_signature": "Aikamerkki",
+ "short": "Lyhyt",
+ "medium": "Keskikokoinen",
+ "long": "Pitkä",
+ "min": "Minimi",
+ "max": "Maximi",
+ "target": "Kohde",
+ "moderate": "Kohtalainen",
+ "deselect_all": "Poista kaikki valinnat",
+ "select_all": "Valitse kaikki",
+ "are_you_sure": "Oletko varma?",
+ "generating_playlist": "Luodaan mukautettua soittolistoa...",
+ "selected_count_tracks": "Valittu {count} kappaletta",
+ "download_warning": "Jos lataat kaikki laulut kerrällä olet selkeästi Piratoimassa ja aiheuttamassa vahinkoa musiikin luovaan yhteiskuntaan. Toivottavasti olet tietoinen tästä. Yritä aina kunnioittaa ja tukea Artistin kovaa työtä.",
+ "download_ip_ban_warning": "BTW, YouTube voi estää IP-Osoitteesi tavallista liiallisten latauspyyntöjen takia. IP-Osoitteen esto tarkoittaa sitä, ettet voi käyttää YouTubea (vaikka olisit kirjautunut) vähintään 2-3kk aikana kyseiseltä laitteelta. Spotube ei kanna yhtään vastuuta jos se tapahtuu.",
+ "by_clicking_accept_terms": "Painamalla 'hyväksy' hyväksyt seuraaviin ehtoihin:",
+ "download_agreement_1": "Tiedän että Piratoin musiikkia. Olen paha.",
+ "download_agreement_2": "Tuen Artisteja silloin kun pystyn, ja teen tämän vain koska minulla ei ole rahaa ostaa heidän taidetta",
+ "download_agreement_3": "Ymmärrän että minun YouTube voi estää IP-Osoitteeni ja en pidä Spotubea tai omistajiinsa/avustajia vastuullisena mistään omista teoistsani",
+ "decline": "Hylkää",
+ "accept": "Hyväksy",
+ "details": "Yksityiskohdat",
+ "youtube": "YouTube",
+ "channel": "Kanava",
+ "likes": "Tykkäykset",
+ "dislikes": "Epä-tykkäykset",
+ "views": "Näyttökerrat",
+ "streamUrl": "Suoratoiston URL",
+ "stop": "Lopeta",
+ "sort_newest": "Suodata uusimmista",
+ "sort_oldest": "Suodata vanhimmista",
+ "sleep_timer": "Uniajastin",
+ "mins": "{minutes} Minuuttia",
+ "hours": "{hours} Tuntia",
+ "hour": "{hours} Tunti",
+ "custom_hours": "Mukautetut tunnit",
+ "logs": "Lokit",
+ "developers": "Kehittäjät",
+ "not_logged_in": "Et ole kirjautunut sisään.",
+ "search_mode": "Hakutila",
+ "audio_source": "Äänilähde",
+ "ok": "Ok",
+ "failed_to_encrypt": "Salaaminen epäonnistui",
+ "encryption_failed_warning": "Spotube käyttää salausta tallentaakseen tietosi, mutta epäonnistui, joten se palaa epäturvalliseen tallennukseen\nJos käytät Linuxia, varmista että sinulla on turvallisuuspalvelu (gnome-keyring, kde-wallet, keepassxc jne) asennettu",
+ "querying_info": "Hankitaan tietoa...",
+ "piped_api_down": "Johdettu palvelinesiintymä on alhaalla",
+ "piped_down_error_instructions": "Johdettu palvelinesiintymä {pipedInstance} on alhaalla.\n\nVaihda joko ilmeytymä tia vahda 'API tyyppi' YouTuben viralliseen API\n\nKäynnistä sovellus uudestaan vaihdon jälkeen",
+ "you_are_offline": "Et ole yhdistetty verkkoon",
+ "connection_restored": "Verkkoyhteys palautettu",
+ "use_system_title_bar": "Käytä järjestelmäpalkkia",
+ "crunching_results": "Paloitellaan tuloksia...",
+ "search_to_get_results": "Hae saadakseen tuloksia",
+ "use_amoled_mode": "Pilkkopimeä tumma teema",
+ "pitch_dark_theme": "AMOLED Tila",
+ "normalize_audio": "Normalisoi audio",
+ "change_cover": "Vaihda koveri",
+ "add_cover": "Lisää koveri",
+ "restore_defaults": "Palauta oletukset",
+ "download_music_codec": "Ladatun musiikin codefc",
+ "streaming_music_codec": "Suoratoistetun musiikin codec",
+ "login_with_lastfm": "Kirjaudu sisään Last.fm:llä",
+ "connect": "Yhdistä",
+ "disconnect_lastfm": "Katkaise Last.fm",
+ "disconnect": "Katkaise",
+ "username": "Käyttäjänimi",
+ "password": "Salasana",
+ "login": "Kirjaudu",
+ "login_with_your_lastfm": "Kirjaudu Last.fm käyttäjälläsi",
+ "scrobble_to_lastfm": "Scrobble Last.fm:ään",
+ "go_to_album": "Mene albumiin",
+ "discord_rich_presence": "Discord Rich Presence",
+ "browse_all": "Selaa kaikki",
+ "genres": "Genret",
+ "explore_genres": "Seikkaile genrejä",
+ "friends": "Kaverit",
+ "no_lyrics_available": "Anteeksi, emme löytäneet lyriikoita tälle laululle",
+ "start_a_radio": "Aloita Radio",
+ "how_to_start_radio": "Kuinka haluat aloittaa radion?",
+ "replace_queue_question": "Haluatko korvata nykyisen jonon vai lisätä siihen?",
+ "endless_playback": "Loputon toisto",
+ "delete_playlist": "Poista soittolista",
+ "delete_playlist_confirmation": "Oletko varma että haluat poistaa tämän soittolistan?",
+ "local_tracks": "Paikalliset kappaleet",
+ "song_link": "Laulun linkki",
+ "skip_this_nonsense": "Ohita tämä hölynpöly",
+ "freedom_of_music": "“Musiikin vapaus”",
+ "freedom_of_music_palm": "“Musiikin vapaus käsissäsi”",
+ "get_started": "Aloitetaan",
+ "youtube_source_description": "Suositeltu ja toimii parhaiten.",
+ "piped_source_description": "Tuntuuko vapaalta? Sama kuin YouTube mutta paljon vapautta",
+ "jiosaavn_source_description": "Paras Etelä-Aasian alueelle.",
+ "highest_quality": "Korkein laatu: {quality}",
+ "select_audio_source": "Valitse äänilähde",
+ "endless_playback_description": "Lisää automaattisesti uusia lauluja\njonon perään",
+ "choose_your_region": "Valitse alueesi",
+ "choose_your_region_description": "Tämä auttaa Spotube näyttämään sinulle oikeaa sisältöä\nsijaintiasi varten.",
+ "choose_your_language": "Valitse kielesi",
+ "help_project_grow": "Auta tätä projektia kasvamaan",
+ "help_project_grow_description": "Spotube projekti minkä lähdekoodi on julkisesti saatavilla. Voit autta tätä projektia kasvamaan muutoksilla, ilmoittamalla bugeista, tai ehdottamalla uusia ominaisuuksia.",
+ "contribute_on_github": "Auta GitHub:ssa",
+ "donate_on_open_collective": "Lahjoita avoimessa kollektiivissa",
+ "browse_anonymously": "Selaa anonyyminä",
+ "enable_connect": "Ota käyttöön yhdistäminen",
+ "enable_connect_description": "Ohjaa Spotubea toiselta laitteelta",
+ "devices": "Laitteet",
+ "select": "Valitse",
+ "connect_client_alert": "{client} ohjaa sinua",
+ "this_device": "Tämä laite",
+ "remote": "Etä",
+ "local_library": "Paikallinen kirjasto",
+ "add_library_location": "Lisää kirjastoon",
+ "remove_library_location": "Poista kirjastosta",
+ "local_tab": "Paikallinen",
+ "stats": "Tilastot"
+}
\ No newline at end of file
diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb
index 69b2bb69..6bd2d0f8 100644
--- a/lib/l10n/app_fr.arb
+++ b/lib/l10n/app_fr.arb
@@ -313,5 +313,17 @@
"help_project_grow_description": "Spotube est un projet open-source. Vous pouvez aider ce projet à grandir en contribuant au projet, en signalant des bugs ou en suggérant de nouvelles fonctionnalités.",
"contribute_on_github": "Contribuer sur GitHub",
"donate_on_open_collective": "Faire un don sur Open Collective",
- "browse_anonymously": "Naviguer anonymement"
+ "browse_anonymously": "Naviguer anonymement",
+ "enable_connect": "Activer la connexion",
+ "enable_connect_description": "Contrôlez Spotube depuis d'autres appareils",
+ "devices": "Appareils",
+ "select": "Sélectionner",
+ "connect_client_alert": "Vous êtes contrôlé par {client}",
+ "this_device": "Cet appareil",
+ "remote": "À distance",
+ "local_library": "Bibliothèque locale",
+ "add_library_location": "Ajouter à la bibliothèque",
+ "remove_library_location": "Retirer de la bibliothèque",
+ "local_tab": "Local",
+ "stats": "Statistiques"
}
\ No newline at end of file
diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb
index b442da37..7dc809c7 100644
--- a/lib/l10n/app_hi.arb
+++ b/lib/l10n/app_hi.arb
@@ -313,5 +313,17 @@
"help_project_grow_description": "Spotube एक ओपन सोर्स परियोजना है। आप इस परियोजना को योगदान देकर, बग रिपोर्ट करके या नई विशेषताओं का सुझाव देकर इस परियोजना को बढ़ा सकते हैं।",
"contribute_on_github": "GitHub पर योगदान करें",
"donate_on_open_collective": "ओपन कलेक्टिव पर दान करें",
- "browse_anonymously": "बिना नाम के ब्राउज़ करें"
+ "browse_anonymously": "बिना नाम के ब्राउज़ करें",
+ "enable_connect": "कनेक्ट सक्षम करें",
+ "enable_connect_description": "अन्य उपकरणों से Spotube को नियंत्रित करें",
+ "devices": "उपकरण",
+ "select": "चयन करें",
+ "connect_client_alert": "आप {client} द्वारा नियंत्रित हो रहे हैं",
+ "this_device": "यह उपकरण",
+ "remote": "रिमोट",
+ "local_library": "स्थानीय पुस्तकालय",
+ "add_library_location": "पुस्तकालय में जोड़ें",
+ "remove_library_location": "पुस्तकालय से हटाएं",
+ "local_tab": "स्थानीय",
+ "stats": "आंकड़े"
}
\ No newline at end of file
diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb
new file mode 100644
index 00000000..669f5e2a
--- /dev/null
+++ b/lib/l10n/app_id.arb
@@ -0,0 +1,329 @@
+{
+ "guest": "Tamu",
+ "browse": "Jelajahi",
+ "search": "Cari",
+ "library": "Pustaka",
+ "lyrics": "Lirik",
+ "settings": "Pengaturan",
+ "genre_categories_filter": "Urutkan kategori atau genre...",
+ "genre": "Genre",
+ "personalized": "Dipersonalisasi",
+ "featured": "Unggulan",
+ "new_releases": "Rilis Terbaru",
+ "songs": "Lagu",
+ "playing_track": "Memutar {track}",
+ "queue_clear_alert": "Ini akan menghapus antrian saat ini This will clear the current queue. {track_length} trek akan dihapus\nAnda ingin melanjutkan?",
+ "load_more": "Lebih Banyak",
+ "playlists": "Daftar Putar",
+ "artists": "Artis",
+ "albums": "Album",
+ "tracks": "Trek",
+ "downloads": "Unduhan",
+ "filter_playlists": "Urutkan daftar putar Anda...",
+ "liked_tracks": "Lagu Yang Disukai",
+ "liked_tracks_description": "Semua lagu yang Anda sukai",
+ "create_playlist": "Buat Daftar Putar",
+ "create_a_playlist": "Buat daftar putar",
+ "update_playlist": "Ubah daftar putar",
+ "create": "Buat",
+ "cancel": "Batal",
+ "update": "Ubah",
+ "playlist_name": "Nama Daftar Putar",
+ "name_of_playlist": "Nama daftar putar",
+ "description": "Deskripsi",
+ "public": "Publik",
+ "collaborative": "Kolaboratif",
+ "search_local_tracks": "Cari trek lokal...",
+ "play": "Putar",
+ "delete": "Hapus",
+ "none": "Tidak Ada",
+ "sort_a_z": "Urutkan berdasarkan A-Z",
+ "sort_z_a": "Urutkan berdasarkan Z-A",
+ "sort_artist": "Urutkan berdasarkan Artis",
+ "sort_album": "Urutkan berdasarkan Album",
+ "sort_duration": "Urutkan berdasarkan Durasi",
+ "sort_tracks": "Urutkan trek",
+ "currently_downloading": "Sedang Mengunduh ({tracks_length})",
+ "cancel_all": "Batalkan Semua",
+ "filter_artist": "Urutkan artis...",
+ "followers": "{followers} Pengikut",
+ "add_artist_to_blacklist": "Tambah artis ke daftar hitam",
+ "top_tracks": "Lagu Teratas",
+ "fans_also_like": "Penggemar juga menyukainya",
+ "loading": "Memuat...",
+ "artist": "Artis",
+ "blacklisted": "Masuk Daftar Hitam",
+ "following": "Mengikuti",
+ "follow": "Ikuti",
+ "artist_url_copied": "URL artis telah disalin",
+ "added_to_queue": "Menambah trek {tracks} ke antrean",
+ "filter_albums": "Urutkan album...",
+ "synced": "Disinkronkan",
+ "plain": "Normal",
+ "shuffle": "Acak",
+ "search_tracks": "Cari trek...",
+ "released": "Dirilis",
+ "error": "Kesalahan {error}",
+ "title": "Judul",
+ "time": "Waktu",
+ "more_actions": "Tindakan Lainnya",
+ "download_count": "Unduhan ({count})",
+ "add_count_to_playlist": "Menambah ({count}) ke Daftar Putar",
+ "add_count_to_queue": "Menambah ({count}) ke Antrian",
+ "play_count_next": "Mainkan ({count}) selanjutnya",
+ "album": "Album",
+ "copied_to_clipboard": "{data} telah disalin",
+ "add_to_following_playlists": "Menambah {track} ke Daftar Putar berikut",
+ "add": "Tambah",
+ "added_track_to_queue": "Menambah {track} ke antrian",
+ "add_to_queue": "Tambah ke antrian",
+ "track_will_play_next": "{track} akan diputar berikutnya",
+ "play_next": "Mainkan selanjutnya",
+ "removed_track_from_queue": "Menghapus {track} dari antrian",
+ "remove_from_queue": "Hapus dari antrian",
+ "remove_from_favorites": "Hapus dari favorit",
+ "save_as_favorite": "Simpan sebagai favorit",
+ "add_to_playlist": "Tambah ke daftar putar",
+ "remove_from_playlist": "Hapus dari daftar putar",
+ "add_to_blacklist": "Tambah ke daftar hitam",
+ "remove_from_blacklist": "Hapus dari daftar hitam",
+ "share": "Bagikan",
+ "mini_player": "Pemutar Mini",
+ "slide_to_seek": "Geser untuk maju atau mundur",
+ "shuffle_playlist": "Acak daftar putar",
+ "unshuffle_playlist": "Batalkan pengacakan daftar putar",
+ "previous_track": "Lagu sebelumnya",
+ "next_track": "Lagu berikutnya",
+ "pause_playback": "Jeda Pemutaran",
+ "resume_playback": "Lanjutkan Pemutaran",
+ "loop_track": "Ulangi Pemutaran",
+ "repeat_playlist": "Ulangi daftar putar",
+ "queue": "Antrian",
+ "alternative_track_sources": "Sumber trek alternatif",
+ "download_track": "Unduh lagu",
+ "tracks_in_queue": "{tracks} trek dalam antrian",
+ "clear_all": "Bersihkan semua",
+ "show_hide_ui_on_hover": "Tampil/Sembunyikan UI saat mengarahkan kursor",
+ "always_on_top": "Selalu di atas",
+ "exit_mini_player": "Keluar Pemutar Mini",
+ "download_location": "Lokasi unduhan",
+ "account": "Akun",
+ "login_with_spotify": "Masuk dengan Spotify",
+ "connect_with_spotify": "Hubungkan dengan Spotify",
+ "logout": "Keluar",
+ "logout_of_this_account": "Keluar dari akun",
+ "language_region": "Bahasa & Wilayah",
+ "language": "Bahasa",
+ "system_default": "Bawaan Sistem",
+ "market_place_region": "Wilayah Pasar",
+ "recommendation_country": "Negara Rekomendasi",
+ "appearance": "Tampilan",
+ "layout_mode": "Mode Tata Letak",
+ "override_layout_settings": "Ganti pengaturan mode tata letak responsif",
+ "adaptive": "Adaptif",
+ "compact": "Ringkas",
+ "extended": "Diperluas",
+ "theme": "Tema",
+ "dark": "Gelap",
+ "light": "Terang",
+ "system": "Sistem",
+ "accent_color": "Warna Aksen",
+ "sync_album_color": "Sinkronkan warna album",
+ "sync_album_color_description": "Menggunakan warna dominan sampul album sebagai warna aksen",
+ "playback": "Pemutaran",
+ "audio_quality": "Kualitas Suara",
+ "high": "Tinggi",
+ "low": "Rendah",
+ "pre_download_play": "Unduh dan putar",
+ "pre_download_play_description": "Daripada streaming audio, unduh byte dan mainkan (Direkomendasikan untuk pengguna bandwidth yang lebih tinggi)",
+ "skip_non_music": "Lewati segmen non-musik (SponsorBlock)",
+ "blacklist_description": "Lagu dan artis di daftar hitam",
+ "wait_for_download_to_finish": "Tunggu hingga unduhan saat ini selesai",
+ "desktop": "Desktop",
+ "close_behavior": "Tutup Perilaku",
+ "close": "Tutup",
+ "minimize_to_tray": "Perkecil ke tray",
+ "show_tray_icon": "Tampilkan tray ikon sistem",
+ "about": "Tentang",
+ "u_love_spotube": "Kami tahu Anda menyukai Spotube",
+ "check_for_updates": "Periksa pembaruan",
+ "about_spotube": "Tentang Spotube",
+ "blacklist": "Daftar Hitam",
+ "please_sponsor": "Silakan Sponsor/Menyumbang",
+ "spotube_description": "Spotube, klien Spotify yang ringan, lintas platform, dan gratis untuk semua",
+ "version": "Versi",
+ "build_number": "Nomor Pembuatan",
+ "founder": "Pendiri",
+ "repository": "Repositori",
+ "bug_issues": "Bug+Masalah",
+ "made_with": "Dibuat dengan ❤️ di Bangladesh🇧🇩",
+ "kingkor_roy_tirtho": "Kingkor Roy Tirtho",
+ "copyright": "© 2021-{current_year} Kingkor Roy Tirtho",
+ "license": "Lisensi",
+ "add_spotify_credentials": "Tambahkan kredensial Spotify Anda untuk memulai",
+ "credentials_will_not_be_shared_disclaimer": "Jangan khawatir, kredensial Anda tidak akan dikumpulkan atau dibagikan kepada siapa pun",
+ "know_how_to_login": "Tidak tahu bagaimana melakukan ini?",
+ "follow_step_by_step_guide": "Ikuti panduan Langkah demi Langkah",
+ "spotify_cookie": "Spotify {name} Cookie",
+ "cookie_name_cookie": "{name} Cookie",
+ "fill_in_all_fields": "Silakan isi semua kolom",
+ "submit": "Kirim",
+ "exit": "Keluar",
+ "previous": "Sebelumnya",
+ "next": "Berikutnya",
+ "done": "Selesai",
+ "step_1": "Langkah 1",
+ "first_go_to": "Pertama, Pergi ke",
+ "login_if_not_logged_in": "dan Masuk/Daftar jika Anda belum masuk",
+ "step_2": "Langkah 2",
+ "step_2_steps": "1. Setelah Anda masuk, tekan F12 atau Klik Kanan Mouse > Buka Browser Devtools.\n2. Lalu buka Tab \"Aplikasi\" (Chrome, Edge, Brave, dll.) atau Tab \"Penyimpanan\" (Firefox, Palemoon, dll.)\n3. Buka bagian \"Cookie\" lalu subbagian \"https://accounts.spotify.com\"",
+ "step_3": "Langkah 3",
+ "step_3_steps": "Salin nilai Cookie \"sp_dc\" ",
+ "success_emoji": "Berhasil🥳",
+ "success_message": "Sekarang Anda telah berhasil Masuk dengan akun Spotify Anda. Kerja bagus, sobat!",
+ "step_4": "Langkah 4",
+ "step_4_steps": "Tempel nilai \"sp_dc\" yang disalin",
+ "something_went_wrong": "Terjadi kesalahan",
+ "piped_instance": "Piped Server Instance",
+ "piped_description": "The Piped server instance untuk digunakan sebagai pencocokan trek",
+ "piped_warning": "Beberapa di antaranya mungkin tidak berfungsi dengan baik. Jadi gunakan dengan risiko Anda sendiri",
+ "generate_playlist": "Hasilkan Daftar Putar",
+ "track_exists": "Lagu {track} sudah ada",
+ "replace_downloaded_tracks": "Ganti semua trek yang diunduh",
+ "skip_download_tracks": "Lewati pengunduhan semua trek yang diunduh",
+ "do_you_want_to_replace": "Apakah Anda ingin mengganti track yang ada?",
+ "replace": "Ganti",
+ "skip": "Lewati",
+ "select_up_to_count_type": "Pilih hingga {count} {type}",
+ "select_genres": "Pilih Genre",
+ "add_genres": "Tambah Genre",
+ "country": "Negara",
+ "number_of_tracks_generate": "Jumlah trek yang akan dihasilkan",
+ "acousticness": "Akustik",
+ "danceability": "Menari",
+ "energy": "Energi",
+ "instrumentalness": "Instrumentalitas",
+ "liveness": "Kehidupan",
+ "loudness": "Kekerasan",
+ "speechiness": "Berbicara",
+ "valence": "Valensi",
+ "popularity": "Popularitas",
+ "key": "Kunci",
+ "duration": "Durasi (s)",
+ "tempo": "Tempo (BPM)",
+ "mode": "Mode",
+ "time_signature": "Tanda Tangan Waktu",
+ "short": "Pendek",
+ "medium": "Sedang",
+ "long": "Panjang",
+ "min": "Minimal",
+ "max": "Maksimal",
+ "target": "Target",
+ "moderate": "Sedang",
+ "deselect_all": "Batalkan Semua",
+ "select_all": "Pilih Semua",
+ "are_you_sure": "Anda yakin?",
+ "generating_playlist": "Menghasilkan daftar putar khusus Anda...",
+ "selected_count_tracks": "{count} lagu yang dipilih",
+ "download_warning": "Jika Anda mengunduh semua Lagu secara massal, Anda jelas membajak Musik & menyebabkan kerusakan pada masyarakat kreatif Musik. Saya harap Anda menyadari hal ini. Selalu berusaha menghormati & mendukung kerja keras Artis",
+ "download_ip_ban_warning": "BTW, IP Anda bisa diblokir di YouTube karena permintaan unduhan yang berlebihan dari biasanya. Blokir IP berarti Anda tidak dapat menggunakan YouTube (meskipun Anda masuk) setidaknya selama 2-3 bulan dari perangkat IP tersebut. Dan Spotube tidak bertanggung jawab jika hal ini terjadi",
+ "by_clicking_accept_terms": "Dengan mengklik 'terima' Anda menyetujui ketentuan berikut:",
+ "download_agreement_1": "Saya tahu saya membajak Musik. Saya buruk",
+ "download_agreement_2": "Saya akan mendukung Artis di mana pun saya bisa dan saya melakukan ini hanya karena saya tidak punya uang untuk membeli karya seni mereka",
+ "download_agreement_3": "Saya sepenuhnya menyadari bahwa IP saya dapat diblokir di YouTube & saya tidak menganggap Spotube atau pemilik/kontributornya bertanggung jawab atas kecelakaan apa pun yang disebabkan oleh tindakan saya saat ini",
+ "decline": "Menolak",
+ "accept": "Setuju",
+ "details": "Detail",
+ "youtube": "YouTube",
+ "channel": "Channel",
+ "likes": "Suka",
+ "dislikes": "Tidak Suka",
+ "views": "Dilihat",
+ "streamUrl": "URL Stream",
+ "stop": "Berhenti",
+ "sort_newest": "Urutkan yang baru ditambah",
+ "sort_oldest": "Urutkan yang paling lama ditambah",
+ "sleep_timer": "Pengatur Waktu Tidur",
+ "mins": "{minutes} Menit",
+ "hours": "{hours} Jam",
+ "hour": "{hours} Jam",
+ "custom_hours": "Jam Kostum",
+ "logs": "Log",
+ "developers": "Pengembang",
+ "not_logged_in": "Anda belum masuk",
+ "search_mode": "Mode Pencarian",
+ "audio_source": "Sumber Suara",
+ "ok": "OK",
+ "failed_to_encrypt": "Gagal mengenkripsi",
+ "encryption_failed_warning": "Spotube menggunakan enkripsi untuk menyimpan data Anda dengan aman. Namun gagal melakukannya. Jadi itu akan kembali ke penyimpanan yang tidak aman\nJika Anda menggunakan linux, pastikan Anda telah menginstal layanan rahasia (gnome-keyring, kde-wallet, keepassxc, dll)",
+ "querying_info": "Mencari informasi...",
+ "piped_api_down": "Piped API tidak aktif",
+ "piped_down_error_instructions": "Piped Instance {pipedInstance} saat ini tidak aktif\n\nUbah instance atau ubah 'jenis API' menjadi API YouTube resmi\n\nPastikan untuk memulai ulang aplikasi setelah perubahan",
+ "you_are_offline": "Anda sedang offline",
+ "connection_restored": "Koneksi internet Anda telah pulih",
+ "use_system_title_bar": "Gunakan bilah judul sistem",
+ "crunching_results": "Mengolah hasil...",
+ "search_to_get_results": "Cari untuk mendapatkan hasil",
+ "use_amoled_mode": "Tema gelap gulita",
+ "pitch_dark_theme": "Mode AMOLED",
+ "normalize_audio": "Normalisasi audio",
+ "change_cover": "Ganti sampul",
+ "add_cover": "Tambah sampul",
+ "restore_defaults": "Kembalikan semula",
+ "download_music_codec": "Unduh codec musik",
+ "streaming_music_codec": "Streaming codec musik",
+ "login_with_lastfm": "Masuk dengan Last.fm",
+ "connect": "Hubungkan",
+ "disconnect_lastfm": "Memutuskan Last.fm",
+ "disconnect": "Memutuskan",
+ "username": "Username",
+ "password": "Password",
+ "login": "Masuk",
+ "login_with_your_lastfm": "Masuk dengan Last.fm Anda",
+ "scrobble_to_lastfm": "Scrobble ke Last.fm",
+ "go_to_album": "Pergi ke Album",
+ "discord_rich_presence": "Discord Rich Presence",
+ "browse_all": "Lihat Semua",
+ "genres": "Genre",
+ "explore_genres": "Jelajahi Genre",
+ "friends": "Daftar Teman",
+ "no_lyrics_available": "Maaf, tidak dapat menemukan lirik untuk lagu ini",
+ "start_a_radio": "Putar Radio",
+ "how_to_start_radio": "Bagaimana Anda ingin memutar radio?",
+ "replace_queue_question": "Apakah Anda ingin mengganti antrean saat ini atau menambahkannya?",
+ "endless_playback": "Pemutaran Tanpa Akhir",
+ "delete_playlist": "Hapus Daftar Putar",
+ "delete_playlist_confirmation": "Anda yakin ingin menghapus daftar putar ini?",
+ "local_tracks": "Trek Lokal",
+ "song_link": "Tautan Lagu",
+ "skip_this_nonsense": "Lewati omong kosong ini",
+ "freedom_of_music": "“Kebebasan Musik”",
+ "freedom_of_music_palm": "“Kebebasan Musik di telapak tangan Anda”",
+ "get_started": "Mari kita mulai",
+ "youtube_source_description": "Direkomendasikan dan berfungsi paling baik.",
+ "piped_source_description": "Merasa bebas? Sama seperti YouTube tetapi banyak yang gratis.",
+ "jiosaavn_source_description": "Terbaik untuk wilayah Asia Selatan.",
+ "highest_quality": "Kualitas Terbaik: {quality}",
+ "select_audio_source": "Pilih Sumber Suara",
+ "endless_playback_description": "Tambahkan lagu baru secara otomatis\nke akhir antrean",
+ "choose_your_region": "Pilih wilayah Anda",
+ "choose_your_region_description": "Ini akan membantu Spotube menampilkan konten yang tepat\nuntuk lokasi Anda.",
+ "choose_your_language": "Pilih bahasa Anda",
+ "help_project_grow": "Bantu proyek ini berkembang",
+ "help_project_grow_description": "Spotube adalah proyek sumber terbuka. Anda dapat membantu proyek ini berkembang dengan berkontribusi pada proyek, melaporkan bug, atau menyarankan fitur baru.",
+ "contribute_on_github": "Berkontribusi di GitHub",
+ "donate_on_open_collective": "Donasi di Open Collective",
+ "browse_anonymously": "Jelajahi Secara Anonim",
+ "enable_connect": "Aktifkan Hubungkan",
+ "enable_connect_description": "Kontrol Spotube dari perangkat lain",
+ "devices": "Perangkat",
+ "select": "Pilih",
+ "connect_client_alert": "Anda dikendalikan oleh {client}",
+ "this_device": "Perangkat Ini",
+ "remote": "Remot",
+ "local_library": "Perpustakaan lokal",
+ "add_library_location": "Tambahkan ke perpustakaan",
+ "remove_library_location": "Hapus dari perpustakaan",
+ "local_tab": "Lokal",
+ "stats": "Statistik"
+}
\ No newline at end of file
diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb
index 1a3910a1..140844b6 100644
--- a/lib/l10n/app_it.arb
+++ b/lib/l10n/app_it.arb
@@ -314,5 +314,17 @@
"help_project_grow_description": "Spotube è un progetto open-source. Puoi aiutare questo progetto a crescere contribuendo al progetto, segnalando bug o suggerendo nuove funzionalità.",
"contribute_on_github": "Contribuisci su GitHub",
"donate_on_open_collective": "Dona su Open Collective",
- "browse_anonymously": "Naviga in modo anonimo"
-}
+ "browse_anonymously": "Naviga in modo anonimo",
+ "enable_connect": "Abilita connessione",
+ "enable_connect_description": "Controlla Spotube da altri dispositivi",
+ "devices": "Dispositivi",
+ "select": "Seleziona",
+ "connect_client_alert": "Stai venendo controllato da {client}",
+ "this_device": "Questo dispositivo",
+ "remote": "Remoto",
+ "local_library": "Biblioteca locale",
+ "add_library_location": "Aggiungi alla biblioteca",
+ "remove_library_location": "Rimuovi dalla biblioteca",
+ "local_tab": "Locale",
+ "stats": "Statistiche"
+}
\ No newline at end of file
diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb
index ecdc77a2..35e76b69 100644
--- a/lib/l10n/app_ja.arb
+++ b/lib/l10n/app_ja.arb
@@ -313,5 +313,17 @@
"help_project_grow_description": "Spotubeはオープンソースプロジェクトです。プロジェクトに貢献したり、バグを報告したり、新しい機能を提案することで、このプロジェクトの成長に貢献できます。",
"contribute_on_github": "GitHubで貢献する",
"donate_on_open_collective": "Open Collectiveで寄付する",
- "browse_anonymously": "匿名で閲覧する"
+ "browse_anonymously": "匿名で閲覧する",
+ "enable_connect": "接続を有効にする",
+ "enable_connect_description": "他のデバイスからSpotubeを制御する",
+ "devices": "デバイス",
+ "select": "選択する",
+ "connect_client_alert": "{client} によって操作されています",
+ "this_device": "このデバイス",
+ "remote": "リモート",
+ "local_library": "ローカルライブラリ",
+ "add_library_location": "ライブラリに追加",
+ "remove_library_location": "ライブラリから削除",
+ "local_tab": "ローカル",
+ "stats": "統計"
}
\ No newline at end of file
diff --git a/lib/l10n/app_ka.arb b/lib/l10n/app_ka.arb
new file mode 100644
index 00000000..28fcc26a
--- /dev/null
+++ b/lib/l10n/app_ka.arb
@@ -0,0 +1,329 @@
+{
+ "guest": "სტუმარი",
+ "browse": "ნახვა",
+ "search": "ძებნა",
+ "library": "ბიბლიოთეკა",
+ "lyrics": "ტექსტები",
+ "settings": "კონფიგურაციები",
+ "genre_categories_filter": "კატეგორიების ან ჟანრების ფილტრი...",
+ "genre": "ჟანრი",
+ "personalized": "პეერსონალიზებული",
+ "featured": "გამორჩეული",
+ "new_releases": "ახალი გამოცემები",
+ "songs": "სიმღერები",
+ "playing_track": "უკრავს {track}",
+ "queue_clear_alert": "ეს გაასუფთავებს მიმდინარე რიგს. {track_length} ტრეკი წაიშლება\nᲒინდა გააგრძელო?",
+ "load_more": "მეტის ჩატვირთვა",
+ "playlists": "ფლეილისტები",
+ "artists": "არტისტები",
+ "albums": "ალბომები",
+ "tracks": "ტრეკები",
+ "downloads": "ჩამოტვირთვები",
+ "filter_playlists": "ფლეილისტების გაფილტვრა...",
+ "liked_tracks": "მოწონებული ტრეკები",
+ "liked_tracks_description": "ყველა შენი მოწონებული ტრეკი",
+ "create_playlist": "ფლეილისტის შექმნა",
+ "create_a_playlist": "ფლეილისტის შექმნა",
+ "update_playlist": "ფლეილისტის განახლება",
+ "create": "შექმნა",
+ "cancel": "გაუქმება",
+ "update": "განახლება",
+ "playlist_name": "ფლეილისტის სახელი",
+ "name_of_playlist": "ფლეილისტის სახელი",
+ "description": "აღწერა",
+ "public": "საჯარო",
+ "collaborative": "კოლაბორაციული",
+ "search_local_tracks": "ლოცალური ტრეკების ძებნა...",
+ "play": "დაკვრა",
+ "delete": "წაშლა",
+ "none": "არცერთი",
+ "sort_a_z": "დალაგება A-Z-ს მიხედვით",
+ "sort_z_a": "დალაგება Z-A-ს მიხედვით",
+ "sort_artist": "დალაგება არტისტის მიხედვით",
+ "sort_album": "დალაგება ალბომის მიხედვით",
+ "sort_duration": "დალაგება ხანგრძლივობის მიხედვით",
+ "sort_tracks": "ტრეკების დალაგება",
+ "currently_downloading": "მიმდინარეობს ჩამოტვირთვა ({tracks_length})",
+ "cancel_all": "ყველას გაუქმება",
+ "filter_artist": "არტისტების ფილტრი...",
+ "followers": "{followers} ფოლოვერები",
+ "add_artist_to_blacklist": "არტისტის შავ სიაში დამატება",
+ "top_tracks": "ტოპ ტრეკები",
+ "fans_also_like": "ფანებს ასევე მოსწონთ",
+ "loading": "იტვირთება...",
+ "artist": "არტისტი",
+ "blacklisted": "შავ სიაში მყოფი",
+ "following": "ფოლოვინგი",
+ "follow": "დაფოლოვება",
+ "artist_url_copied": "არტისტის ლინკი დაკოპირებულია",
+ "added_to_queue": "{tracks} ტრეკი დაემატა რიგში",
+ "filter_albums": "ალბომების გაფილტვრა...",
+ "synced": "სინქრონიზებული",
+ "plain": "Plain",
+ "shuffle": "რიგის არევა",
+ "search_tracks": "ტრეკების ძებნა...",
+ "released": "გამოშვებული",
+ "error": "შეცდომა {error}",
+ "title": "სათაური",
+ "time": "დრო",
+ "more_actions": "მეტი მოქმედებები",
+ "download_count": "გადმოწერა ({count})",
+ "add_count_to_playlist": "ფლეილისტში ({count})-ის დამატება",
+ "add_count_to_queue": "რიგში ({count})-ის დამატება",
+ "play_count_next": "შემდეგი ({count})-ის დაკვრა",
+ "album": "ალბომი",
+ "copied_to_clipboard": "{data} დაკოპირებულია",
+ "add_to_following_playlists": "დაამატე {track} ამ ფლეილისტებში",
+ "add": "დამატება",
+ "added_track_to_queue": "რიგში დაემატა {track}",
+ "add_to_queue": "რიგში დამატება",
+ "track_will_play_next": "{track} დაუკრავს შემდეგს",
+ "play_next": "შემდეგის დაკვრა",
+ "removed_track_from_queue": "რიგიდან წაიშალა {track}",
+ "remove_from_queue": "რიგიდან წაშლა",
+ "remove_from_favorites": "ფავორიტებიდან წაშლა",
+ "save_as_favorite": "ფავორიტებში დამატება",
+ "add_to_playlist": "ფლეილისტში დამატება",
+ "remove_from_playlist": "ფლეილისტიდან წაშლა",
+ "add_to_blacklist": "შავ სიაში დამატება",
+ "remove_from_blacklist": "შავი სიიდან წაშლა",
+ "share": "გაზიარება",
+ "mini_player": "მინი დამკვრელი",
+ "slide_to_seek": "გადახვევისთვის გაასრიალეთ წინ ან უკან",
+ "shuffle_playlist": "ფლეილისტის არევა",
+ "unshuffle_playlist": "ფლეილისტის დალაგება",
+ "previous_track": "წინა ტრეკი",
+ "next_track": "შემდეგი ტრეკი",
+ "pause_playback": "დაკვრის გაჩერება",
+ "resume_playback": "დაკვრის გაგრძელება",
+ "loop_track": "ტრეკის ლუპზე დაკვრა",
+ "repeat_playlist": "ფლეილისტის გამეორება",
+ "queue": "რიგი",
+ "alternative_track_sources": "ალტერნატიული ტრეკების წყაროები",
+ "download_track": "გადმოწერე ტრეკი",
+ "tracks_in_queue": "{tracks} ტრეკი რიგში",
+ "clear_all": "ყველას წაშლა",
+ "show_hide_ui_on_hover": "UI-ის ჩვენება/დამალვა ჰოვერზე",
+ "always_on_top": "ტოველთვის ზემოდან",
+ "exit_mini_player": "მინი დამკვრელიდან გამოსვლა",
+ "download_location": "ჩამოტვირთვის მდებარეობა",
+ "account": "ანგარიში",
+ "login_with_spotify": "შედით თქვენი Spotify ანგარიშით",
+ "connect_with_spotify": "დაუკავშირდით Spotify-ს",
+ "logout": "გასვლა",
+ "logout_of_this_account": "ანგარიშიდან გასვლა",
+ "language_region": "ენა და რეგიონი",
+ "language": "ენა",
+ "system_default": "სისტემის ნაგულისხმევი",
+ "market_place_region": "მარკეტფლეისის რეგიონი",
+ "recommendation_country": "რეკომენდირებული ქვეყანა",
+ "appearance": "გარეგნობა",
+ "layout_mode": "განლაგების რეჟიმი",
+ "override_layout_settings": "რესფონსივ განლაგების რეჟიმის კონფიგურაციაზე გადაწერა",
+ "adaptive": "ადაპტირებული",
+ "compact": "კომპაქტური",
+ "extended": "გაფართოებული",
+ "theme": "თემა",
+ "dark": "ბნელი",
+ "light": "ღია",
+ "system": "სისტემის",
+ "accent_color": "აქცენტის ფერი",
+ "sync_album_color": "ალბომის ფერის სინქრონიზაცია",
+ "sync_album_color_description": "დომინანტური ალბომის ფერის აქცენტის ფერად გამოყენება",
+ "playback": "დაკვრა",
+ "audio_quality": "აუდიოს ხარისხი",
+ "high": "მაღალი",
+ "low": "დაბალი",
+ "pre_download_play": "წინასწარ ჩამოტვირთვა და დაკვრა",
+ "pre_download_play_description": "აუდიოს სტრიმინგის ნაცვლად, ბაიტების ჩამოტვირთვა და დაკვრა (რეკომენდებულია უფრო მაღალი გამტარუნარიანობის მომხმარებლებისთვის)",
+ "skip_non_music": "არა მუსიკალური ნაწილის გამოტოვება (სპონსორის ბლოკი)",
+ "blacklist_description": "შავ სიაში მყოფი არტისტები და ტრეკები",
+ "wait_for_download_to_finish": "გთხოვთ, დაელოდოთ მიმდინარე ჩამოტვირთვის დასრულებას",
+ "desktop": "დესკტოპი",
+ "close_behavior": "დახურვის ქცევა",
+ "close": "დახურვა",
+ "minimize_to_tray": "მინიმიზაცია",
+ "show_tray_icon": "სისტემის აიკონის ჩვენება",
+ "about": "ჩვენს შესახებ",
+ "u_love_spotube": "We know you love Spotube",
+ "check_for_updates": "განახლებების შემოწმება",
+ "about_spotube": "Spotube-ს შესახებ",
+ "blacklist": "შავი სია",
+ "please_sponsor": "გთხოვთ დაგვასპონსოროთ",
+ "spotube_description": "Spotube, a lightweight, cross-platform, free-for-all spotify client",
+ "version": "ვერსია",
+ "build_number": "Build Number",
+ "founder": "დამფუძნებელი",
+ "repository": "რეპოზიტორია",
+ "bug_issues": "Bug+Issues",
+ "made_with": "Made with ❤️ in Bangladesh🇧🇩",
+ "kingkor_roy_tirtho": "Kingkor Roy Tirtho",
+ "copyright": "© 2021-{current_year} Kingkor Roy Tirtho",
+ "license": "ლიცენზია",
+ "add_spotify_credentials": "დასაწყებად დაამატეთ თქვენი Spotify მონაცემები",
+ "credentials_will_not_be_shared_disclaimer": "არ ინერვიულოთ, თქვენი მონაცემები არ იქნება შეგროვებული ან გაზიარებული ვინმესთან",
+ "know_how_to_login": "არ იცით როგორ გააკეთოთ ეს?",
+ "follow_step_by_step_guide": "მიჰყევით ნაბიჯ-ნაბიჯ სახელმძღვანელოს",
+ "spotify_cookie": "Spotify {name} ქუქი",
+ "cookie_name_cookie": "{name} ქუქი",
+ "fill_in_all_fields": "გთხოვთ შეავსოთ ყველა ველი",
+ "submit": "გაგზავნა",
+ "exit": "გამოსვლა",
+ "previous": "წინა",
+ "next": "შემდეგი",
+ "done": "მზადაა",
+ "step_1": "ნაბიჯი 1",
+ "first_go_to": "პირველი, გადადით",
+ "login_if_not_logged_in": "და შესვლა/რეგისტრაცია, თუ არ ხართ შესული",
+ "step_2": "ნაბიჯი 2",
+ "step_2_steps": "1. როცა შეხვალთ, დააჭირეთ F12-ს ან მაუსის მარჯვენა ღილაკს > Inspect to Open the Browser devtools.\n2. შემდეგ გახსენით \"Application\" განყოფილება (Chrome, Edge, Brave etc..) ან \"Storage\" განყოფილება (Firefox, Palemoon etc..)\n3. შედით \"Cookies\" სექციაში და შემდეგ \"https://accounts.spotify.com\" სუბსექციაში",
+ "step_3": "ნაბიჯი 3",
+ "step_3_steps": "დააკოპირეთ \"sp_dc\" ქუქი-ფაილის მნიშვნელობა",
+ "success_emoji": "წარმატება🥳",
+ "success_message": "თქვენ წარმატებით შეხვედით თქვენი Spotify ანგარიშით.",
+ "step_4": "ნაბიჯი 4",
+ "step_4_steps": "ჩასვით კოპირებული \"sp_dc\" მნიშვნელობა",
+ "something_went_wrong": "Რაღაც არასწორად წავიდა",
+ "piped_instance": "Piped Server Instance",
+ "piped_description": "The Piped server instance to use for track matching",
+ "piped_warning": "ზოგიერთი მათგანმა შეიძლება კარგად არ იმუშაოს. ",
+ "generate_playlist": "ფლეილისტის დაგენერირება",
+ "track_exists": "ტრეკი {track} უკვე არსებობს",
+ "replace_downloaded_tracks": "ყველა ჩამოტვირთული ტრეკის შეცვლა",
+ "skip_download_tracks": "ყველა ჩამოტვირთული ტრეკის გამოტოვება",
+ "do_you_want_to_replace": "გსურთ შეცვალოთ არსებული ტრეკი??",
+ "replace": "შეცვლა",
+ "skip": "გამოტოვება",
+ "select_up_to_count_type": "აირჩიე {count}-მდე {type}",
+ "select_genres": "ჟანრების არჩევა",
+ "add_genres": "ჟანრების დამატება",
+ "country": "ქვეყანა",
+ "number_of_tracks_generate": "დასაგენერირებელი ტრეკების რაოდენობა",
+ "acousticness": "Acousticness",
+ "danceability": "Danceability",
+ "energy": "Energy",
+ "instrumentalness": "Instrumentalness",
+ "liveness": "Liveness",
+ "loudness": "Loudness",
+ "speechiness": "Speechiness",
+ "valence": "Valence",
+ "popularity": "Popularity",
+ "key": "Key",
+ "duration": "Duration (s)",
+ "tempo": "Tempo (BPM)",
+ "mode": "Mode",
+ "time_signature": "Time Signature",
+ "short": "Short",
+ "medium": "საშუალო",
+ "long": "გრძელი",
+ "min": "მინიმალური",
+ "max": "მაქსიმალური",
+ "target": "სამიზნე",
+ "moderate": "საშუალო",
+ "deselect_all": "ყველა მონიშვნის გაუქმება",
+ "select_all": "ყველას მონიშვნა",
+ "are_you_sure": "Დარწმუნებული ხართ?",
+ "generating_playlist": "მიმდინარეობს თქვენი მორგებული ფლეილისტის გენერირება...",
+ "selected_count_tracks": "არჩეულია {count} ტრეკი",
+ "download_warning": "If you download all Tracks at bulk you're clearly pirating Music & causing damage to the creative society of Music. I hope you are aware of this. Always, try respecting & supporting Artist's hard work",
+ "download_ip_ban_warning": "BTW, your IP can get blocked on YouTube due excessive download requests than usual. IP block means you can't use YouTube (even if you're logged in) for at least 2-3 months from that IP device. And Spotube doesn't hold any responsibility if this ever happens",
+ "by_clicking_accept_terms": "By clicking 'accept' you agree to following terms:",
+ "download_agreement_1": "I know I'm pirating Music. I'm bad",
+ "download_agreement_2": "I'll support the Artist wherever I can and I'm only doing this because I don't have money to buy their art",
+ "download_agreement_3": "I'm completely aware that my IP can get blocked on YouTube & I don't hold Spotube or his owners/contributors responsible for any accidents caused by my current action",
+ "decline": "უარყოფა",
+ "accept": "დათანხმება",
+ "details": "დეტალები",
+ "youtube": "YouTube",
+ "channel": "Channel",
+ "likes": "მოწონებები",
+ "dislikes": "არ მოწონებები",
+ "views": "ნახვები",
+ "streamUrl": "სტრიმის ლინკი",
+ "stop": "გაჩერება",
+ "sort_newest": "ფალაგება სიახლის მიხედიტ",
+ "sort_oldest": "დალაგება სიძველის მიხედვით",
+ "sleep_timer": "ძილის ტაიმერი",
+ "mins": "{minutes} წუთი",
+ "hours": "{hours} საათი",
+ "hour": "{hours} საათი",
+ "custom_hours": "მორგებული საათები",
+ "logs": "ლოგები",
+ "developers": "დეველოპერები",
+ "not_logged_in": "არ ხარ დალოგინებული",
+ "search_mode": "ძებნის რეჟიმი",
+ "audio_source": "აუდიოს წყარო",
+ "ok": "ოკ",
+ "failed_to_encrypt": "დაშიფვრა ვერ მოხერხდა",
+ "encryption_failed_warning": "Spotube uses encryption to securely store your data. But failed to do so. So it'll fallback to insecure storage\nIf you're using linux, please make sure you've any secret-service (gnome-keyring, kde-wallet, keepassxc etc) installed",
+ "querying_info": "Querying info...",
+ "piped_api_down": "Piped API is down",
+ "piped_down_error_instructions": "The Piped instance {pipedInstance} is currently down\n\nEither change the instance or change the 'API type' to official YouTube API\n\nMake sure to restart the app after change",
+ "you_are_offline": "ამჟამად ხაზგარეშე ხართ",
+ "connection_restored": "თქვენი ინტერნეტ კავშირი აღდგა",
+ "use_system_title_bar": "სისტემის სათაურის ზოლის გამოყენება",
+ "crunching_results": "იტვირთება შედეგები...",
+ "search_to_get_results": "მოძებნეთ შედეგების მისაღებად",
+ "use_amoled_mode": "Pitch black dark theme",
+ "pitch_dark_theme": "AMOLED Mode",
+ "normalize_audio": "აუდიოს ნორმალიზება",
+ "change_cover": "Ქავერის შეცვლა",
+ "add_cover": "Ქავერის ფოტოს დამატება",
+ "restore_defaults": "ნაგულისხმევი პარამეტრების აღდგენა",
+ "download_music_codec": "მუსიკის კოდეკის გადმოწერა",
+ "streaming_music_codec": "სტრიმინგ მუსიკის კოდეკი",
+ "login_with_lastfm": "Last.fm-ით შესვლა",
+ "connect": "დაკავშირება",
+ "disconnect_lastfm": "Last.fm-იდან გამოსვლა",
+ "disconnect": "გამოსვლა",
+ "username": "მომხმარებელი",
+ "password": "პაროლი",
+ "login": "შესვლა",
+ "login_with_your_lastfm": "Last.fm ანგარიშით შესვლა",
+ "scrobble_to_lastfm": "Scrobble to Last.fm",
+ "go_to_album": "ალბომზე გადასვლა",
+ "discord_rich_presence": "Discord Rich Presence",
+ "browse_all": "ყველას ნახვა",
+ "genres": "ჟანრები",
+ "explore_genres": "შეისწავლეთ ჟანრები",
+ "friends": "მეგობრები",
+ "no_lyrics_available": "უკაცრავად, ამ ტრეკისთვის ტექსტის პოვნა შეუძლებელია",
+ "start_a_radio": "რადიოს ჩართვა",
+ "how_to_start_radio": "როგორ გნებავთ რადიოს ჩართვა?",
+ "replace_queue_question": "გნებავთ ჩაანაცვლოთ არსებული რიგი თუ დაამატოთ მასზე?",
+ "endless_playback": "დაუსრულებელი დაკვრა",
+ "delete_playlist": "ფლეილისტის წაშლა",
+ "delete_playlist_confirmation": "დარწმუნებული ხართ რომ გნებავთ ფლეილისტის წაშლა?",
+ "local_tracks": "ლოკალური ტრეკები",
+ "song_link": "ტრეკის ლინკი",
+ "skip_this_nonsense": "ამ სისულელის გამოტოვება",
+ "freedom_of_music": "“მუსიკის თავისუფლება”",
+ "freedom_of_music_palm": "“მუსიკის თავისუფლება შენს ხელის გულზე”",
+ "get_started": "დავიწყოთ",
+ "youtube_source_description": "რეკომენდებულია და მუშაობს საუკეთესოდ.",
+ "piped_source_description": "თავისუფლად გრძნობთ თავს? იგივეა, რაც YouTube, მაგრამ ბევრი თავისუფალი.",
+ "jiosaavn_source_description": "საუკეთესოა სამხრეთ აზიის რეგიონისთვის.",
+ "highest_quality": "საუკეთესო ხარისხი: {quality}",
+ "select_audio_source": "აუდიოს წყაროს არჩევა",
+ "endless_playback_description": "ახალი სიმთერების ავტომატურად რიგის ბოლოში დამატება",
+ "choose_your_region": "აირჩიე შენი რეგიონი",
+ "choose_your_region_description": "This will help Spotube show you the right content\nfor your location.",
+ "choose_your_language": "აირჩიე ენა",
+ "help_project_grow": "დაეხმარეთ ამ პროექტს განვითარებაში",
+ "help_project_grow_description": "Spotube is an open-source project. You can help this project grow by contributing to the project, reporting bugs, or suggesting new features.",
+ "contribute_on_github": "GitHub-ზე კონტრიბუცია",
+ "donate_on_open_collective": "Open Collective-ზე დონაცია",
+ "browse_anonymously": "ანონიმურად ნახვა",
+ "enable_connect": "დაკავშირების ჩართვა",
+ "enable_connect_description": "აკონტროლე Spotube სხვა მოწყობილობებიდან",
+ "devices": "მოწყობილობები",
+ "select": "არჩევა",
+ "connect_client_alert": "თქვენ კონტროლირებული ხართ {client} მოწყობილობით",
+ "this_device": "ეს მოწყობილობა",
+ "remote": "დისტანციური",
+ "local_library": "ადგილობრივი ბიბლიოთეკა",
+ "add_library_location": "ბიბლიოთეკაში დამატება",
+ "remove_library_location": "ბიბლიოთეკიდან წაშლა",
+ "local_tab": "ადგილობრივი",
+ "stats": "სტატისტიკა"
+}
\ No newline at end of file
diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb
index 5a3ee8bc..cb6e0999 100644
--- a/lib/l10n/app_ko.arb
+++ b/lib/l10n/app_ko.arb
@@ -314,5 +314,17 @@
"help_project_grow_description": "Spotube는 오픈 소스 프로젝트입니다. 프로젝트에 기여하거나 버그를 보고하거나 새로운 기능을 제안하여이 프로젝트의 성장에 도움을 줄 수 있습니다.",
"contribute_on_github": "GitHub에서 기여하기",
"donate_on_open_collective": "Open Collective에 기부하기",
- "browse_anonymously": "익명으로 둘러보기"
+ "browse_anonymously": "익명으로 둘러보기",
+ "enable_connect": "연결 활성화",
+ "enable_connect_description": "다른 장치에서 Spotube 제어",
+ "devices": "장치",
+ "select": "선택",
+ "connect_client_alert": "{client}님에 의해 제어되고 있습니다",
+ "this_device": "이 장치",
+ "remote": "원격",
+ "local_library": "로컬 도서관",
+ "add_library_location": "도서관에 추가",
+ "remove_library_location": "도서관에서 제거",
+ "local_tab": "로컬",
+ "stats": "통계"
}
\ No newline at end of file
diff --git a/lib/l10n/app_ne.arb b/lib/l10n/app_ne.arb
index d921f3ba..f8e8d46a 100644
--- a/lib/l10n/app_ne.arb
+++ b/lib/l10n/app_ne.arb
@@ -313,5 +313,17 @@
"help_project_grow_description": "Spotube एक खुला स्रोतको परियोजना हो। तपाईं परियोजनामा योगदान गरेर, त्रुटिहरू सूचिकै, वा नयाँ सुविधाहरू सुझाव दिएर यस परियोजनामा वृद्धि गर्न सक्नुहुन्छ।",
"contribute_on_github": "GitHubमा योगदान गर्नुहोस्",
"donate_on_open_collective": "खुला संगठनमा दान गर्नुहोस्",
- "browse_anonymously": "अनामित रूपमा ब्राउज़ गर्नुहोस्"
+ "browse_anonymously": "अनामित रूपमा ब्राउज़ गर्नुहोस्",
+ "enable_connect": "कनेक्ट सक्रिय गर्नुहोस्",
+ "enable_connect_description": "अन्य उपकरणहरूबाट Spotube कन्ट्रोल गर्नुहोस्",
+ "devices": "उपकरणहरू",
+ "select": "चयन गर्नुहोस्",
+ "connect_client_alert": "तपाईंलाई {client} द्वारा नियन्त्रित गरिएको छ",
+ "this_device": "यो उपकरण",
+ "remote": "दूरसंचार",
+ "local_library": "स्थानिय पुस्तकालय",
+ "add_library_location": "पुस्तकालयमा थप्नुहोस्",
+ "remove_library_location": "पुस्तकालयबाट हटाउनुहोस्",
+ "local_tab": "स्थानिय",
+ "stats": "तथ्याङ्क"
}
\ No newline at end of file
diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb
index 33e94a2e..aa5c846d 100644
--- a/lib/l10n/app_nl.arb
+++ b/lib/l10n/app_nl.arb
@@ -314,5 +314,17 @@
"help_project_grow_description": "Spotube is een open-source project. U kunt dit project helpen groeien door bij te dragen aan het project, bugs te melden of nieuwe functies voor te stellen.",
"contribute_on_github": "Bijdragen op GitHub",
"donate_on_open_collective": "Doneren op Open Collective",
- "browse_anonymously": "Anoniem Bladeren"
+ "browse_anonymously": "Anoniem Bladeren",
+ "enable_connect": "Verbinding inschakelen",
+ "enable_connect_description": "Spotube bedienen vanaf andere apparaten",
+ "devices": "Apparaten",
+ "select": "Selecteren",
+ "connect_client_alert": "Je wordt gecontroleerd door {client}",
+ "this_device": "Dit apparaat",
+ "remote": "Afstandsbediening",
+ "local_library": "Lokale bibliotheek",
+ "add_library_location": "Toevoegen aan bibliotheek",
+ "remove_library_location": "Verwijderen uit bibliotheek",
+ "local_tab": "Lokaal",
+ "stats": "Statistieken"
}
\ No newline at end of file
diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb
index a1bc5de6..2c4e8369 100644
--- a/lib/l10n/app_pl.arb
+++ b/lib/l10n/app_pl.arb
@@ -313,5 +313,17 @@
"help_project_grow_description": "Spotube to projekt open-source. Możesz pomóc temu projektowi rosnąć, przyczyniając się do projektu, zgłaszając błędy lub sugerując nowe funkcje.",
"contribute_on_github": "Przyczyniaj się na GitHubie",
"donate_on_open_collective": "Dotuj na Open Collective",
- "browse_anonymously": "Przeglądaj Anonimowo"
+ "browse_anonymously": "Przeglądaj Anonimowo",
+ "enable_connect": "Włącz połączenie",
+ "enable_connect_description": "Kontroluj Spotube z innych urządzeń",
+ "devices": "Urządzenia",
+ "select": "Wybierz",
+ "connect_client_alert": "Jesteś sterowany przez {client}",
+ "this_device": "To urządzenie",
+ "remote": "Zdalny",
+ "local_library": "Biblioteka lokalna",
+ "add_library_location": "Dodaj do biblioteki",
+ "remove_library_location": "Usuń z biblioteki",
+ "local_tab": "Lokalny",
+ "stats": "Statystyki"
}
\ No newline at end of file
diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb
index 7f290a1d..88cf5cb3 100644
--- a/lib/l10n/app_pt.arb
+++ b/lib/l10n/app_pt.arb
@@ -313,5 +313,17 @@
"help_project_grow_description": "Spotube é um projeto de código aberto. Você pode ajudar este projeto a crescer contribuindo para o projeto, relatando bugs ou sugerindo novos recursos.",
"contribute_on_github": "Contribuir no GitHub",
"donate_on_open_collective": "Doar no Open Collective",
- "browse_anonymously": "Navegar Anonimamente"
+ "browse_anonymously": "Navegar Anonimamente",
+ "enable_connect": "Ativar conexão",
+ "enable_connect_description": "Controle o Spotube a partir de outros dispositivos",
+ "devices": "Dispositivos",
+ "select": "Selecionar",
+ "connect_client_alert": "Você está sendo controlado por {client}",
+ "this_device": "Este dispositivo",
+ "remote": "Remoto",
+ "local_library": "Biblioteca local",
+ "add_library_location": "Adicionar à biblioteca",
+ "remove_library_location": "Remover da biblioteca",
+ "local_tab": "Local",
+ "stats": "Estatísticas"
}
\ No newline at end of file
diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb
index c9139a90..0a1c1c22 100644
--- a/lib/l10n/app_ru.arb
+++ b/lib/l10n/app_ru.arb
@@ -313,5 +313,17 @@
"help_project_grow_description": "Spotube - это проект с открытым исходным кодом. Вы можете помочь этому проекту развиваться, внося вклад в проект, сообщая ошибках или предлагая новые функции.",
"contribute_on_github": "Внести вклад на GitHub",
"donate_on_open_collective": "Пожертвовать на Open Collective",
- "browse_anonymously": "Анонимно просматривать"
+ "browse_anonymously": "Анонимно просматривать",
+ "enable_connect": "Включить подключение",
+ "enable_connect_description": "Управление Spotube с других устройств",
+ "devices": "Устройства",
+ "select": "Выбрать",
+ "connect_client_alert": "Вас контролирует {client}",
+ "this_device": "Это устройство",
+ "remote": "Дистанционное управление",
+ "local_library": "Местная библиотека",
+ "add_library_location": "Добавить в библиотеку",
+ "remove_library_location": "Удалить из библиотеки",
+ "local_tab": "Местный",
+ "stats": "Статистика"
}
\ No newline at end of file
diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb
new file mode 100644
index 00000000..60ced74b
--- /dev/null
+++ b/lib/l10n/app_th.arb
@@ -0,0 +1,330 @@
+{
+ "guest": "ผู้มาเยือน",
+ "browse": "เรียกดู",
+ "search": "ค้นหา",
+ "library": "คลัง",
+ "lyrics": "เนื้อเพลง",
+ "settings": "ตั้งค่า",
+ "genre_categories_filter": "กรองประเภทหรือแนวเพลง...",
+ "genre": "ประเภท",
+ "personalized": "ปรับแต่ง",
+ "featured": "เด่น",
+ "new_releases": "เพิ่งปล่อยใหม่",
+ "songs": "เพลง",
+ "playing_track": "กำลังเล่น {track}",
+ "queue_clear_alert": "การดำเนินการนี้จะล้างคิวปัจจุบัน {track_length} แทร็ก จะถูกลบออก\nคุณต้องการดำเนินการต่อหรือไม่?",
+ "load_more": "โหลดเพิ่มเติม",
+ "playlists": "เพลย์ลิสต์",
+ "artists": "ศิลปิน",
+ "albums": "อัลบั้ม",
+ "tracks": "แทร็ก",
+ "downloads": "ดาวน์โหลด",
+ "filter_playlists": "กรองเพลย์ลิสต์...",
+ "liked_tracks": "เพลงที่ชอบ",
+ "liked_tracks_description": "เพลงที่คุณชื่นชอบทั้งหมด",
+ "create_playlist": "สร้างเพลย์ลิสต์",
+ "create_a_playlist": "สร้างเพลย์ลิสต์",
+ "update_playlist": "อัพเดทเพลย์ลิสต์",
+ "create": "สร้าง",
+ "cancel": "ยกเลิก",
+ "update": "อัพเดท",
+ "playlist_name": "ชื่อเพลย์ลิสต์",
+ "name_of_playlist": "ชื่อของเพลย์ลิสต์",
+ "description": "คำอธิบาย",
+ "public": "สาธารณะ",
+ "collaborative": "ร่วมมือกัน",
+ "search_local_tracks": "ค้นหาเพลงในเครื่อง...",
+ "play": "เล่น",
+ "delete": "ลบ",
+ "none": "ไม่มี",
+ "sort_a_z": "เรียงตาม A-Z",
+ "sort_z_a": "เรียงตาม Z-A",
+ "sort_artist": "เรียงตามศิลปิน",
+ "sort_album": "เรียงตามอัลบั้ม",
+ "sort_duration": "เรียงตามความยาว",
+ "sort_tracks": "เรียงตามเพลง",
+ "currently_downloading": "กำลังดาวน์โหลด ({tracks_length})",
+ "cancel_all": "ยกเลิกทั้งหมด",
+ "filter_artist": "กรองศิลปิน...",
+ "followers": "{followers} ผู้ติดตาม",
+ "add_artist_to_blacklist": "เพิ่มศิลปินในบัญชีดำ",
+ "top_tracks": "เพลงฮิต",
+ "fans_also_like": "แฟนๆ ยังชอบ",
+ "loading": "กำลังโหลด...",
+ "artist": "ศิลปิน",
+ "blacklisted": "อยู่ในบัญชีดำ",
+ "following": "กำลังติดตาม",
+ "follow": "ติดตาม",
+ "artist_url_copied": "คัดลอก URL ศิลปินไปยังคลิปบอร์ด",
+ "added_to_queue": "เพิ่ม {tracks} เพลงลงในคิว",
+ "filter_albums": "กรองอัลบั้ม...",
+ "synced": "ซิงค์",
+ "plain": "เรียบง่าย",
+ "shuffle": "สุ่ม",
+ "search_tracks": "ค้นหาเพลง...",
+ "released": "เผยแพร่",
+ "error": "ข้อผิดพลาด {error}",
+ "title": "ชื่อ",
+ "time": "เวลา",
+ "more_actions": "เพิ่มเติม",
+ "download_count": "ดาวน์โหลด ({count})",
+ "add_count_to_playlist": "เพิ่ม ({count}) ลงในเพลย์ลิสต์",
+ "add_count_to_queue": "เพิ่ม ({count}) ลงในคิว",
+ "play_count_next": "เล่น ({count}) ต่อไป",
+ "album": "อัลบั้ม",
+ "copied_to_clipboard": "คัดลอก {data} ไปยังคลิปบอร์ด",
+ "add_to_following_playlists": "เพิ่ม {track} ลงในเพลย์ลิสต์",
+ "add": "เพิ่ม",
+ "added_track_to_queue": "เพิ่ม {track} ลงในคิว",
+ "add_to_queue": "เพิ่มลงในคิว",
+ "track_will_play_next": "{track} จะเล่นต่อไป",
+ "play_next": "เล่นต่อไป",
+ "removed_track_from_queue": "ลบ {track} ออกจากคิว",
+ "remove_from_queue": "ลบออกจากคิว",
+ "remove_from_favorites": "ลบออกจากรายการโปรด",
+ "save_as_favorite": "บันทึกเป็นรายการโปรด",
+ "add_to_playlist": "เพิ่มลงในเพลย์ลิสต์",
+ "remove_from_playlist": "ลบออกจากเพลย์ลิสต์",
+ "add_to_blacklist": "เพิ่มลงในบัญชีดำ",
+ "remove_from_blacklist": "ลบออกจากบัญชีดำ",
+ "share": "แชร์",
+ "mini_player": "มินิเพลเยอร์",
+ "slide_to_seek": "เลื่อนเพื่อไปข้างหน้าหรือถอยหลัง",
+ "shuffle_playlist": "สุ่มเพลย์ลิสต์",
+ "unshuffle_playlist": "ยกเลิกการสุ่มเพลย์ลิสต์",
+ "previous_track": "แทร็กก่อนหน้า",
+ "next_track": "แทร็กถัดไป",
+ "pause_playback": "หยุดการเล่น",
+ "resume_playback": "เล่นต่อ",
+ "loop_track": "วนเพลง",
+ "repeat_playlist": "ซ้ำเพลย์ลิสต์",
+ "queue": "คิว",
+ "alternative_track_sources": "แหล่งแทร็กอื่น",
+ "download_track": "ดาวน์โหลดแทร็ก",
+ "tracks_in_queue": "{tracks} แทร็กในคิว",
+ "clear_all": "ล้างทั้งหมด",
+ "show_hide_ui_on_hover": "แสดง/ซ่อน UI เมื่อโฮเวอร์",
+ "always_on_top": "อยู่ด้านบนเสมอ",
+ "exit_mini_player": "ออกจากมินิเพลย์เยอร์",
+ "download_location": "ตำแหน่งดาวน์โหลด",
+ "account": "บัญชี",
+ "login_with_spotify": "เข้าสู่ระบบด้วยบัญชี Spotify",
+ "connect_with_spotify": "เชื่อมต่อกับ Spotify",
+ "logout": "ออกจากระบบ",
+ "logout_of_this_account": "ออกจากระบบบัญชีนี้",
+ "language_region": "ภาษาและภูมิภาค",
+ "language": "ภาษา",
+ "system_default": "ค่าเริ่มต้นของระบบ",
+ "market_place_region": "ภูมิภาค Marketplace",
+ "recommendation_country": "ประเทศที่แนะนำ",
+ "appearance": "ลักษณะที่ปรากฏ",
+ "layout_mode": "โหมดเค้าโครง",
+ "override_layout_settings": "แทนที่การตั้งค่าโหมดเค้าโครงแบบตอบสนอง",
+ "adaptive": "ปรับเปลี่ยน",
+ "compact": "กระชับ",
+ "extended": "ขยาย",
+ "theme": "ธีม",
+ "dark": "มืด",
+ "light": "สว่าง",
+ "system": "ระบบ",
+ "accent_color": "สีเน้น",
+ "sync_album_color": "ซิงค์สีอัลบั้ม",
+ "sync_album_color_description": "ใช้สีเด่นของอาร์ตอัลบั้มเป็นสีเน้น",
+ "playback": "การเล่น",
+ "audio_quality": "คุณภาพเสียง",
+ "high": "สูง",
+ "low": "ต่ำ",
+ "pre_download_play": "ดาวน์โหลดล่วงหน้าและเล่น",
+ "pre_download_play_description": "แทนที่จะสตรีมเสียง ดาวน์โหลดข้อมูลและเล่นแทน (แนะนำสำหรับผู้ใช้แบนด์วิดธ์สูง)",
+ "skip_non_music": "ข้ามส่วนที่ไม่ใช่เพลง (SponsorBlock)",
+ "blacklist_description": "แทร็กและศิลปินที่บล็อก",
+ "wait_for_download_to_finish": "โปรดรอให้การดาวน์โหลดปัจจุบันเสร็จสิ้น",
+ "desktop": "เดสก์ท็อป",
+ "close_behavior": "ปิดพฤติกรรม",
+ "close": "ปิด",
+ "minimize_to_tray": "ลดขนาดลงถาด",
+ "show_tray_icon": "แสดงไอคอนถาดระบบ",
+ "about": "เกี่ยวกับ",
+ "u_love_spotube": "เรารู้ว่าคุณรัก Spotube",
+ "check_for_updates": "ตรวจสอบการปรับปรุง",
+ "about_spotube": "เกี่ยวกับ Spotube",
+ "blacklist": "แบล็กลิสต์",
+ "please_sponsor": "กรุณาสนับสนุน/บริจาค",
+ "spotube_description": "Spotube โปรแกรมเล่น Spotify ฟรีสำหรับทุกคน น้ำหนักเบา รองรับหลายแพลตฟอร์ม",
+ "version": "รุ่น",
+ "build_number": "หมายเลขบิลด์",
+ "founder": "ผู้ก่อตั้ง",
+ "repository": "ที่เก็บ",
+ "bug_issues": "ข้อผิดพลาด+ปัญหา",
+ "made_with": "ทำด้วย❤️ใน บังคลาเทศ🇧🇩",
+ "kingkor_roy_tirtho": "Kingkor Roy Tirtho",
+ "copyright": "© 2021-{current_year} Kingkor Roy Tirtho",
+ "license": "ใบอนุญาต",
+ "add_spotify_credentials": "เพิ่มข้อมูลรับรอง Spotify ของคุณเพื่อเริ่มต้น",
+ "credentials_will_not_be_shared_disclaimer": "ไม่ต้องกังวล ข้อมูลรับรองใดๆ ของคุณจะไม่ถูกเก็บรวบรวมหรือแชร์กับใคร",
+ "know_how_to_login": "ไม่รู้จักวิธีดำเนินการนี้ใช่ไหม",
+ "follow_step_by_step_guide": "ทำตามคู่มือทีละขั้น",
+ "spotify_cookie": "คุกกี้ Spotify {name}",
+ "cookie_name_cookie": "คุกกี้ {name}",
+ "fill_in_all_fields": "กรุณากรอกข้อมูลทุกช่อง",
+ "submit": "ยื่น",
+ "exit": "ออก",
+ "previous": "ย้อนกลับ",
+ "next": "ถัดไป",
+ "done": "เสร็จ",
+ "step_1": "ขั้นที่ 1",
+ "first_go_to": "ก่อนอื่น ไปที่",
+ "login_if_not_logged_in": "ยังไม่ได้เข้าสู่ระบบ ให้เข้าสู่ระบบ/ลงทะเบียน",
+ "step_2": "ขั้นที่ 2",
+ "step_2_steps": "1. หลังจากเข้าสู่ระบบแล้ว กด F12 หรือ คลิกขวาที่เมาส์ > ตรวจสอบเพื่อเปิด Devtools เบราว์เซอร์\n2. จากนั้นไปที่แท็บ \"แอปพลิเคชัน\" (Chrome, Edge, Brave เป็นต้น) หรือแท็บ \"ที่เก็บข้อมูล\" (Firefox, Palemoon เป็นต้น)\n3. ไปที่ส่วน \"คุกกี้\" แล้วไปที่ subsection \"https: //accounts.spotify.com\"",
+ "step_3": "ขั้นที่ 3",
+ "step_3_steps": "คัดลอกค่าคุกกี้ \"sp_dc\"",
+ "success_emoji": "สำเร็จ",
+ "success_message": "ตอนนี้คุณเข้าสู่ระบบด้วยบัญชี Spotify ของคุณเรียบร้อยแล้ว ยอดเยี่ยม!",
+ "step_4": "ขั้นที่ 4",
+ "step_4_steps": "วางค่า \"sp_dc\" ที่คัดลอกมา",
+ "something_went_wrong": "มีอะไรผิดพลาด",
+ "piped_instance": "อินสแตนซ์เซิร์ฟเวอร์แบบ Pipe",
+ "piped_description": "อินสแตนซ์เซิร์ฟเวอร์แบบ Pipe ที่ใช้สำหรับการจับคู่แทร็ก",
+ "piped_warning": "บางอย่างอาจใช้งานไม่ได้ผล คุณจึงต้องรับความเสี่ยงเอง",
+ "generate_playlist": "สร้างเพลย์ลิสต์",
+ "track_exists": "แทร็ก {track} มีอยู่แล้ว",
+ "replace_downloaded_tracks": "แทนที่แทร็กที่ดาวน์โหลดทั้งหมด",
+ "skip_download_tracks": "ข้ามการดาวน์โหลดแทร็กที่ดาวน์โหลดทั้งหมด",
+ "do_you_want_to_replace": "คุณต้องการแทนที่แทร็กที่มีอยู่หรือไม่",
+ "replace": "แทนที่",
+ "skip": "ข้าม",
+ "select_up_to_count_type": "เลือกสูงสุด {count} {type}",
+ "select_genres": "เลือกประเภท",
+ "add_genres": "เพิ่มประเภท",
+ "country": "ประเทศ",
+ "number_of_tracks_generate": "จำนวนแทร็กที่จะสร้าง",
+ "acousticness": "อะคูสติก",
+ "danceability": "ความสามารถในการเต้น",
+ "energy": "พลัง",
+ "instrumentalness": "บรรเลง",
+ "liveness": "ความสด",
+ "loudness": "ความดัง",
+ "speechiness": "การพูด",
+ "valence": "ความสุข",
+ "popularity": "ความนิยม",
+ "key": "คีย์",
+ "duration": "ระยะเวลา (วินาที)",
+ "tempo": "ความเร็ว (BPM)",
+ "mode": "โหมด",
+ "time_signature": "ลายเซ็นเวลา",
+ "short": "สั้น",
+ "medium": "กลาง",
+ "long": "ยาว",
+ "min": "ต่ำสุด",
+ "max": "สูงสุด",
+ "target": "เป้าหมาย",
+ "moderate": "ปานกลาง",
+ "deselect_all": "ยกเลิกการเลือกทั้งหมด",
+ "select_all": "เลือกทั้งหมด",
+ "are_you_sure": "คุณแน่ใจไหม?",
+ "generating_playlist": "กำลังสร้างเพลย์ลิสต์ที่คุณกำหนดเอง...",
+ "selected_count_tracks": "เลือก {count} แทร็ก",
+ "download_warning": "ถ้าคุณดาวน์โหลดเพลงทั้งหมดเป็นจำนวนมาก คุณกำลังละเมิดลิขสิทธิ์เพลงและสร้างความเสียหายให้กับสังคมดนตรี สร้างสรรค์ หวังว่าคุณจะรับรู้เรื่องนี้ เสมอ พยายามเคารพและสนับสนุนผลงานหนักของศิลปิน",
+ "download_ip_ban_warning": "นอกเหนือจากนั้น IP ของคุณอาจถูกบล็อกบน YouTube เนื่องจากคำขอดาวน์โหลดมากเกินกว่าปกติ การบล็อก IP หมายความว่าคุณไม่สามารถใช้ YouTube (แม้ว่าคุณจะล็อกอินอยู่) เป็นเวลาอย่างน้อย 2-3 เดือนจากอุปกรณ์ IP นั้น และ Spotube จะไม่รับผิดชอบใด ๆ หากสิ่งนี้เกิดขึ้น",
+ "by_clicking_accept_terms": "คลิก 'ยอมรับ' คุณยินยอมตามเงื่อนไขต่อไปนี้:",
+ "download_agreement_1": "ฉันรู้ว่าฉันกำลังละเมิดลิขสิทธิ์เพลง ฉันเลว",
+ "download_agreement_2": "ฉันจะสนับสนุนศิลปินทุกที่ที่ฉันทำได้และฉันทำสิ่งนี้เพียงเพราะฉันไม่มีเงินซื้อผลงานศิลปะของพวกเขา",
+ "download_agreement_3": "ฉันรับทราบอย่างสมบูรณ์ว่า IP ของฉันอาจถูกบล็อกบน YouTube และฉันจะไม่ถือ Spotube หรือเจ้าของ/ผู้มีส่วนร่วมใด ๆ รับผิดชอบต่ออุบัติเหตุใด ๆ ที่เกิดจากการกระทำปัจจุบันของฉัน",
+ "decline": "ปฏิเสธ",
+ "accept": "ยอมรับ",
+ "details": "รายละเอียด",
+ "youtube": "youtube",
+ "channel": "ช่อง",
+ "likes": "ถูกใจ",
+ "dislikes": "ไม่ชอบ",
+ "views": "วิว",
+ "streamUrl": "สตรีม URL",
+ "stop": "หยุด",
+ "sort_newest": "เรียงตามการเพิ่มใหม่ล่าสุด",
+ "sort_oldest": "เรียงตามการเพิ่มเก่าสุด",
+ "sleep_timer": "ตั้งเวลาปิด",
+ "mins": "{minutes} นาที",
+ "hours": "{hours} ชั่วโมง",
+ "hour": "{hours} ชั่วโมง",
+ "custom_hours": "ชั่วโมงที่กำหนดเอง",
+ "logs": "บันทึก",
+ "developers": "นักพัฒนา",
+ "not_logged_in": "คุณไม่ได้เข้าสู่ระบบ",
+ "search_mode": "โหมดการค้นหา",
+ "audio_source": "แหล่งที่มาของเสียง",
+ "ok": "ตกลง",
+ "failed_to_encrypt": "เข้ารหัสล้มเหลว",
+ "encryption_failed_warning": "Spotube ใช้การเข้ารหัสเพื่อเก็บข้อมูลของคุณอย่างปลอดภัย แต่ไม่สามารถทำได้ ดังนั้นจะเปลี่ยนเป็นการจัดเก็บที่ไม่ปลอดภัย\nหากคุณใช้ Linux โปรดตรวจสอบว่าคุณได้ติดตั้งบริการลับ (gnome-keyring, kde-wallet, keepassxc เป็นต้น)",
+ "querying_info": "กำลังดึงข้อมูล...",
+ "piped_api_down": "Piped API ไม่ทำงาน",
+ "piped_down_error_instructions": "Piped instance {pipedInstance} ไม่ทำงานขณะนี้\n\nเปลี่ยนอินสแตนซ์หรือเปลี่ยน 'ประเภท API' เป็น YouTube API อย่างเป็นทางการ\n\nอย่าลืมรีสตาร์ทแอปหลังจากเปลี่ยน",
+ "you_are_offline": "คุณออฟไลน์อยู่",
+ "connection_restored": "การเชื่อมต่ออินเทอร์เน็ตของคุณได้รับการกู้คืน",
+ "use_system_title_bar": "ใช้แถบชื่อระบบ",
+ "crunching_results": "กำลังประมวลผล...",
+ "search_to_get_results": "ค้นหาเพื่อดูผลลัพธ์",
+ "use_amoled_mode": "ธีมมืดสนิท",
+ "pitch_dark_theme": "โหมด AMOLED",
+ "normalize_audio": "ปรับระดับเสียง",
+ "change_cover": "เปลี่ยนปก",
+ "add_cover": "เพิ่มปก",
+ "restore_defaults": "คืนค่าเริ่มต้น",
+ "download_music_codec": "ดาวน์โหลดโคเดคเพลง",
+ "streaming_music_codec": "สตรีมมิ่งโคเดคเพลง",
+ "login_with_lastfm": "เข้าสู่ระบบด้วย Last.fm",
+ "connect": "เชื่อมต่อ",
+ "disconnect_lastfm": "ตัดการเชื่อมต่อ Last.fm",
+ "disconnect": "ตัดการเชื่อมต่อ",
+ "username": "ชื่อผู้ใช้",
+ "password": "รหัสผ่าน",
+ "login": "เข้าสู่ระบบ",
+ "login_with_your_lastfm": "เข้าสู่ระบบด้วย Last.fm",
+ "scrobble_to_lastfm": "Scrobble ไปเป็น Last.fm",
+ "go_to_album": "ไปที่อัลบั้ม",
+ "discord_rich_presence": "Discord Rich Presence",
+ "browse_all": "เรียกดูทั้งหมด",
+ "genres": "ประเภท",
+ "explore_genres": "สำรวจประเภท",
+ "friends": "เพื่อน",
+ "no_lyrics_available": "ขออภัย ไม่พบเนื้อเพลงสำหรับเพลงนี้",
+ "start_a_radio": "เปิดวิทยุ",
+ "how_to_start_radio": "หากต้องการเปิดวิทยุฟังยังไง?",
+ "replace_queue_question": "คุณต้องการแทนที่คิวปัจจุบันหรือเพิ่มเข้าไปหรือไม่",
+ "endless_playback": "เล่นซ้ำ",
+ "delete_playlist": "ลบเพลย์ลิสต์",
+ "delete_playlist_confirmation": "คุณแน่ใจที่จะลบเพลย์ลิสต์นี้หรือไม่",
+ "local_tracks": "เพลงในเครื่อง",
+ "song_link": "ลิงค์เพลง",
+ "skip_this_nonsense": "ข้ามสิ่งไร้สาระนี้",
+ "freedom_of_music": "“เสรีภาพแห่งเสียงเพลง”",
+ "freedom_of_music_palm": "“เสรีภาพแห่งเสียงเพลง ในมือของคุณ”",
+ "get_started": "เริ่มต้น",
+ "youtube_source_description": "แนะนำและใช้งานได้ดีที่สุด",
+ "piped_source_description": "รู้สึกอิสระ? เหมือน YouTube แต่ฟรีกว่าเยอะ",
+ "jiosaavn_source_description": "ดีที่สุดสำหรับภูมิภาคเอเชียใต้",
+ "highest_quality": "คุณภาพสูงสุด: {quality}",
+ "select_audio_source": "เลือกแหล่งเสียง",
+ "endless_playback_description": "เพิ่มเพลงใหม่ลงในคิวโดยอัตโนมัติ",
+ "choose_your_region": "เลือกภูมิภาคของคุณ",
+ "choose_your_region_description": "สิ่งนี้จะช่วยให้ Spotube แสดงเนื้อหาที่เหมาะสมสำหรับคุณ",
+ "choose_ your_language": "เลือกภาษาของคุณ",
+ "help_project_grow": "ช่วยให้โครงการนี้เติบโต",
+ "help_project_grow_description": "Spotube เป็นโครงการโอเพนซอร์ส คุณสามารถช่วยให้โครงการนี้เติบโตได้โดยการมีส่วนร่วมในโครงการ รายงานข้อบกพร่อง หรือเสนอคุณสมบัติใหม่",
+ "contribute_on_github": "มีส่วนร่วมบน GitHub",
+ "donate_on_open_collective": "บริจาคบน Open Collective",
+ "browse_anonymously": "เรียกดูแบบไม่ระบุตัวตน",
+ "choose_your_language": "เลือกภาษาของคุณ",
+ "enable_connect": "เปิดใช้งานการเชื่อมต่อ",
+ "enable_connect_description": "ควบคุม Spotube จากอุปกรณ์อื่น",
+ "devices": "อุปกรณ์",
+ "select": "เลือก",
+ "connect_client_alert": "คุณกำลังถูกควบคุมโดย {client}",
+ "this_device": "อุปกรณ์นี้",
+ "remote": "ระยะไกล",
+ "local_library": "ห้องสมุดท้องถิ่น",
+ "add_library_location": "เพิ่มในห้องสมุด",
+ "remove_library_location": "ลบออกจากห้องสมุด",
+ "local_tab": "ท้องถิ่น",
+ "stats": "สถิติ"
+}
\ No newline at end of file
diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb
index 94800023..b329cfa7 100644
--- a/lib/l10n/app_tr.arb
+++ b/lib/l10n/app_tr.arb
@@ -1,63 +1,64 @@
{
"guest": "Misafir",
- "browse": "Gözat",
+ "browse": "Göz at",
"search": "Ara",
"library": "Kütüphane",
- "lyrics": "Sözler",
+ "lyrics": "Şarkı sözleri",
"settings": "Ayarlar",
"genre_categories_filter": "Kategorileri veya türleri filtreleyin...",
"genre": "Tür",
"personalized": "Kişiselleştirilmiş",
- "featured": "Öne Çıkanlar",
- "new_releases": "Yeni Çıkanlar",
+ "featured": "Öne çıkanlar",
+ "new_releases": "Yeni çıkanlar",
"songs": "Şarkılar",
- "playing_track": "Oynatılıyor {track}",
- "queue_clear_alert": "Bu, mevcut kuyruğu temizleyecektir. {track_length} parçaları kaldırılacaktır\nDevam etmek istiyor musunuz?",
+ "playing_track": "{track} oynatılıyor",
+ "queue_clear_alert": "Bu, mevcut kuyruğu temizleyecektir. {track_length} parça kaldırılacak\nDevam etmek istiyor musunuz?",
"load_more": "Daha fazlasını yükle",
- "playlists": "Çalma Listeleri",
+ "playlists": "Oynatma listeleri",
"artists": "Sanatçılar",
"albums": "Albümler",
"tracks": "Parçalar",
- "downloads": "İndirmeler",
- "filter_playlists": "Çalma listelerinizi filtreleyin...",
- "liked_tracks": "Beğenilen Parçalar",
+ "downloads": "İndirilenler",
+ "filter_playlists": "Oynatma listelerinizi filtreleyin...",
+ "liked_tracks": "Beğenilen parçalar",
"liked_tracks_description": "Beğendiğiniz tüm parçalar",
- "create_playlist": "Çalma Listesi Oluştur",
- "create_a_playlist": "Bir çalma listesi oluştur",
- "update_playlist": "Çalma listesini güncelle",
+ "create_playlist": "Oynatma listesi oluştur",
+ "create_a_playlist": "Bir oynatma listesi oluştur",
+ "update_playlist": "Oynatma listesini güncelle",
"create": "Oluştur",
"cancel": "İptal",
"update": "Güncelle",
- "playlist_name": "Çalma Listesi Adı",
- "name_of_playlist": "Çalma listesi adı",
+ "playlist_name": "Oynatma listesi adı",
+ "name_of_playlist": "Oynatma listesinin adı",
"description": "Açıklama",
"public": "Halka açık",
"collaborative": "İşbirliği",
- "search_local_tracks": "Yerel parçaları arayın...",
+ "search_local_tracks": "Yerel parçaları ara...",
"play": "Oynat",
"delete": "Sil",
- "none": "Hiçbiri",
- "sort_a_z": "A'dan Z'ye sırala",
- "sort_z_a": "Z'dan A'ye sırala",
- "sort_artist": "Sanatçıya Göre Sırala",
- "sort_album": "Albüme Göre Sırala",
- "sort_tracks": "Parçaları Sırala",
- "currently_downloading": "Şu Anda İndiriliyor ({tracks_length})",
- "cancel_all": "Tümünü İptal Et",
- "filter_artist": "Sanatçıları filtrele...",
+ "none": "Yok",
+ "sort_a_z": "A - Z'ye göre sırala",
+ "sort_z_a": "Z - A'ya göre sırala",
+ "sort_artist": "Sanatçıya göre sırala",
+ "sort_album": "Albüme göre sırala",
+ "sort_duration": "Süreye göre sırala",
+ "sort_tracks": "Parçaları sırala",
+ "currently_downloading": "Şu anda indirilenler ({tracks_length})",
+ "cancel_all": "Tümünü iptal et",
+ "filter_artist": "Sanatçıları filtreleyin...",
"followers": "{followers} Takipçiler",
"add_artist_to_blacklist": "Sanatçıyı kara listeye ekle",
- "top_tracks": "En İyi Parçalar",
- "fans_also_like": "Hayranlar ayrıca şunları beğendi",
+ "top_tracks": "En iyi parçalar",
+ "fans_also_like": "Hayranlar ayrıca şunları da beğendi",
"loading": "Yükleniyor...",
"artist": "Sanatçı",
- "blacklisted": "Kara Listede",
- "following": "Takip Ediliyor",
- "follow": "Takip Et",
+ "blacklisted": "Kara listeye alındı",
+ "following": "Takip ediliyor",
+ "follow": "Takip et",
"artist_url_copied": "Sanatçı bağlantısı panoya kopyalandı",
- "added_to_queue": "Kuyruğa {tracks} parçaları eklendi",
- "filter_albums": "Albümleri filtrele...",
- "synced": "Eşitlendi",
+ "added_to_queue": "Kuyruğa {tracks} parçası eklendi",
+ "filter_albums": "Albümleri filtreleyin...",
+ "synced": "Senkronize edildi",
"plain": "Sade",
"shuffle": "Karıştır",
"search_tracks": "Parça ara...",
@@ -65,151 +66,153 @@
"error": "Hata {error}",
"title": "Başlık",
"time": "Zaman",
- "more_actions": "Daha fazla işlem",
+ "more_actions": "Daha fazla eylem",
"download_count": "İndir ({count})",
- "add_count_to_playlist": "Çalma Listesine ({count}) Ekle",
- "add_count_to_queue": "Sıraya ({count}) ekle",
- "play_count_next": "Oynat ({count}) sonraki",
+ "add_count_to_playlist": "Oynatma Listesine ekle ({count})",
+ "add_count_to_queue": "Kuyruğa ekle ({count})",
+ "play_count_next": "Sonrakini oynat ({count})",
"album": "Albüm",
- "copied_to_clipboard": "Panoya {data} kopyalandı",
- "add_to_following_playlists": "Aşağıdaki Çalma Listelerine {track} ekle",
+ "copied_to_clipboard": "{data} panoya kopyalandı",
+ "add_to_following_playlists": "{track} parçasını aşağıdaki oynatma listelerine ekle",
"add": "Ekle",
- "added_track_to_queue": "Sıraya {track} eklendi",
+ "added_track_to_queue": "{track} kuyruğa eklendi",
"add_to_queue": "Kuyruğa ekle",
- "track_will_play_next": "{track} sonraki çalacak",
- "play_next": "Sıradaki",
- "removed_track_from_queue": "Sıradan {track} kaldırıldı",
- "remove_from_queue": "Kuyruktan çıkar",
+ "track_will_play_next": "{track} bir sonraki çalacak",
+ "play_next": "Sonrakini oynat",
+ "removed_track_from_queue": "{track} kuyruktan kaldırıldı",
+ "remove_from_queue": "Kuyruktan kaldır",
"remove_from_favorites": "Favorilerden kaldır",
"save_as_favorite": "Favori olarak kaydet",
- "add_to_playlist": "Çalma listesine ekle",
- "remove_from_playlist": "Çalma listesinden kaldır",
+ "add_to_playlist": "Oynatma listesine ekle",
+ "remove_from_playlist": "Oynatma listesinden kaldır",
"add_to_blacklist": "Kara listeye ekle",
- "remove_from_blacklist": "Kara listeden çıkar",
+ "remove_from_blacklist": "Kara listeden kaldır",
"share": "Paylaş",
- "mini_player": "Mini Oynatıcı",
+ "mini_player": "Mini oynatıcı",
"slide_to_seek": "İleri veya geri arama yapmak için kaydırın",
- "shuffle_playlist": "Çalma listesini karıştır",
- "unshuffle_playlist": "Karışık çalma listesi",
+ "shuffle_playlist": "Oynatma listesini karıştır",
+ "unshuffle_playlist": "Oynatma listesinin karışıklığını kaldır",
"previous_track": "Önceki parça",
"next_track": "Sonraki parça",
- "pause_playback": "Çalmayı Duraklat",
- "resume_playback": "Çalmaya Devam Et",
+ "pause_playback": "Oynatmayı duraklat",
+ "resume_playback": "Oynatmayı sürdür",
"loop_track": "Döngü parçası",
- "repeat_playlist": "Çalma listesini tekrarla",
- "queue": "Sıra",
+ "repeat_playlist": "Oynatma listesini tekrarla",
+ "queue": "Kuyruk",
"alternative_track_sources": "Alternatif parça kaynakları",
"download_track": "Parçayı indir",
- "tracks_in_queue": "{tracks} sıradaki parçalar",
+ "tracks_in_queue": "{tracks} parça kuyrukta",
"clear_all": "Tümünü temizle",
- "show_hide_ui_on_hover": "Üzerine gelindiğinde kullanıcı arayüzünü göster/gizle",
- "always_on_top": "Her zaman en üstte",
+ "show_hide_ui_on_hover": "Fareyle üzerine gelindiğinde kullanıcı arayüzünü göster/gizle",
+ "always_on_top": "Her zaman üstte",
"exit_mini_player": "Mini oynatıcıdan çık",
"download_location": "İndirme konumu",
"account": "Hesap",
- "login_with_spotify": "Spotify hesabınız ile giriş yapın",
- "connect_with_spotify": "Spotify ile bağlantı kurun",
- "logout": "Çıkış Yap",
- "logout_of_this_account": "Bu hesaptan çıkış yap",
- "language_region": "Dil & Bölge",
- "language": "Dil",
- "system_default": "Sistem Varsayılanı",
- "market_place_region": "Mevcut Bölge",
- "recommendation_country": "Tavsiye Edilen Ülke",
+ "login_with_spotify": "Spotify hesabı ile giriş yap",
+ "connect_with_spotify": "Spotify ile bağlan",
+ "logout": "Çıkış yap",
+ "logout_of_this_account": "Hesaptan çıkış yap",
+ "language_region": "Dil ve bölge",
+ "language": "Tercih edilen dil",
+ "system_default": "Sistem varsayılanı",
+ "market_place_region": "Tercih edilen bölge",
+ "recommendation_country": "Tavsiye edilen ülke",
"appearance": "Görünüm",
- "layout_mode": "Düzen Modu",
+ "layout_mode": "Düzen modu",
"override_layout_settings": "Duyarlı düzen modu ayarlarını geçersiz kıl",
"adaptive": "Uyarlanabilir",
"compact": "Sıkıştırılmış",
"extended": "Genişletilmiş",
"theme": "Tema",
- "dark": "Karanlık",
- "light": "Aydınlık",
+ "dark": "Koyu",
+ "light": "Açık",
"system": "Sistem",
- "accent_color": "Vurgu Rengi",
- "sync_album_color": "Albüm rengini eşitle",
- "sync_album_color_description": "Albüm resminin baskın rengini vurgu rengi olarak kullanır",
- "playback": "Çalma",
- "audio_quality": "Ses Kalitesi",
+ "accent_color": "Vurgu rengi",
+ "sync_album_color": "Albüm rengini senkronize et",
+ "sync_album_color_description": "Vurgu rengi olarak albüm resminin baskın rengini kullanır",
+ "playback": "Oynatma",
+ "audio_quality": "Ses kalitesi",
"high": "Yüksek",
"low": "Düşük",
"pre_download_play": "Önceden indir ve oynat",
- "pre_download_play_description": "Ses akışı yerine, baytları indirin ve oynatın (Daha yüksek bant genişliği kullanıcıları için önerilir)",
- "skip_non_music": "Müzik olmayan bölümleri atla (SponsorBlock)",
+ "pre_download_play_description": "Ses akışı yerine baytları indir ve oynat (Daha yüksek bant genişliğine sahip kullanıcılar için önerilir)",
+ "skip_non_music": "Müzik olmayan bölümleri atlat (SponsorBlock)",
"blacklist_description": "Kara listeye alınan parçalar ve sanatçılar",
- "wait_for_download_to_finish": "Lütfen mevcut indirme işleminin bitmesini bekleyin",
+ "wait_for_download_to_finish": "Lütfen mevcut indirme işleminin tamamlanmasını bekleyin",
"desktop": "Masaüstü",
- "close_behavior": "Yakın Davranış",
+ "close_behavior": "Kapatma davranışı",
"close": "Kapat",
"minimize_to_tray": "Tepsiye küçült",
"show_tray_icon": "Sistem tepsisi simgesini göster",
"about": "Hakkında",
"u_love_spotube": "Spotube'u sevdiğinizi biliyoruz",
"check_for_updates": "Güncellemeleri kontrol et",
- "about_spotube": "Spotube Hakkında",
- "blacklist": "Kara Liste",
- "please_sponsor": "Lütfen Sponsor Olun/Bağış Yapın",
- "spotube_description": "Spotube, hafif, platformlar arası, herkesin kullanabileceği ücretsiz bir Spotify istemcisidir.",
+ "about_spotube": "Spotube hakkında",
+ "blacklist": "Kara liste",
+ "please_sponsor": "Sponsor Ol/Bağış Yap",
+ "spotube_description": "Spotube, hafif, platformlar arası uyumlu ve herkes için ücretsiz bir Spotify istemcisidir.",
"version": "Sürüm",
- "build_number": "Derleme Numarası",
- "founder": "Kurucu",
+ "build_number": "Derleme numarası",
+ "founder": "Geliştirici",
"repository": "Depo",
"bug_issues": "Hata + Sorunlar",
- "made_with": "❤️ ile Bangladesh🇧🇩 adresinde yapılmıştır.",
+ "made_with": "❤️ ile Bangladeş'te yapıldı",
"kingkor_roy_tirtho": "Kingkor Roy Tirtho",
"copyright": "© 2021-{current_year} Kingkor Roy Tirtho",
"license": "Lisans",
- "add_spotify_credentials": "Başlamak için spotify bilgilerinizi ekleyin",
- "credentials_will_not_be_shared_disclaimer": "Endişelenmeyin, bilgileriniz toplanmayacak veya kimseyle paylaşılmayacak",
- "know_how_to_login": "Nasıl yapılacağını bilmiyor musunuz?",
- "follow_step_by_step_guide": "Adım Adım kılavuzu takip edin",
- "spotify_cookie": "Spotify {name} Çerez",
- "cookie_name_cookie": "{name} Çerez",
+ "add_spotify_credentials": "Başlamak için spotify kimlik bilgilerinizi ekleyin",
+ "credentials_will_not_be_shared_disclaimer": "Endişelenmeyin, kimlik bilgilerinizden hiçbiri toplanmayacak veya kimseyle paylaşılmayacak",
+ "know_how_to_login": "Bunu nasıl yapacağınızı bilmiyor musunuz?",
+ "follow_step_by_step_guide": "Adım adım kılavuzu takip edin",
+ "spotify_cookie": "Spotify {name} çerezi",
+ "cookie_name_cookie": "{name} çerezi",
"fill_in_all_fields": "Lütfen tüm alanları doldurun",
- "submit": "Gönder",
+ "submit": "Başvur",
"exit": "Çık",
"previous": "Önceki",
"next": "Sonraki",
"done": "Bitti",
"step_1": "1. Adım",
- "first_go_to": "İlk önce şu adrese gidin",
- "login_if_not_logged_in": "ve oturum açmadıysanız Giriş Yapın/Kaydolun",
+ "first_go_to": "İlk olarak şuraya gidin:",
+ "login_if_not_logged_in": "ve oturum açmadıysanız Oturum açın/Kaydolun",
"step_2": "2. Adım",
- "step_2_steps": "1. Giriş yaptıktan sonra, Tarayıcı devtools.\n2'yi açmak için F12'ye basın veya Fare Sağ Tıklaması > İncele'ye basın. Ardından \"Uygulama\" Sekmesine (Chrome, Edge, Brave vb.) veya \"Depolama\" Sekmesine (Firefox, Palemoon vb.) gidin\n3. \"Çerezler\" bölümüne ve ardından \"https://accounts.spotify.com\" alt bölümüne gidin",
+ "step_2_steps": "1. Oturum açtıktan sonra, tarayıcı geliştirme araçlarını açmak için F12'ye veya fareye sağ tıklayın > İncele'ye basın.\n2. Daha sonra \"Uygulama\" sekmesine (Chrome, Edge, Brave vb..) veya \"Depolama\" sekmesine (Firefox, Palemoon vb..) gidin\n3. \"Çerezler\" bölümüne, ardından \"https://accounts.spotify.com\" alt bölümüne gidin",
"step_3": "3. Adım",
+ "step_3_steps": "\"sp_dc\" Çerezinin değerini kopyalayın",
"success_emoji": "Başarılı🥳",
- "success_message": "Şimdi Spotify hesabınızla başarılı bir şekilde oturum açtınız. İyi iş, dostum!",
+ "success_message": "Artık Spotify hesabınızla başarıyla giriş yaptınız. Tebrik ederim!",
"step_4": "4. Adım",
- "something_went_wrong": "Bir şeyler ters gitti",
- "piped_instance": "Piped Sunucu Örneği",
+ "step_4_steps": "Kopyalanan \"sp_dc\" değerini yapıştırın",
+ "something_went_wrong": "Bir hata oluştu",
+ "piped_instance": "Piped sunucu örneği",
"piped_description": "Parça eşleştirme için kullanılacak Piped sunucu örneği",
- "piped_warning": "Bazıları iyi çalışmayabilir. Bu yüzden riski size ait olmak üzere kullanın",
- "generate_playlist": "Çalma Listesi Oluştur",
- "track_exists": "Track {track} zaten mevcut",
+ "piped_warning": "Bazıları iyi çalışmayabilir. Yani riski size ait olmak üzere kullanın",
+ "generate_playlist": "Oynatma listesi oluştur",
+ "track_exists": "{track} parçası zaten var",
"replace_downloaded_tracks": "İndirilen tüm parçaları değiştir",
"skip_download_tracks": "İndirilen tüm parçaları indirmeyi atla",
- "do_you_want_to_replace": "Mevcut parçayı değiştirmek mi istiyorsunuz?",
+ "do_you_want_to_replace": "Mevcut parçayı değiştirmek istiyor musunuz?",
"replace": "Değiştir",
"skip": "Atla",
"select_up_to_count_type": "En fazla {count} {type} seçin",
- "select_genres": "Tür Seç",
- "add_genres": "Tür Ekle",
+ "select_genres": "Türleri seç",
+ "add_genres": "Tür ekle",
"country": "Ülke",
"number_of_tracks_generate": "Oluşturulacak parça sayısı",
"acousticness": "Akustiklik",
- "danceability": "Dansedilebilirlik",
+ "danceability": "Dans Edilebilirlik",
"energy": "Enerji",
- "instrumentalness": "Enstrümansallık",
+ "instrumentalness": "Araçsallık",
"liveness": "Canlılık",
- "loudness": "Yükseklik",
+ "loudness": "Ses yüksekliği",
"speechiness": "Konuşkanlık",
- "valence": "Değerlilik",
+ "valence": "Değerlik",
"popularity": "Popülerlik",
"key": "Anahtar",
"duration": "Süre (sn)",
"tempo": "Tempo (BPM)",
"mode": "Mod",
- "time_signature": "Zaman İmzası",
+ "time_signature": "Zaman imzası",
"short": "Kısa",
"medium": "Orta",
"long": "Uzun",
@@ -217,101 +220,110 @@
"max": "Maks",
"target": "Hedef",
"moderate": "Orta",
- "deselect_all": "Tüm Seçimleri Kaldır",
- "select_all": "Tümünü Seç",
+ "deselect_all": "Tüm seçimleri kaldır",
+ "select_all": "Tümünü seç",
"are_you_sure": "Emin misiniz?",
- "generating_playlist": "Özel çalma listenizi oluşturun...",
- "selected_count_tracks": "Seçilen {count} parçalar",
- "download_warning": "Tüm Parçaları toplu olarak indirirseniz, açıkça Müzik korsanlığı yapmış ve yaratıcı Müzik toplumuna zarar vermiş olursunuz. Umarım bunun farkındasınızdır. Her zaman, Sanatçıların sıkı çalışmalarına saygı duymayı ve desteklemeyi deneyin",
- "download_ip_ban_warning": "Bu arada, normalden fazla indirme isteği nedeniyle IP adresiniz YouTube'da engellenebilir. IP engeli, o IP cihazından en az 2-3 ay boyunca YouTube'u (giriş yapmış olsanız bile) kullanamayacağınız anlamına gelir. Ve Spotube böyle bir durumda herhangi bir sorumluluk kabul etmez",
- "by_clicking_accept_terms": "'Kabul et' seçeneğine tıklayarak aşağıdaki şartları kabul etmiş olursunuz:",
- "download_agreement_1": "Müzik korsanlığı yaptığımı biliyorum. Ben malım.",
- "download_agreement_2": "Sanatçıları elimden geldiğince destekleyeceğim ve bunu sadece sanatlarını satın alacak param olmadığı için yapıyorum",
- "download_agreement_3": "IP adresimin YouTube'da engellenebileceğinin tamamen farkındayım ve mevcut eylemimin neden olduğu herhangi bir kazadan Spotube'u veya sahiplerini/dağıtıcılarını sorumlu tutmuyorum",
+ "generating_playlist": "Özel oynatma listeniz oluşturuluyor...",
+ "selected_count_tracks": "{count} parça seçildi",
+ "download_warning": "Tüm şarkıları toplu olarak indiriyorsanız, açıkça müzik korsanlığı yapıyorsunuz ve müzik dünyasının yaratıcı topluluğuna zarar veriyorsunuz demektir. Umuyorum bunun farkındasınızdır. Her zaman, sanatçıların emeğine saygı göstermeyi ve desteklemeyi deneyin.",
+ "download_ip_ban_warning": "Ayrıca, normalden fazla indirme istekleri nedeniyle YouTube'da IP'niz engellenebilir. IP engeli, en az 2-3 ay boyunca YouTube'u (hatta oturum açmış olsanız bile) o IP cihazından kullanamayacağınız anlamına gelir. Ve eğer böyle bir durum yaşanırsa, Spotube bundan hiçbir sorumluluk kabul etmez.",
+ "by_clicking_accept_terms": "\"Kabul et\" e tıklayarak aşağıdaki şartları kabul etmiş olursunuz:",
+ "download_agreement_1": "Müzik korsanlığı yaptığımı biliyorum. Ben fakir biriyim.",
+ "download_agreement_2": "Sanatçıyı elimden geldiğince destekleyeceğim ve bunu sadece sanatını satın alacak param olmadığı için yapıyorum",
+ "download_agreement_3": "YouTube'da IP'min engellenebileceğinin tamamen farkındayım ve mevcut eylemlerimden kaynaklanan herhangi bir kaza için Spotube'u veya sahiplerini/katkıda bulunanları sorumlu tutmuyorum.",
"decline": "Reddet",
"accept": "Kabul et",
"details": "Detaylar",
"youtube": "YouTube",
"channel": "Kanal",
- "likes": "Beğeniler",
- "dislikes": "Beğenmemeler",
+ "likes": "Beğenenler",
+ "dislikes": "Beğenmeyenler",
"views": "İzlenmeler",
- "streamUrl": "Yayın Bağlantısı",
- "stop": "Dur",
- "sort_newest": "En yeni eklenene göre sırala",
+ "streamUrl": "Akış bağlantısı",
+ "stop": "Durdur",
+ "sort_newest": "En yeni eklenene göre sırala.",
"sort_oldest": "En eski eklenene göre sırala",
"sleep_timer": "Uyku Zamanlayıcısı",
- "mins": "{minutes} Dakikalar",
- "hours": "{hours} Saat",
- "hour": "{hours} Saatler",
+ "mins": "{minutes} Dakika",
+ "hours": "{hours} Saatler",
+ "hour": "{hours} Saat",
"custom_hours": "Özel Saatler",
"logs": "Günlükler",
"developers": "Geliştiriciler",
"not_logged_in": "Giriş yapmadınız",
- "search_mode": "Arama Modu",
- "audio_source": "Ses Kaynağı",
+ "search_mode": "Arama modu",
+ "audio_source": "Ses kaynağı",
"ok": "Tamam",
"failed_to_encrypt": "Şifreleme başarısız oldu",
- "encryption_failed_warning": "Spotube, verilerinizi güvenli bir şekilde depolamak için şifreleme kullanır. Ancak bunu başaramadı. Bu nedenle güvensiz bir depolamaya geri dönecektir. Linux kullanıyorsanız, lütfen gnome-keyring, kde-wallet, keepassxc vb. gibi bir güvenlik hizmetinizin kurulu olduğundan emin olun.",
+ "encryption_failed_warning": "Spotube, verilerinizi güvenli bir şekilde depolamak için şifreleme kullanır. Ancak bunu başaramadı. Bu nedenle, güvensiz depolamaya geri dönecektir\nLinux kullanıyorsanız, lütfen gnome-keyring, kde-wallet, keepassxc vb. herhangi bir gizli servisin yüklü olduğundan emin olun.",
"querying_info": "Bilgi sorgulanıyor...",
"piped_api_down": "Piped API kapalı",
- "piped_down_error_instructions": "Piped örneği {pipedInstance} şu anda kapalı\n\nYa örneği değiştirin ya da 'API türünü' resmi YouTube API'si olarak değiştirin\n\nDeğişiklikten sonra uygulamayı yeniden başlattığınızdan emin olun",
+ "piped_down_error_instructions": "Piped örneği {pipedInstance} şu anda kapalı\n\nÖrneği değiştirin veya 'API türünü' resmi YouTube API'si olarak değiştirin\n\nDeğişiklikten sonra uygulamayı yeniden başlattığınızdan emin olun",
"you_are_offline": "Şu anda çevrimdışısınız",
- "connection_restored": "İnternet bağlantınız yeniden kuruldu",
+ "connection_restored": "İnternet bağlantınız geri yüklendi",
"use_system_title_bar": "Sistem başlık çubuğunu kullan",
- "crunching_results": "Sonuçlar kırılıyor...",
- "search_to_get_results": "Sonuç almak için arama yap",
- "use_amoled_mode": "AMOLED modunu kullan",
- "pitch_dark_theme": "Zifiri siyah dart teması",
+ "crunching_results": "Sonuçlar...",
+ "search_to_get_results": "Sonuç almak için arayın",
+ "use_amoled_mode": "AMOLED modu kullan",
+ "pitch_dark_theme": "Zifiri karanlık koyu tema",
"normalize_audio": "Sesi normalleştir",
"change_cover": "Kapağı değiştir",
"add_cover": "Kapak ekle",
"restore_defaults": "Varsayılanları geri yükle",
- "download_music_codec": "Müzik codec bileşenini indirin",
- "streaming_music_codec": "Müzik akışı codec bileşeni",
+ "download_music_codec": "Müzik codec bileşenini indir",
+ "streaming_music_codec": "Müzik codec'i akışı",
"login_with_lastfm": "Last.fm ile giriş yap",
"connect": "Bağlan",
"disconnect_lastfm": "Last.fm bağlantısını kes",
- "disconnect": "Bağlantıyı Kes",
- "username": "Kullanıcı Adı",
+ "disconnect": "Bağlantıyı kes",
+ "username": "Kullanıcı adı",
"password": "Şifre",
- "login": "Giriş Yap",
- "login_with_your_lastfm": "Last.fm hesabınız ile giriş yapın",
+ "login": "Giriş yap",
+ "login_with_your_lastfm": "Last.fm hesabınızla giriş yapın",
"scrobble_to_lastfm": "Last.fm için Scrobble",
- "go_to_album": "Albüme Git",
- "discord_rich_presence": "Discord Zengin Varlık",
- "browse_all": "Tümünü Gözat",
- "genres": "Müzik Türleri",
- "explore_genres": "Türleri Keşfet",
- "step_3_steps": "\"sp_dc\" Çerezinin değerini kopyala",
- "step_4_steps": "Kopyalanan \"sp_dc\" değerini yapıştır",
+ "go_to_album": "Albüme git",
+ "discord_rich_presence": "Discord zengin varlığı",
+ "browse_all": "Tümüne göz at",
+ "genres": "Müzik türleri",
+ "explore_genres": "Türleri keşfet",
"friends": "Arkadaşlar",
- "no_lyrics_available": "Üzgünüz, bu parça için şarkı sözleri bulunamıyor",
- "sort_duration": "Süreye Göre Sırala",
- "start_a_radio": "Radyo Başlat",
+ "no_lyrics_available": "Üzgünüz, bu parçanın sözleri bulunamıyor",
+ "start_a_radio": "Radyo başlat",
"how_to_start_radio": "Radyoyu nasıl başlatmak istersiniz?",
"replace_queue_question": "Mevcut kuyruğu değiştirmek mi yoksa eklemek mi istersiniz?",
- "endless_playback": "Sonsuz Çalma",
- "delete_playlist": "Çalma Listesini Sil",
- "delete_playlist_confirmation": "Bu çalma listesini silmek istediğinizden emin misiniz?",
- "local_tracks": "Yerel Parçalar",
- "song_link": "Şarkı Bağlantısı",
+ "endless_playback": "Sonsuz olarak oynat",
+ "delete_playlist": "Oynatma listesini sil",
+ "delete_playlist_confirmation": "Bu oynatma listesini silmek istediğinizden emin misiniz?",
+ "local_tracks": "Yerel parçalar",
+ "song_link": "Şarkı bağlantısı",
"skip_this_nonsense": "Bu saçmalığı atla",
- "freedom_of_music": "“Müziğin Özgürlüğü”",
- "freedom_of_music_palm": "“Müziğin Özgürlüğü avucunuzun içinde”",
- "get_started": "Başlayalım",
- "youtube_source_description": "Tavsiye edilir ve en iyi çalışır.",
- "piped_source_description": "Özgür hissediyor musunuz? YouTube ile aynı ama çok daha fazla ücretsiz.",
+ "freedom_of_music": "“Müzik özgürlüğü”",
+ "freedom_of_music_palm": "“Müzik özgürlüğü avucunuzun içinde”",
+ "get_started": "Haydi başlayalım",
+ "youtube_source_description": "Tavsiye edilir ve en iyi şekilde çalışır.",
+ "piped_source_description": "Özgür hissediyor musunuz? YouTube ile aynı, ama çok daha özgür.",
"jiosaavn_source_description": "Güney Asya bölgesi için en iyisi.",
- "highest_quality": "En Yüksek Kalite: {quality}",
- "select_audio_source": "Ses Kaynağını Seç",
- "endless_playback_description": "Yeni şarkıları otomatik olarak sıraya ekle\nsonuna",
- "choose_your_region": "Bölgenizi Seçin",
- "choose_your_region_description": "Bu, Spotube'un konumunuza uygun doğru içeriği göstermesine yardımcı olacaktır.",
- "choose_your_language": "Dilinizi Seçin",
+ "highest_quality": "En yüksek kalite: {quality}",
+ "select_audio_source": "Ses kaynağını seçin",
+ "endless_playback_description": "Yeni şarkıları otomatik olarak\nkuyruğun sonuna ekle",
+ "choose_your_region": "Bölgenizi seçin",
+ "choose_your_region_description": "Bu, Spotube'un konumunuza uygun içerikleri göstermesine yardımcı olacaktır.",
+ "choose_your_language": "Dilinizi seçin",
"help_project_grow": "Bu projenin büyümesine yardımcı olun",
- "help_project_grow_description": "Spotube açık kaynaklı bir projedir. Bu projenin büyümesine, projeye katkıda bulunarak, hataları raporlayarak veya yeni özellikler önererek yardımcı olabilirsiniz.",
- "contribute_on_github": "GitHub'da Katkıda Bulun",
- "donate_on_open_collective": "Açık Topluluğa Bağış Yapın",
- "browse_anonymously": "Anonim Olarak Göz At"
+ "help_project_grow_description": "Spotube açık kaynaklı bir projedir. Projeye katkıda bulunarak, hataları bildirerek veya yeni özellikler önererek bu projenin büyümesine yardımcı olabilirsiniz.",
+ "contribute_on_github": "GitHub'da katkıda bulun",
+ "donate_on_open_collective": "Open Collective'de bağış yap",
+ "browse_anonymously": "Anonim olarak giriş yap",
+ "enable_connect": "Bağlanmayı etkinleştir",
+ "enable_connect_description": "Spotube'u diğer cihazlardan kontrol edin",
+ "devices": "Cihazlar",
+ "select": "Seç",
+ "connect_client_alert": "{client} tarafından kontrol ediliyorsun.",
+ "this_device": "Bu cihaz",
+ "remote": "Yönet",
+ "local_library": "Yerel kütüphane",
+ "add_library_location": "Kütüphaneye ekle",
+ "remove_library_location": "Kütüphaneden çıkar",
+ "local_tab": "Yerel",
+ "stats": "İstatistikler"
}
\ No newline at end of file
diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb
index fe57e617..d056524e 100644
--- a/lib/l10n/app_uk.arb
+++ b/lib/l10n/app_uk.arb
@@ -313,5 +313,17 @@
"help_project_grow_description": "Spotube - це проект з відкритим кодом. Ви можете допомогти цьому проекту зростати, вносячи свій внесок у проект, повідомляючи про помилки або пропонуючи нові функції.",
"contribute_on_github": "Долучайтесь на GitHub",
"donate_on_open_collective": "Пожертвуйте на Open Collective",
- "browse_anonymously": "Анонімно переглядати"
+ "browse_anonymously": "Анонімно переглядати",
+ "enable_connect": "Увімкнути підключення",
+ "enable_connect_description": "Керуйте Spotube з інших пристроїв",
+ "devices": "Пристрої",
+ "select": "Вибрати",
+ "connect_client_alert": "Вас керує {client}",
+ "this_device": "Цей пристрій",
+ "remote": "Віддалений",
+ "local_library": "Місцева бібліотека",
+ "add_library_location": "Додати до бібліотеки",
+ "remove_library_location": "Видалити з бібліотеки",
+ "local_tab": "Місцевий",
+ "stats": "Статистика"
}
\ No newline at end of file
diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb
index 0e9b0b7c..6bbd6cb6 100644
--- a/lib/l10n/app_vi.arb
+++ b/lib/l10n/app_vi.arb
@@ -311,5 +311,19 @@
"help_project_grow_description": "Spotube là một dự án mã nguồn mở. Bạn có thể giúp dự án này phát triển bằng cách đóng góp vào dự án, báo cáo lỗi hoặc đề xuất tính năng mới.",
"contribute_on_github": "Đóng góp trên GitHub",
"donate_on_open_collective": "Quyên góp trên Open Collective",
- "browse_anonymously": "Duyệt Anonymously"
+ "browse_anonymously": "Duyệt Anonymously",
+ "friends": "Bạn bè",
+ "no_lyrics_available": "Xin lỗi, không tìm thấy lời cho bài hát này",
+ "enable_connect": "Kích hoạt kết nối",
+ "enable_connect_description": "Điều khiển Spotube từ các thiết bị khác",
+ "devices": "Thiết bị",
+ "select": "Chọn",
+ "connect_client_alert": "Bạn đang được điều khiển bởi {client}",
+ "this_device": "Thiết bị này",
+ "remote": "Từ xa",
+ "local_library": "Thư viện địa phương",
+ "add_library_location": "Thêm vào thư viện",
+ "remove_library_location": "Xóa khỏi thư viện",
+ "local_tab": "Địa phương",
+ "stats": "Thống kê"
}
\ No newline at end of file
diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb
index 506661f0..b145f97b 100644
--- a/lib/l10n/app_zh.arb
+++ b/lib/l10n/app_zh.arb
@@ -313,5 +313,17 @@
"help_project_grow_description": "Spotube是一个开源项目。您可以通过为项目做出贡献、报告错误或建议新功能来帮助该项目成长。",
"contribute_on_github": "在GitHub上做出贡献",
"donate_on_open_collective": "在Open Collective上捐款",
- "browse_anonymously": "匿名浏览"
+ "browse_anonymously": "匿名浏览",
+ "enable_connect": "启用连接",
+ "enable_connect_description": "从其他设备控制Spotube",
+ "devices": "设备",
+ "select": "选择",
+ "connect_client_alert": "您正在被 {client} 控制",
+ "this_device": "此设备",
+ "remote": "远程",
+ "local_library": "本地图书馆",
+ "add_library_location": "添加到图书馆",
+ "remove_library_location": "从图书馆中删除",
+ "local_tab": "本地",
+ "stats": "统计"
}
\ No newline at end of file
diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart
index 7aec682a..ebdc4b61 100644
--- a/lib/l10n/l10n.dart
+++ b/lib/l10n/l10n.dart
@@ -7,10 +7,15 @@
/// TexturedPolak@github => Polish
/// yuri-val@github => Ukrainian
/// energywave@github, ncvescera@github, OpenCode@github => Italian
-/// mdksec@github => Turkish
+/// mikropsoft@github => Turkish
/// Stephan-P@github, SecularSteve@github => Dutch
/// doannc2212@github => Vietnamese
/// sappho192@github => Korean
+/// watchakorn-18k@github => Thai
+/// Microsoft Copilot, Tutislav@github => Czech
+
+library l10n;
+
import 'package:flutter/material.dart';
class L10n {
@@ -19,22 +24,28 @@ class L10n {
const Locale('ar', 'SA'),
const Locale('bn', 'BD'),
const Locale('ca', 'AD'),
+ const Locale('cs', 'CZ'),
const Locale('de', 'GE'),
const Locale('es', 'ES'),
- const Locale("fa", "IR"),
+ const Locale('fa', 'IR'),
+ const Locale('fi', 'FI'),
const Locale('fr', 'FR'),
const Locale('ne', 'NP'),
const Locale('hi', 'IN'),
+ const Locale('id', 'ID'),
const Locale('it', 'IT'),
const Locale('ja', 'JP'),
+ const Locale('ka', 'GE'),
const Locale('ko', 'KR'),
const Locale('nl', 'NL'),
const Locale('pl', 'PL'),
const Locale('pt', 'PT'),
const Locale('ru', 'RU'),
const Locale('uk', 'UA'),
+ const Locale('th', 'TH'),
const Locale('tr', 'TR'),
const Locale('zh', 'CN'),
const Locale('vi', 'VN'),
+ const Locale('eu', 'ES'),
];
}
diff --git a/lib/main.dart b/lib/main.dart
index 01e418dd..1693d9d8 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -1,18 +1,15 @@
import 'package:catcher_2/catcher_2.dart';
import 'package:dart_discord_rpc/dart_discord_rpc.dart';
-import 'package:device_preview/device_preview.dart';
-import 'package:fl_query/fl_query.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
-import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:local_notifier/local_notifier.dart';
import 'package:media_kit/media_kit.dart';
import 'package:metadata_god/metadata_god.dart';
-import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotube/collections/initializers.dart';
import 'package:spotube/collections/routes.dart';
import 'package:spotube/collections/intents.dart';
@@ -20,24 +17,30 @@ import 'package:spotube/hooks/configurators/use_close_behavior.dart';
import 'package:spotube/hooks/configurators/use_deep_linking.dart';
import 'package:spotube/hooks/configurators/use_disable_battery_optimizations.dart';
import 'package:spotube/hooks/configurators/use_get_storage_perms.dart';
+import 'package:spotube/provider/tray_manager/tray_manager.dart';
import 'package:spotube/l10n/l10n.dart';
import 'package:spotube/models/logger.dart';
import 'package:spotube/models/skip_segment.dart';
import 'package:spotube/models/source_match.dart';
+import 'package:spotube/provider/connect/clients.dart';
+import 'package:spotube/provider/connect/server.dart';
import 'package:spotube/provider/palette_provider.dart';
+import 'package:spotube/provider/server/server.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/cli/cli.dart';
-import 'package:spotube/services/connectivity_adapter.dart';
import 'package:spotube/services/kv_store/kv_store.dart';
+import 'package:spotube/services/wm_tools/wm_tools.dart';
import 'package:spotube/themes/theme.dart';
import 'package:spotube/utils/persisted_state_notifier.dart';
+import 'package:spotube/utils/platform.dart';
import 'package:system_theme/system_theme.dart';
import 'package:path_provider/path_provider.dart';
-import 'package:spotube/hooks/configurators/use_init_sys_tray.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_native_splash/flutter_native_splash.dart';
import 'package:flutter_displaymode/flutter_displaymode.dart';
+import 'package:timezone/data/latest.dart' as tz;
+import 'package:window_manager/window_manager.dart';
Future main(List rawArgs) async {
final arguments = await startCLI(rawArgs);
@@ -46,17 +49,19 @@ Future main(List rawArgs) async {
await registerWindowsScheme("spotify");
+ tz.initializeTimeZones();
+
FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);
MediaKit.ensureInitialized();
// force High Refresh Rate on some Android devices (like One Plus)
- if (DesktopTools.platform.isAndroid) {
+ if (kIsAndroid) {
await FlutterDisplayMode.setHighRefreshRate();
}
- if (DesktopTools.platform.isDesktop) {
- await DesktopTools.window.setPreventClose(true);
+ if (kIsDesktop) {
+ await windowManager.setPreventClose(true);
}
await SystemTheme.accentColor.load();
@@ -65,7 +70,7 @@ Future main(List rawArgs) async {
MetadataGod.initialize();
}
- if (DesktopTools.platform.isWindows || DesktopTools.platform.isLinux) {
+ if (kIsWindows || kIsLinux) {
DiscordRPC.initialize();
}
@@ -74,11 +79,7 @@ Future main(List rawArgs) async {
final hiveCacheDir =
kIsWeb ? null : (await getApplicationSupportDirectory()).path;
- await QueryClient.initialize(
- cachePrefix: "oss.krtirtho.spotube",
- cacheDir: hiveCacheDir,
- connectivity: FlQueryInternetConnectionCheckerAdapter(),
- );
+ Hive.init(hiveCacheDir);
Hive.registerAdapter(SkipSegmentAdapter());
@@ -101,14 +102,10 @@ Future main(List rawArgs) async {
path: hiveCacheDir,
);
- await DesktopTools.ensureInitialized(
- DesktopWindowOptions(
- hideTitleBar: true,
- title: "Spotube",
- backgroundColor: Colors.transparent,
- minimumSize: const Size(300, 700),
- ),
- );
+ if (kIsDesktop) {
+ await localNotifier.setup(appName: "Spotube");
+ await WindowManagerTools.initialize();
+ }
Catcher2(
enableLogger: arguments["verbose"],
@@ -135,49 +132,17 @@ Future main(List rawArgs) async {
),
runAppFunction: () {
runApp(
- DevicePreview(
- availableLocales: L10n.all,
- enabled: false,
- data: const DevicePreviewData(
- isEnabled: false,
- orientation: Orientation.portrait,
- ),
- builder: (context) {
- return ProviderScope(
- child: QueryClientProvider(
- staleDuration: const Duration(minutes: 30),
- child: const Spotube(),
- ),
- );
- },
- ),
+ const ProviderScope(child: Spotube()),
);
},
);
}
-class Spotube extends StatefulHookConsumerWidget {
- const Spotube({Key? key}) : super(key: key);
+class Spotube extends HookConsumerWidget {
+ const Spotube({super.key});
@override
- SpotubeState createState() => SpotubeState();
-
- static SpotubeState of(BuildContext context) =>
- context.findAncestorStateOfType()!;
-}
-
-class SpotubeState extends ConsumerState {
- final logger = getLogger(Spotube);
- SharedPreferences? localStorage;
-
- @override
- void initState() {
- super.initState();
- SharedPreferences.getInstance().then(((value) => localStorage = value));
- }
-
- @override
- Widget build(BuildContext context) {
+ Widget build(BuildContext context, ref) {
final themeMode =
ref.watch(userPreferencesProvider.select((s) => s.themeMode));
final accentMaterialColor =
@@ -189,8 +154,12 @@ class SpotubeState extends ConsumerState {
ref.watch(paletteProvider.select((s) => s?.dominantColor?.color));
final router = ref.watch(routerProvider);
+ ref.listen(playbackServerProvider, (_, __) {});
+ ref.listen(connectServerProvider, (_, __) {});
+ ref.listen(connectClientsProvider, (_, __) {});
+ ref.listen(trayManagerProvider, (_, __) {});
+
useDisableBatteryOptimizations();
- useInitSysTray(ref);
useDeepLinking(ref);
useCloseBehavior(ref);
useGetStoragePermissions(ref);
@@ -208,6 +177,7 @@ class SpotubeState extends ConsumerState {
() => theme(paletteColor ?? accentMaterialColor, Brightness.light, false),
[paletteColor, accentMaterialColor],
);
+
final darkTheme = useMemoized(
() => theme(
paletteColor ?? accentMaterialColor,
@@ -230,12 +200,8 @@ class SpotubeState extends ConsumerState {
debugShowCheckedModeBanner: false,
title: 'Spotube',
builder: (context, child) {
- return DevicePreview.appBuilder(
- context,
- DesktopTools.platform.isDesktop
- ? DragToResizeArea(child: child!)
- : child,
- );
+ if (kIsDesktop && !kIsMacOS) return DragToResizeArea(child: child!);
+ return child!;
},
themeMode: themeMode,
theme: lightTheme,
diff --git a/lib/models/connect/connect.dart b/lib/models/connect/connect.dart
new file mode 100644
index 00000000..28386050
--- /dev/null
+++ b/lib/models/connect/connect.dart
@@ -0,0 +1,15 @@
+library connect;
+
+import 'dart:async';
+import 'dart:convert';
+
+import 'package:freezed_annotation/freezed_annotation.dart';
+import 'package:spotify/spotify.dart';
+import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
+import 'package:spotube/services/audio_player/loop_mode.dart';
+
+part 'connect.freezed.dart';
+part 'connect.g.dart';
+
+part 'ws_event.dart';
+part 'load.dart';
diff --git a/lib/models/connect/connect.freezed.dart b/lib/models/connect/connect.freezed.dart
new file mode 100644
index 00000000..088cfbd1
--- /dev/null
+++ b/lib/models/connect/connect.freezed.dart
@@ -0,0 +1,610 @@
+// coverage:ignore-file
+// GENERATED CODE - DO NOT MODIFY BY HAND
+// ignore_for_file: type=lint
+// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
+
+part of 'connect.dart';
+
+// **************************************************************************
+// FreezedGenerator
+// **************************************************************************
+
+T _$identity(T value) => value;
+
+final _privateConstructorUsedError = UnsupportedError(
+ 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
+
+WebSocketLoadEventData _$WebSocketLoadEventDataFromJson(
+ Map json) {
+ switch (json['runtimeType']) {
+ case 'playlist':
+ return WebSocketLoadEventDataPlaylist.fromJson(json);
+ case 'album':
+ return WebSocketLoadEventDataAlbum.fromJson(json);
+
+ default:
+ throw CheckedFromJsonException(
+ json,
+ 'runtimeType',
+ 'WebSocketLoadEventData',
+ 'Invalid union type "${json['runtimeType']}"!');
+ }
+}
+
+/// @nodoc
+mixin _$WebSocketLoadEventData {
+ @JsonKey(name: 'tracks', toJson: _tracksJson)
+ List get tracks => throw _privateConstructorUsedError;
+ Object? get collection => throw _privateConstructorUsedError;
+ int? get initialIndex => throw _privateConstructorUsedError;
+ @optionalTypeArgs
+ TResult when({
+ required TResult Function(
+ @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks,
+ PlaylistSimple? collection,
+ int? initialIndex)
+ playlist,
+ required TResult Function(
+ @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks,
+ AlbumSimple? collection,
+ int? initialIndex)
+ album,
+ }) =>
+ throw _privateConstructorUsedError;
+ @optionalTypeArgs
+ TResult? whenOrNull({
+ TResult? Function(
+ @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks,
+ PlaylistSimple? collection,
+ int? initialIndex)?
+ playlist,
+ TResult? Function(
+ @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks,
+ AlbumSimple? collection,
+ int? initialIndex)?
+ album,
+ }) =>
+ throw _privateConstructorUsedError;
+ @optionalTypeArgs
+ TResult maybeWhen({
+ TResult Function(
+ @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks,
+ PlaylistSimple? collection,
+ int? initialIndex)?
+ playlist,
+ TResult Function(
+ @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks,
+ AlbumSimple? collection,
+ int? initialIndex)?
+ album,
+ required TResult orElse(),
+ }) =>
+ throw _privateConstructorUsedError;
+ @optionalTypeArgs
+ TResult map({
+ required TResult Function(WebSocketLoadEventDataPlaylist value) playlist,
+ required TResult Function(WebSocketLoadEventDataAlbum value) album,
+ }) =>
+ throw _privateConstructorUsedError;
+ @optionalTypeArgs
+ TResult? mapOrNull({
+ TResult? Function(WebSocketLoadEventDataPlaylist value)? playlist,
+ TResult? Function(WebSocketLoadEventDataAlbum value)? album,
+ }) =>
+ throw _privateConstructorUsedError;
+ @optionalTypeArgs
+ TResult maybeMap({
+ TResult Function(WebSocketLoadEventDataPlaylist value)? playlist,
+ TResult Function(WebSocketLoadEventDataAlbum value)? album,
+ required TResult orElse(),
+ }) =>
+ throw _privateConstructorUsedError;
+ Map toJson() => throw _privateConstructorUsedError;
+ @JsonKey(ignore: true)
+ $WebSocketLoadEventDataCopyWith get copyWith =>
+ throw _privateConstructorUsedError;
+}
+
+/// @nodoc
+abstract class $WebSocketLoadEventDataCopyWith<$Res> {
+ factory $WebSocketLoadEventDataCopyWith(WebSocketLoadEventData value,
+ $Res Function(WebSocketLoadEventData) then) =
+ _$WebSocketLoadEventDataCopyWithImpl<$Res, WebSocketLoadEventData>;
+ @useResult
+ $Res call(
+ {@JsonKey(name: 'tracks', toJson: _tracksJson) List tracks,
+ int? initialIndex});
+}
+
+/// @nodoc
+class _$WebSocketLoadEventDataCopyWithImpl<$Res,
+ $Val extends WebSocketLoadEventData>
+ implements $WebSocketLoadEventDataCopyWith<$Res> {
+ _$WebSocketLoadEventDataCopyWithImpl(this._value, this._then);
+
+ // ignore: unused_field
+ final $Val _value;
+ // ignore: unused_field
+ final $Res Function($Val) _then;
+
+ @pragma('vm:prefer-inline')
+ @override
+ $Res call({
+ Object? tracks = null,
+ Object? initialIndex = freezed,
+ }) {
+ return _then(_value.copyWith(
+ tracks: null == tracks
+ ? _value.tracks
+ : tracks // ignore: cast_nullable_to_non_nullable
+ as List,
+ initialIndex: freezed == initialIndex
+ ? _value.initialIndex
+ : initialIndex // ignore: cast_nullable_to_non_nullable
+ as int?,
+ ) as $Val);
+ }
+}
+
+/// @nodoc
+abstract class _$$WebSocketLoadEventDataPlaylistImplCopyWith<$Res>
+ implements $WebSocketLoadEventDataCopyWith<$Res> {
+ factory _$$WebSocketLoadEventDataPlaylistImplCopyWith(
+ _$WebSocketLoadEventDataPlaylistImpl value,
+ $Res Function(_$WebSocketLoadEventDataPlaylistImpl) then) =
+ __$$WebSocketLoadEventDataPlaylistImplCopyWithImpl<$Res>;
+ @override
+ @useResult
+ $Res call(
+ {@JsonKey(name: 'tracks', toJson: _tracksJson) List tracks,
+ PlaylistSimple? collection,
+ int? initialIndex});
+}
+
+/// @nodoc
+class __$$WebSocketLoadEventDataPlaylistImplCopyWithImpl<$Res>
+ extends _$WebSocketLoadEventDataCopyWithImpl<$Res,
+ _$WebSocketLoadEventDataPlaylistImpl>
+ implements _$$WebSocketLoadEventDataPlaylistImplCopyWith<$Res> {
+ __$$WebSocketLoadEventDataPlaylistImplCopyWithImpl(
+ _$WebSocketLoadEventDataPlaylistImpl _value,
+ $Res Function(_$WebSocketLoadEventDataPlaylistImpl) _then)
+ : super(_value, _then);
+
+ @pragma('vm:prefer-inline')
+ @override
+ $Res call({
+ Object? tracks = null,
+ Object? collection = freezed,
+ Object? initialIndex = freezed,
+ }) {
+ return _then(_$WebSocketLoadEventDataPlaylistImpl(
+ tracks: null == tracks
+ ? _value._tracks
+ : tracks // ignore: cast_nullable_to_non_nullable
+ as List,
+ collection: freezed == collection
+ ? _value.collection
+ : collection // ignore: cast_nullable_to_non_nullable
+ as PlaylistSimple?,
+ initialIndex: freezed == initialIndex
+ ? _value.initialIndex
+ : initialIndex // ignore: cast_nullable_to_non_nullable
+ as int?,
+ ));
+ }
+}
+
+/// @nodoc
+@JsonSerializable()
+class _$WebSocketLoadEventDataPlaylistImpl
+ extends WebSocketLoadEventDataPlaylist {
+ _$WebSocketLoadEventDataPlaylistImpl(
+ {@JsonKey(name: 'tracks', toJson: _tracksJson)
+ required final List tracks,
+ this.collection,
+ this.initialIndex,
+ final String? $type})
+ : _tracks = tracks,
+ $type = $type ?? 'playlist',
+ super._();
+
+ factory _$WebSocketLoadEventDataPlaylistImpl.fromJson(
+ Map json) =>
+ _$$WebSocketLoadEventDataPlaylistImplFromJson(json);
+
+ final List _tracks;
+ @override
+ @JsonKey(name: 'tracks', toJson: _tracksJson)
+ List get tracks {
+ if (_tracks is EqualUnmodifiableListView) return _tracks;
+ // ignore: implicit_dynamic_type
+ return EqualUnmodifiableListView(_tracks);
+ }
+
+ @override
+ final PlaylistSimple? collection;
+ @override
+ final int? initialIndex;
+
+ @JsonKey(name: 'runtimeType')
+ final String $type;
+
+ @override
+ String toString() {
+ return 'WebSocketLoadEventData.playlist(tracks: $tracks, collection: $collection, initialIndex: $initialIndex)';
+ }
+
+ @override
+ bool operator ==(Object other) {
+ return identical(this, other) ||
+ (other.runtimeType == runtimeType &&
+ other is _$WebSocketLoadEventDataPlaylistImpl &&
+ const DeepCollectionEquality().equals(other._tracks, _tracks) &&
+ (identical(other.collection, collection) ||
+ other.collection == collection) &&
+ (identical(other.initialIndex, initialIndex) ||
+ other.initialIndex == initialIndex));
+ }
+
+ @JsonKey(ignore: true)
+ @override
+ int get hashCode => Object.hash(runtimeType,
+ const DeepCollectionEquality().hash(_tracks), collection, initialIndex);
+
+ @JsonKey(ignore: true)
+ @override
+ @pragma('vm:prefer-inline')
+ _$$WebSocketLoadEventDataPlaylistImplCopyWith<
+ _$WebSocketLoadEventDataPlaylistImpl>
+ get copyWith => __$$WebSocketLoadEventDataPlaylistImplCopyWithImpl<
+ _$WebSocketLoadEventDataPlaylistImpl>(this, _$identity);
+
+ @override
+ @optionalTypeArgs
+ TResult when({
+ required TResult Function(
+ @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks,
+ PlaylistSimple? collection,
+ int? initialIndex)
+ playlist,
+ required TResult Function(
+ @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks,
+ AlbumSimple? collection,
+ int? initialIndex)
+ album,
+ }) {
+ return playlist(tracks, collection, initialIndex);
+ }
+
+ @override
+ @optionalTypeArgs
+ TResult? whenOrNull({
+ TResult? Function(
+ @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks,
+ PlaylistSimple? collection,
+ int? initialIndex)?
+ playlist,
+ TResult? Function(
+ @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks,
+ AlbumSimple? collection,
+ int? initialIndex)?
+ album,
+ }) {
+ return playlist?.call(tracks, collection, initialIndex);
+ }
+
+ @override
+ @optionalTypeArgs
+ TResult maybeWhen({
+ TResult Function(
+ @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks,
+ PlaylistSimple? collection,
+ int? initialIndex)?
+ playlist,
+ TResult Function(
+ @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks,
+ AlbumSimple? collection,
+ int? initialIndex)?
+ album,
+ required TResult orElse(),
+ }) {
+ if (playlist != null) {
+ return playlist(tracks, collection, initialIndex);
+ }
+ return orElse();
+ }
+
+ @override
+ @optionalTypeArgs
+ TResult map({
+ required TResult Function(WebSocketLoadEventDataPlaylist value) playlist,
+ required TResult Function(WebSocketLoadEventDataAlbum value) album,
+ }) {
+ return playlist(this);
+ }
+
+ @override
+ @optionalTypeArgs
+ TResult? mapOrNull({
+ TResult? Function(WebSocketLoadEventDataPlaylist value)? playlist,
+ TResult? Function(WebSocketLoadEventDataAlbum value)? album,
+ }) {
+ return playlist?.call(this);
+ }
+
+ @override
+ @optionalTypeArgs
+ TResult maybeMap({
+ TResult Function(WebSocketLoadEventDataPlaylist value)? playlist,
+ TResult Function(WebSocketLoadEventDataAlbum value)? album,
+ required TResult orElse(),
+ }) {
+ if (playlist != null) {
+ return playlist(this);
+ }
+ return orElse();
+ }
+
+ @override
+ Map toJson() {
+ return _$$WebSocketLoadEventDataPlaylistImplToJson(
+ this,
+ );
+ }
+}
+
+abstract class WebSocketLoadEventDataPlaylist extends WebSocketLoadEventData {
+ factory WebSocketLoadEventDataPlaylist(
+ {@JsonKey(name: 'tracks', toJson: _tracksJson)
+ required final List tracks,
+ final PlaylistSimple? collection,
+ final int? initialIndex}) = _$WebSocketLoadEventDataPlaylistImpl;
+ WebSocketLoadEventDataPlaylist._() : super._();
+
+ factory WebSocketLoadEventDataPlaylist.fromJson(Map json) =
+ _$WebSocketLoadEventDataPlaylistImpl.fromJson;
+
+ @override
+ @JsonKey(name: 'tracks', toJson: _tracksJson)
+ List