Merge branch 'dev' into patch-1

This commit is contained in:
Guanciottaman 2024-06-03 16:26:24 +02:00 committed by GitHub
commit ff252d6b14
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
422 changed files with 20188 additions and 8951 deletions

6
.dockerignore Normal file
View File

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

View File

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

View File

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

23
.github/Dockerfile vendored Normal file
View File

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

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

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

View File

@ -4,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 }}

View File

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

View File

@ -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}}%|<release version="${{ env.BUILD_VERSION }}" date="${{ steps.date.outputs.date }}" />|' linux/com.github.KRTirtho.Spotube.appdata.xml
- name: Generate Secrets
run: |
flutter config --enable-linux-desktop
flutter pub get
dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns
- name: Build Linux Packages
run: |
dart pub global activate flutter_distributor
alias dpkg-deb="dpkg-deb --Zxz"
flutter_distributor package --platform=linux --targets=deb
flutter_distributor package --platform=linux --targets=rpm
- name: Create tar.xz (stable)
if: ${{ inputs.channel == 'stable' }}
run: make tar VERSION=${{ env.BUILD_VERSION }} ARCH=x64 PKG_ARCH=x86_64
- name: Create tar.xz (nightly)
if: ${{ inputs.channel == 'nightly' }}
run: make tar VERSION=nightly ARCH=x64 PKG_ARCH=x86_64
- name: Move Files to dist
run: |
mv build/spotube-linux-*-x86_64.tar.xz dist/
mv dist/**/spotube-*-linux.deb dist/Spotube-linux-x86_64.deb
mv dist/**/spotube-*-linux.rpm dist/Spotube-linux-x86_64.rpm
- uses: actions/upload-artifact@v3
if: ${{ inputs.channel == 'stable' }}
with:
if-no-files-found: error
name: Spotube-Release-Binaries
path: |
dist/Spotube-linux-x86_64.deb
dist/Spotube-linux-x86_64.rpm
dist/spotube-linux-${{ env.BUILD_VERSION }}-x86_64.tar.xz
- uses: actions/upload-artifact@v3
if: ${{ inputs.channel == 'nightly' }}
with:
if-no-files-found: error
name: Spotube-Release-Binaries
path: |
dist/Spotube-linux-x86_64.deb
dist/Spotube-linux-x86_64.rpm
dist/spotube-linux-nightly-x86_64.tar.xz
- name: Debug With SSH When fails
if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }}
uses: mxschmitt/action-tmate@v3
with:
limit-access-to-actor: true
android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2.12.0
with:
cache: true
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Install Dependencies
run: |
sudo apt-get update -y
sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools patchelf desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse xmlstarlet
- name: Replace pubspec version and BUILD_VERSION Env (nightly)
if: ${{ inputs.channel == 'nightly' }}
run: |
curl -sS https://webi.sh/yq | sh
yq -i '.version |= sub("\+\d+", "+${{ inputs.channel }}.")' pubspec.yaml
yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml
echo "BUILD_VERSION=${{ inputs.version }}+${{ inputs.channel }}.${{ github.run_number }}" >> $GITHUB_ENV
- name: BUILD_VERSION Env (stable)
if: ${{ inputs.channel == 'stable' }}
run: |
echo "BUILD_VERSION=${{ inputs.version }}" >> $GITHUB_ENV
- name: Create Stable .env
if: ${{ inputs.channel == 'stable' }}
run: echo '${{ secrets.DOTENV_RELEASE }}' > .env
- name: Create Nightly .env
if: ${{ inputs.channel == 'nightly' }}
run: echo '${{ secrets.DOTENV_NIGHTLY }}' > .env
- name: Generate Secrets
- name: Install ${{matrix.platform}} dependencies
run: |
flutter pub get
dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns
dart cli/cli.dart install-dependencies --platform=${{matrix.platform}}
- name: Sign Apk
if: ${{matrix.platform == 'android'}}
run: |
echo '${{ secrets.KEYSTORE }}' | base64 --decode > android/app/upload-keystore.jks
echo '${{ secrets.KEY_PROPERTIES }}' > android/key.properties
- name: Build Apk
run: |
flutter build apk --flavor ${{ inputs.channel }}
mv build/app/outputs/flutter-apk/app-${{ inputs.channel }}-release.apk build/Spotube-android-all-arch.apk
- name: Build Playstore AppBundle
run: |
echo 'ENABLE_UPDATE_CHECK=0' >> .env
dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns
export MANIFEST=android/app/src/main/AndroidManifest.xml
xmlstarlet ed -d '//meta-data[@android:name="com.google.android.gms.car.application"]' $MANIFEST > $MANIFEST.tmp
mv $MANIFEST.tmp $MANIFEST
flutter build appbundle --flavor ${{ inputs.channel }}
mv build/app/outputs/bundle/${{ inputs.channel }}Release/app-${{ inputs.channel }}-release.aab build/Spotube-playstore-all-arch.aab
- name: Build ${{matrix.platform}} binaries
run: dart cli/cli.dart build ${{matrix.platform}}
env:
CHANNEL: ${{inputs.channel}}
DOTENV: ${{secrets.DOTENV_RELEASE}}
- uses: actions/upload-artifact@v3
with:
if-no-files-found: error
name: Spotube-Release-Binaries
path: |
build/Spotube-android-all-arch.apk
build/Spotube-playstore-all-arch.aab
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

2
.gitignore vendored
View File

@ -76,3 +76,5 @@ android/key.properties
.fvm/flutter_sdk
**/pb_data
tm.json

View File

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

View File

@ -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",
}
}

170
.vscode/snippets.code-snippets vendored Normal file
View File

@ -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(),",
");"
]
},
}

View File

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

View File

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

104
README.md
View File

@ -7,7 +7,7 @@ eliminating the need for Spotify Premium
Btw it's not just another Electron app 😉
<a href="https://spotube.netlify.app"><img alt="Visit the website" height="56" src="https://cdn.jsdelivr.net/npm/@intergrav/devins-badges@3/assets/cozy/documentation/website_vector.svg"></a>
<a href="https://spotube.krtirtho.dev"><img alt="Visit the website" height="56" src="https://cdn.jsdelivr.net/npm/@intergrav/devins-badges@3/assets/cozy/documentation/website_vector.svg"></a>
<a href="https://discord.gg/uJ94vxB6vg"><img alt="Discord Server" height="56" src="https://cdn.jsdelivr.net/npm/@intergrav/devins-badges@3/assets/cozy/social/discord-plural_vector.svg"></a>
<a href="https://patreon.com/krtirtho"><img alt="Support me on Patron" height="56" src="https://cdn.jsdelivr.net/npm/@intergrav/devins-badges@3/assets/cozy/donate/patreon-singular_vector.svg"></a>
@ -97,12 +97,7 @@ This handy table lists all the methods you can use to install Spotube:
</tr>
<tr>
<td>AppImage</td>
<td>
<a href="https://github.com/KRTirtho/spotube/releases/latest/download/Spotube-linux-x86_64.AppImage">
<img width="220" alt="Download AppImage" src="https://user-images.githubusercontent.com/61944859/169455015-13385466-8901-48fe-ba90-b62d58b0be64.png">
</a>
<p><b>Note:</b> <a href="https://github.com/TheAssassin/AppImageLauncher">AppimageLauncher</a> is required!</p>
</td>
<td>AppImage's lacking stability led to it's temporal removal. More information at https://github.com/KRTirtho/spotube/issues/1082</td>
</tr>
<tr>
<td>Debian/Ubuntu</td>
@ -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.
</details>
<div align="center"><h4>© Copyright Spotube 2024</h4></div>

View File

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

View File

@ -1,3 +1,9 @@
plugins {
id "com.android.application"
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
}
def localProperties = new Properties()
def 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

View File

@ -24,6 +24,11 @@
android:usesCleartextTraffic="true"
android:requestLegacyExternalStorage="true"
>
<!-- Enable Impeller -->
<!-- <meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="true" /> -->
<activity
android:name="com.ryanheise.audioservice.AudioServiceActivity"
android:exported="true"

View File

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

View File

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

View File

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

View File

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

View File

@ -1,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<String> args) {
final file = jsonDecode(
File('untranslated_messages.json').readAsStringSync(),
) as Map<String, dynamic>;
final englishMessages =
jsonDecode(File('lib/l10n/app_en.arb').readAsStringSync())
as Map<String, dynamic>;
final messagesWithValues = <String, dynamic>{};
for (final MapEntry(key: locale, value: messages) in file.entries) {
messagesWithValues[locale] = Map.fromEntries(
messages
.map(
(message) =>
MapEntry<String, dynamic>(message, englishMessages[message]),
)
.toList()
.cast<MapEntry<String, dynamic>>(),
);
}
print(
"Prompt:\n"
"Translate following to their appropriate locale for flutter arb translations files."
" Put the respective new translations in a map of their corresponding locale.",
);
// ignore: avoid_print
print(
const JsonEncoder.withIndent(' ').convert(
args.isNotEmpty ? messagesWithValues[args.first] : messagesWithValues,
),
);
}

View File

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

View File

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

4
cli/README.md Normal file
View File

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

22
cli/cli.dart Normal file
View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

1
devtools_options.yaml Normal file
View File

@ -0,0 +1 @@
extensions:

View File

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

View File

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

View File

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

View File

@ -66,5 +66,11 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true />
<key>NSLocalNetworkUsageDescription</key>
<string>To allow other devices on the network control playback of Spotube securely.</string>
<key>NSBonjourServices</key>
<array>
<string>_spotube._tcp</string>
</array>
</dict>
</plist>

View File

@ -88,7 +88,7 @@ class Assets {
AssetGenImage('assets/user-placeholder.png');
/// List of all assets
List<dynamic> get values => [
static List<dynamic> get values => [
albumPlaceholder,
bengaliPatternsBg,
branding,

View File

@ -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";
}
}

View File

@ -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",
),
)
],
);
}

View File

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

View File

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

View File

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

View File

@ -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: "ትግርኛ",

View File

@ -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()),

View File

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

View File

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

View File

@ -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<List<Track>> 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<ArtistSimple>(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(

View File

@ -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<Album>((page) => page.items ?? const Iterable.empty())
.toList();
}, [albumsQuery.pages]);
final albums = albumsQuery.asData?.value.items ?? [];
final theme = Theme.of(context);
return HorizontalPlaybuttonCardView<Album>(
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,

View File

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

View File

@ -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);
},
),
),
],
),
);
}
}

View File

@ -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),
),
);
},
),
],
);
}
}

View File

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

View File

@ -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<Page<PlaylistSimple>>()
.expand((page) => page.items ?? const <PlaylistSimple>[]),
[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<PlaylistSimple>(
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,
),
);
}

View File

@ -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,
},
),
),
),
);
},
);
}
}

View File

@ -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<List<List<SpotifyFriendActivity>>>(
[],
(previousValue, element) {
if (previousValue.isEmpty) {
final friendGroup = useMemoized(
() => friends.fold<List<List<SpotifyFriendActivity>>>(
[],
(previousValue, element) {
if (previousValue.isEmpty) {
return [
[element]
];
}
final lastGroup = previousValue.last;
if (lastGroup.length < groupCount) {
return [
...previousValue.sublist(0, previousValue.length - 1),
[...lastGroup, element]
];
}
return [
...previousValue,
[element]
];
}
final lastGroup = previousValue.last;
if (lastGroup.length < groupCount) {
return [
...previousValue.sublist(0, previousValue.length - 1),
[...lastGroup, element]
];
}
return [
...previousValue,
[element]
];
},
},
),
[friends, groupCount],
);
if (!friendsQuery.isLoading &&
(!friendsQuery.hasData || friendsQuery.data!.friends.isEmpty)) {
if (friendsQuery.isLoading ||
friendsQuery.asData?.value.friends.isEmpty == true ||
auth == null) {
return const SliverToBoxAdapter(
child: SizedBox.shrink(),
);

View File

@ -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, dynamic>(
"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, dynamic>(
"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,
);
}

View File

@ -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() ??
<Category>[],
[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() ??
<Category>[];
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),

View File

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

View File

@ -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<Page<AlbumSimple>>()
.expand((page) => page.items ?? const <AlbumSimple>[])
.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<Album>(
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,
);
}
}

View File

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

View File

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

View File

@ -25,7 +25,7 @@ class MultiSelectField<T> 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<T> extends HookWidget {
this.dialogTitle,
this.helperText,
this.enabled = true,
}) : super(key: key);
});
Widget defaultSelectedOptionBuilder(T option) {
return Chip(
@ -71,7 +71,7 @@ class MultiSelectField<T> extends HookWidget {
: theme.colorScheme.onSurface.withOpacity(0.1),
),
),
mouseCursor: MaterialStateMouseCursor.textable,
mouseCursor: WidgetStateMouseCursor.textable,
onPressed: !enabled
? null
: () async {
@ -134,14 +134,14 @@ class _MultiSelectDialog<T> 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) {

View File

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

View File

@ -12,12 +12,12 @@ class RecommendationAttributeFields extends HookWidget {
final Map<String, RecommendationAttribute>? 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) {

View File

@ -26,7 +26,7 @@ class SeedsMultiAutocomplete<T extends Object> 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<T extends Object> extends HookWidget {
this.inputDecoration,
this.enabled = true,
this.selectedItemDisplayType = SelectedItemDisplayType.wrap,
}) : super(key: key);
});
@override
Widget build(BuildContext context) {

View File

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

View File

@ -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 ?? <AlbumSimple>[]),
[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,
);
},
);
}),
),
],
),
),
),

View File

@ -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(),
),
),
),
),
),
),
),
),
);
}
}

View File

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

View File

@ -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 ?? <Artist>[],
subtitle: ArtistLink(
artists: track.artists ?? <Artist>[],
mainAxisAlignment: WrapAlignment.start,
),
trailing: isQueryingSourceInfo

View File

@ -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<List<LocalTrack>>((ref) async {
try {
if (kIsWeb) return [];
final downloadLocation = ref.watch(
userPreferencesProvider.select((s) => s.downloadLocation),
);
if (downloadLocation.isEmpty) return [];
final downloadDir = Directory(downloadLocation);
if (!await downloadDir.exists()) {
await downloadDir.create(recursive: true);
return [];
}
final entities = downloadDir.listSync(recursive: true);
final filesWithMetadata = (await Future.wait(
entities.map((e) => File(e.path)).where((file) {
final mimetype = lookupMimeType(file.path);
return mimetype != null && supportedAudioTypes.contains(mimetype);
}).map(
(file) async {
try {
final metadata = await MetadataGod.readMetadata(file: file.path);
final imageFile = File(join(
(await getTemporaryDirectory()).path,
"spotube",
basenameWithoutExtension(file.path) +
imgMimeToExt[metadata.picture?.mimeType ?? "image/jpeg"]!,
));
if (!await imageFile.exists() && metadata.picture != null) {
await imageFile.create(recursive: true);
await imageFile.writeAsBytes(
metadata.picture?.data ?? [],
mode: FileMode.writeOnly,
);
}
return {"metadata": metadata, "file": file, "art": imageFile.path};
} catch (e, stack) {
if (e is FfiException) {
return {"file": file};
}
Catcher2.reportCheckedError(e, stack);
return {};
}
},
),
))
.where((e) => e.isNotEmpty)
.toList();
final tracks = filesWithMetadata
.map(
(fileWithMetadata) => LocalTrack.fromTrack(
track: 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<void> playLocalTracks(
WidgetRef ref,
List<LocalTrack> 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>(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<Artist>(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],
);
},
),
),
],
),
);
});
}
}

View File

@ -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() ?? <PlaylistSimple>[]),
[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),

View File

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

View File

@ -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);
},
);
}),
),
),
],

View File

@ -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<Artist>(
element.artists ?? []) ==
TypeConversionUtils.artists_X_String<Artist>(
playlist.activeTrack?.artists ?? []),
element.artists?.asString() ==
playlist.activeTrack?.artists?.asString(),
) ==
true;
}, [localTracks, playlist.activeTrack]);

View File

@ -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,
},
);
},
);
}),

View File

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

View File

@ -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<void> Function(Track track) onJump;
final Future<void> Function(String trackId) onRemove;
final Future<void> Function(int oldIndex, int newIndex) onReorder;
final Future<void> 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<int>(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),
],
),
],
),
),
),
),
),
),
);
},
);
}
}

View File

@ -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<Artist>(
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);
},

View File

@ -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<Artist>(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,
]
: <SourceInfo>[],
[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,

View File

@ -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<double> 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);
}
},
),

View File

@ -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<List<TrackSimple>?>(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<List<Track>> fetchAllTracks() async {
Future<List<Track>> 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<List<Track>, 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<List<Track>> fetchAllTracks() async {
final initialTracks = await fetchInitialTracks();
if (playlist.id == 'user-liked-tracks') {
return initialTracks;
}
return ref.read(playlistTracksProvider(playlist.id!).notifier).fetchAll();
}
return 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<Track> 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!));
},
),
);

View File

@ -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<String> trackIds;
final String? playlistId;
PlaylistCreateDialog({
Key? key,
super.key,
this.trackIds = const [],
this.playlistId,
}) : super(key: key);
});
final formKey = GlobalKey<FormState>();
@ -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 ?? <PlaylistSimple>[])
() => 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<void> 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(

View File

@ -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);
},
);
}),
)
],
)
),
],
),
),

View File

@ -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);
},
],
),
],
),

View File

@ -3,55 +3,54 @@ import 'dart:ui';
import 'package:curved_navigation_bar/curved_navigation_bar.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/side_bar_tiles.dart';
import 'package:spotube/components/root/sidebar.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/hooks/utils/use_brightness_value.dart';
import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
import 'package:spotube/utils/service_utils.dart';
final navigationPanelHeight = StateProvider<double>((ref) => 50);
class SpotubeNavigationBar extends HookConsumerWidget {
final int? selectedIndex;
final void Function(int) onSelectedIndexChanged;
const SpotubeNavigationBar({
required this.selectedIndex,
required this.onSelectedIndexChanged,
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<int>(selectedIndex ?? 0);
final buttonColor = useBrightnessValue(
theme.colorScheme.inversePrimary,
theme.colorScheme.primary.withOpacity(0.2),
);
final navbarTileList =
useMemoized(() => getNavbarTileList(context.l10n), [context.l10n]);
final navbarTileList = useMemoized(
() => getNavbarTileList(context.l10n),
[context.l10n],
);
final panelHeight = ref.watch(navigationPanelHeight);
useEffect(() {
if (selectedIndex != null) {
insideSelectedIndex.value = selectedIndex!;
}
return null;
}, [selectedIndex]);
final selectedIndex = useMemoized(() {
final index = navbarTileList.indexWhere(
(e) => routerState.namedLocation(e.name) == routerState.matchedLocation,
);
return index == -1 ? 0 : index;
}, [navbarTileList, routerState.matchedLocation]);
if (layoutMode == LayoutMode.extended ||
(mediaQuery.mdAndUp && layoutMode == LayoutMode.adaptive) ||
@ -69,7 +68,7 @@ class SpotubeNavigationBar extends HookConsumerWidget {
backgroundColor:
theme.colorScheme.secondaryContainer.withOpacity(0.72),
buttonBackgroundColor: buttonColor,
color: theme.colorScheme.background,
color: theme.colorScheme.surface,
height: panelHeight,
animationDuration: const Duration(milliseconds: 350),
items: navbarTileList.map(
@ -91,14 +90,9 @@ class SpotubeNavigationBar extends HookConsumerWidget {
});
},
).toList(),
index: insideSelectedIndex.value,
index: selectedIndex,
onTap: (i) {
insideSelectedIndex.value = i;
if (navbarTileList[i].id == "settings") {
Sidebar.goToSettings(context);
return;
}
onSelectedIndexChanged(i);
ServiceUtils.navigateNamed(context, navbarTileList[i].name);
},
),
),

View File

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

View File

@ -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<SpotubeColor> 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,
];

View File

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

View File

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

View File

@ -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<T> extends HookWidget {
@ -38,11 +39,22 @@ class AdaptiveSelectTile<T> extends HookWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
final rawControl = DropdownButton<T>(
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<T>(
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

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