mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 16:05:18 +00:00
commit
2be84ec4ee
@ -1,177 +0,0 @@
|
|||||||
version: 2.1
|
|
||||||
|
|
||||||
orbs:
|
|
||||||
gh: circleci/github-cli@2.2.0
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
flutter_linux_arm:
|
|
||||||
machine:
|
|
||||||
image: ubuntu-2204:current
|
|
||||||
resource_class: arm.medium
|
|
||||||
parameters:
|
|
||||||
version:
|
|
||||||
type: string
|
|
||||||
default: 3.1.1
|
|
||||||
channel:
|
|
||||||
type: enum
|
|
||||||
enum:
|
|
||||||
- release
|
|
||||||
- nightly
|
|
||||||
default: release
|
|
||||||
github_run_number:
|
|
||||||
type: string
|
|
||||||
default: "0"
|
|
||||||
dry_run:
|
|
||||||
type: boolean
|
|
||||||
default: true
|
|
||||||
steps:
|
|
||||||
- checkout
|
|
||||||
- gh/setup
|
|
||||||
|
|
||||||
- run:
|
|
||||||
name: Get current date
|
|
||||||
command: |
|
|
||||||
echo "export CURRENT_DATE=$(date +%Y-%m-%d)" >> $BASH_ENV
|
|
||||||
|
|
||||||
- run:
|
|
||||||
name: Install dependencies
|
|
||||||
command: |
|
|
||||||
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 zip rpm
|
|
||||||
|
|
||||||
- run:
|
|
||||||
name: Install Flutter
|
|
||||||
command: |
|
|
||||||
git clone https://github.com/flutter/flutter.git
|
|
||||||
cd flutter && git checkout stable && cd ..
|
|
||||||
export PATH="$PATH:`pwd`/flutter/bin"
|
|
||||||
flutter precache
|
|
||||||
flutter doctor -v
|
|
||||||
|
|
||||||
- run:
|
|
||||||
name: Install AppImageTool
|
|
||||||
command: |
|
|
||||||
wget -O appimagetool "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-aarch64.AppImage"
|
|
||||||
chmod +x appimagetool
|
|
||||||
mv appimagetool flutter/bin
|
|
||||||
|
|
||||||
- persist_to_workspace:
|
|
||||||
root: flutter
|
|
||||||
paths:
|
|
||||||
- .
|
|
||||||
|
|
||||||
- when:
|
|
||||||
condition:
|
|
||||||
equal: [<< parameters.channel >>, nightly]
|
|
||||||
steps:
|
|
||||||
- run:
|
|
||||||
name: Replace pubspec version and BUILD_VERSION Env (nightly)
|
|
||||||
command: |
|
|
||||||
curl -sS https://webi.sh/yq | sh
|
|
||||||
yq -i '.version |= sub("\+\d+", "+<< parameters.channel >>.")' pubspec.yaml
|
|
||||||
yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml
|
|
||||||
echo 'export BUILD_VERSION="<< parameters.version >>+<< parameters.channel >>.<< parameters.github_run_number >>"' >> $BASH_ENV
|
|
||||||
|
|
||||||
- when:
|
|
||||||
condition:
|
|
||||||
equal: [<< parameters.channel >>, release]
|
|
||||||
steps:
|
|
||||||
- run: echo 'export BUILD_VERSION="<< parameters.version >>"' >> $BASH_ENV
|
|
||||||
|
|
||||||
- run:
|
|
||||||
name: Generate .env file
|
|
||||||
command: |
|
|
||||||
echo "SPOTIFY_SECRETS=${SPOTIFY_SECRETS}" >> .env
|
|
||||||
|
|
||||||
- run:
|
|
||||||
name: Replace Version in files
|
|
||||||
command: |
|
|
||||||
sed -i 's|%{{APPDATA_RELEASE}}%|<release version="${BUILD_VERSION}" date="${CURRENT_DATE}" />|' linux/com.github.KRTirtho.Spotube.appdata.xml
|
|
||||||
echo "build_arch: aarch64" >> linux/packaging/rpm/make_config.yaml
|
|
||||||
|
|
||||||
- run:
|
|
||||||
name: Build secrets
|
|
||||||
command: |
|
|
||||||
export PATH="$PATH:`pwd`/flutter/bin"
|
|
||||||
flutter config --enable-linux-desktop
|
|
||||||
flutter pub get
|
|
||||||
dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns
|
|
||||||
|
|
||||||
- run:
|
|
||||||
name: Build Flutter app
|
|
||||||
command: |
|
|
||||||
export PATH="$PATH:`pwd`/flutter/bin"
|
|
||||||
export PATH="$PATH":"$HOME/.pub-cache/bin"
|
|
||||||
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=appimage
|
|
||||||
flutter_distributor package --platform=linux --targets=rpm
|
|
||||||
|
|
||||||
- when:
|
|
||||||
condition:
|
|
||||||
equal: [<< parameters.channel >>, nightly]
|
|
||||||
steps:
|
|
||||||
- run: make tar VERSION=nightly ARCH=arm64 PKG_ARCH=aarch64
|
|
||||||
|
|
||||||
- when:
|
|
||||||
condition:
|
|
||||||
equal: [<< parameters.channel >>, release]
|
|
||||||
steps:
|
|
||||||
- run: make tar VERSION=${BUILD_VERSION} ARCH=arm64 PKG_ARCH=aarch64
|
|
||||||
|
|
||||||
- run:
|
|
||||||
name: Move artifacts
|
|
||||||
command: |
|
|
||||||
mkdir bundle
|
|
||||||
mv build/spotube-linux-*-aarch64.tar.xz bundle/
|
|
||||||
mv dist/**/spotube-*-linux.deb bundle/Spotube-linux-aarch64.deb
|
|
||||||
mv dist/**/spotube-*-linux.rpm bundle/Spotube-linux-aarch64.rpm
|
|
||||||
mv dist/**/spotube-*-linux.AppImage bundle/Spotube-linux-aarch64.AppImage
|
|
||||||
zip -r Spotube-linux-aarch64.zip bundle
|
|
||||||
|
|
||||||
- store_artifacts:
|
|
||||||
path: Spotube-linux-aarch64.zip
|
|
||||||
|
|
||||||
- when:
|
|
||||||
condition:
|
|
||||||
and:
|
|
||||||
- equal: [<< parameters.dry_run >>, false]
|
|
||||||
- equal: [<< parameters.channel >>, release]
|
|
||||||
steps:
|
|
||||||
- run:
|
|
||||||
name: Upload to release (release)
|
|
||||||
command: gh release upload v<< parameters.version >> bundle/* --clobber
|
|
||||||
|
|
||||||
- when:
|
|
||||||
condition:
|
|
||||||
and:
|
|
||||||
- equal: [<< parameters.dry_run >>, false]
|
|
||||||
- equal: [<< parameters.channel >>, nightly]
|
|
||||||
steps:
|
|
||||||
- run:
|
|
||||||
name: Upload to release (nightly)
|
|
||||||
command: gh release upload nightly bundle/* --clobber
|
|
||||||
|
|
||||||
parameters:
|
|
||||||
GHA_Actor:
|
|
||||||
type: string
|
|
||||||
default: ""
|
|
||||||
GHA_Action:
|
|
||||||
type: string
|
|
||||||
default: ""
|
|
||||||
GHA_Event:
|
|
||||||
type: string
|
|
||||||
default: ""
|
|
||||||
GHA_Meta:
|
|
||||||
type: string
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
workflows:
|
|
||||||
build_flutter_for_arm_workflow:
|
|
||||||
when: << pipeline.parameters.GHA_Action >>
|
|
||||||
jobs:
|
|
||||||
- flutter_linux_arm:
|
|
||||||
context:
|
|
||||||
- org-global
|
|
||||||
- GITHUB_CREDS
|
|
@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"flutterSdkVersion": "3.19.6",
|
"flutterSdkVersion": "3.22.3",
|
||||||
"flavors": {}
|
"flavors": {}
|
||||||
}
|
}
|
4
.github/Dockerfile
vendored
4
.github/Dockerfile
vendored
@ -1,6 +1,6 @@
|
|||||||
ARG FLUTTER_VERSION
|
ARG FLUTTER_VERSION
|
||||||
|
|
||||||
FROM --platform=linux/arm64 krtirtho/flutter_distributor_arm64:${FLUTTER_VERSION}
|
FROM --platform=linux/arm64 krtirtho/flutter_distributor:${FLUTTER_VERSION}
|
||||||
|
|
||||||
ARG BUILD_VERSION
|
ARG BUILD_VERSION
|
||||||
|
|
||||||
@ -10,6 +10,8 @@ COPY . .
|
|||||||
|
|
||||||
RUN chown -R $(whoami) /app
|
RUN chown -R $(whoami) /app
|
||||||
|
|
||||||
|
RUN rustup target add aarch64-unknown-linux-gnu
|
||||||
|
|
||||||
RUN flutter pub get
|
RUN flutter pub get
|
||||||
|
|
||||||
RUN alias dpkg-deb="dpkg-deb --Zxz" &&\
|
RUN alias dpkg-deb="dpkg-deb --Zxz" &&\
|
||||||
|
23
.github/Dockerfile.flutter_distributor
vendored
23
.github/Dockerfile.flutter_distributor
vendored
@ -1,23 +0,0 @@
|
|||||||
FROM --platform=linux/arm64 ubuntu:22.04
|
|
||||||
|
|
||||||
ARG FLUTTER_VERSION
|
|
||||||
|
|
||||||
RUN apt-get clean &&\
|
|
||||||
apt-get update &&\
|
|
||||||
apt-get install -y bash curl file git unzip xz-utils zip libglu1-mesa cmake tar clang ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse libunwind-dev locate patchelf gir1.2-appindicator3-0.1 libappindicator3-1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev mpv libmpv-dev rpm && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /home/flutter
|
|
||||||
|
|
||||||
RUN git clone https://github.com/flutter/flutter.git -b ${FLUTTER_VERSION} --single-branch flutter-sdk
|
|
||||||
|
|
||||||
RUN flutter-sdk/bin/flutter precache
|
|
||||||
|
|
||||||
RUN flutter-sdk/bin/flutter config --no-analytics
|
|
||||||
|
|
||||||
ENV PATH="$PATH:/home/flutter/flutter-sdk/bin"
|
|
||||||
ENV PATH="$PATH:/home/flutter/flutter-sdk/bin/cache/dart-sdk/bin"
|
|
||||||
ENV PATH="$PATH:/home/flutter/.pub-cache/bin"
|
|
||||||
ENV PUB_CACHE="/home/flutter/.pub-cache"
|
|
||||||
|
|
||||||
RUN dart pub global activate flutter_distributor
|
|
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@ -53,7 +53,7 @@ body:
|
|||||||
description: Where did you install Spotube from?
|
description: Where did you install Spotube from?
|
||||||
multiple: true
|
multiple: true
|
||||||
options:
|
options:
|
||||||
- "Website (spotube.netlify.app) or (spotube.krtirtho.dev)"
|
- "Website (spotube.krtirtho.dev)"
|
||||||
- "GitHub Releases (Binary)"
|
- "GitHub Releases (Binary)"
|
||||||
- "GitHub Actions (Nightly Binary)"
|
- "GitHub Actions (Nightly Binary)"
|
||||||
- "Play Store (Android)"
|
- "Play Store (Android)"
|
||||||
|
2
.github/workflows/pr-lint.yml
vendored
2
.github/workflows/pr-lint.yml
vendored
@ -4,7 +4,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
FLUTTER_VERSION: '3.19.6'
|
FLUTTER_VERSION: 3.22.2
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
|
37
.github/workflows/spotube-publish-binary.yml
vendored
37
.github/workflows/spotube-publish-binary.yml
vendored
@ -4,7 +4,7 @@ on:
|
|||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
description: Version to publish (x.x.x)
|
description: Version to publish (x.x.x)
|
||||||
default: 3.7.1
|
default: 3.8.0
|
||||||
required: true
|
required: true
|
||||||
dry_run:
|
dry_run:
|
||||||
description: Dry run
|
description: Dry run
|
||||||
@ -12,10 +12,10 @@ on:
|
|||||||
type: boolean
|
type: boolean
|
||||||
default: true
|
default: true
|
||||||
jobs:
|
jobs:
|
||||||
description: Jobs to run (flathub,aur,winget,chocolatey)
|
description: Jobs to run (flathub,aur,winget,chocolatey,playstore)
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
default: "flathub,aur,winget,chocolatey"
|
default: "flathub,aur,winget,chocolatey,playstore"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
flathub:
|
flathub:
|
||||||
@ -104,3 +104,34 @@ jobs:
|
|||||||
- name: Publish to Chocolatey Repository
|
- name: Publish to Chocolatey Repository
|
||||||
if: ${{ !inputs.dry_run }}
|
if: ${{ !inputs.dry_run }}
|
||||||
run: choco push Spotube-windows-x86_64.nupkg --source https://push.chocolatey.org/
|
run: choco push Spotube-windows-x86_64.nupkg --source https://push.chocolatey.org/
|
||||||
|
|
||||||
|
playstore:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: contains(inputs.jobs, 'playstore')
|
||||||
|
steps:
|
||||||
|
- name: Tagname (workflow dispatch)
|
||||||
|
run: echo 'TAG_NAME=${{inputs.version}}' >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- uses: robinraju/release-downloader@main
|
||||||
|
with:
|
||||||
|
repository: KRTirtho/spotube
|
||||||
|
tag: v${{ env.TAG_NAME }}
|
||||||
|
tarBall: false
|
||||||
|
zipBall: false
|
||||||
|
out-file-path: dist
|
||||||
|
fileName: "Spotube-playstore-all-arch.aab"
|
||||||
|
|
||||||
|
- name: Create service-account.json
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_BASE64 }}" | base64 -d > service-account.json
|
||||||
|
|
||||||
|
- name: Upload Android Release to Play Store
|
||||||
|
if: ${{!inputs.dry_run}}
|
||||||
|
uses: r0adkll/upload-google-play@v1
|
||||||
|
with:
|
||||||
|
serviceAccountJson: ./service-account.json
|
||||||
|
releaseFiles: ./dist/Spotube-playstore-all-arch.aab
|
||||||
|
packageName: oss.krtirtho.spotube
|
||||||
|
track: production
|
||||||
|
status: draft
|
||||||
|
releaseName: ${{ env.TAG_NAME }}
|
12
.github/workflows/spotube-release-binary.yml
vendored
12
.github/workflows/spotube-release-binary.yml
vendored
@ -20,7 +20,7 @@ on:
|
|||||||
description: Dry run without uploading to release
|
description: Dry run without uploading to release
|
||||||
|
|
||||||
env:
|
env:
|
||||||
FLUTTER_VERSION: 3.19.6
|
FLUTTER_VERSION: 3.22.3
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@ -82,6 +82,11 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
if: ${{matrix.platform == 'linux_arm'}}
|
if: ${{matrix.platform == 'linux_arm'}}
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Setup Rust toolchain
|
||||||
|
if: ${{matrix.platform != 'linux_arm'}}
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
toolchain: stable
|
||||||
|
|
||||||
- name: Install ${{matrix.platform}} dependencies
|
- name: Install ${{matrix.platform}} dependencies
|
||||||
run: |
|
run: |
|
||||||
@ -94,6 +99,11 @@ jobs:
|
|||||||
echo '${{ secrets.KEYSTORE }}' | base64 --decode > android/app/upload-keystore.jks
|
echo '${{ secrets.KEYSTORE }}' | base64 --decode > android/app/upload-keystore.jks
|
||||||
echo '${{ secrets.KEY_PROPERTIES }}' > android/key.properties
|
echo '${{ secrets.KEY_PROPERTIES }}' > android/key.properties
|
||||||
|
|
||||||
|
- name: Unessary hosted tools
|
||||||
|
if: ${{matrix.platform == 'linux_arm'}}
|
||||||
|
run: |
|
||||||
|
sudo rm -rf /usr/share/dotnet
|
||||||
|
|
||||||
- name: Build ${{matrix.platform}} binaries
|
- name: Build ${{matrix.platform}} binaries
|
||||||
run: dart cli/cli.dart build ${{matrix.platform}}
|
run: dart cli/cli.dart build ${{matrix.platform}}
|
||||||
env:
|
env:
|
||||||
|
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -17,6 +17,7 @@
|
|||||||
"songlink",
|
"songlink",
|
||||||
"speechiness",
|
"speechiness",
|
||||||
"Spotube",
|
"Spotube",
|
||||||
|
"titlebar",
|
||||||
"winget"
|
"winget"
|
||||||
],
|
],
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
|
37
CHANGELOG.md
37
CHANGELOG.md
@ -2,7 +2,42 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
||||||
|
|
||||||
## [3.7.1](https://github.com/krtirtho/spotube/compare/v3.7.1...v3.7.1) (2024-06-06)
|
## [3.8.0](https://github.com/krtirtho/spotube/compare/v3.7.1...v3.8.0) (2024-06-06)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- translations: make state page's hard coded strings translatable (#1719)
|
||||||
|
- discord: add listening activity type
|
||||||
|
- discord: album art, playing time and play pause support (#1765)
|
||||||
|
- linux: Use XDG_STATE_HOME to storage logs (#1675)
|
||||||
|
- discord rpc for macOS, windows-arm64 and linux-arm64 (#1713)
|
||||||
|
- desktop: implement webview based login
|
||||||
|
- stats: add lazy loading support
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- translations: fix Russian translations (#1696)
|
||||||
|
- ios: permission exception
|
||||||
|
- linux: tray icon wrong name for flatpak
|
||||||
|
- windows: app crashes when no internet
|
||||||
|
- windows: local tracks plays but disabled playback controls
|
||||||
|
- go to track album shows up for local tracks
|
||||||
|
- local track metadata timeout
|
||||||
|
- windows: window stretching #1553
|
||||||
|
- android: app getting killed from background
|
||||||
|
- linux: OS Media control not working for Flatpak #1627
|
||||||
|
- incorrect datatype used for MPRIS position property #1521
|
||||||
|
- Too many artists for a track causing overflows
|
||||||
|
- playlist share button does not work #1639
|
||||||
|
- unescape html escape values #1300
|
||||||
|
- lyrics page doesn't scroll to top after song ends #885
|
||||||
|
- changed source doesn't get saved and uses the wrong once again
|
||||||
|
- null exception in album page navigated from /home
|
||||||
|
- popup menu item opacity
|
||||||
|
- linux: change app id in flatpak environment
|
||||||
|
|
||||||
|
|
||||||
|
## [3.7.1](https://github.com/krtirtho/spotube/compare/v3.7.0...v3.7.1) (2024-06-06)
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
@ -32,7 +32,7 @@ All types of contributions are encouraged and valued. See the [Table of Contents
|
|||||||
This project and everyone participating in it is governed by the
|
This project and everyone participating in it is governed by the
|
||||||
[Spotube Code of Conduct](https://github.com/KRTirtho/spotube/blob/master/CODE_OF_CONDUCT.md).
|
[Spotube Code of Conduct](https://github.com/KRTirtho/spotube/blob/master/CODE_OF_CONDUCT.md).
|
||||||
By participating, you are expected to uphold this code. Please report unacceptable behavior
|
By participating, you are expected to uphold this code. Please report unacceptable behavior
|
||||||
to <>.
|
to krtirtho@gmail.com.
|
||||||
|
|
||||||
## I Have a Question
|
## I Have a Question
|
||||||
|
|
||||||
@ -123,16 +123,16 @@ Do the following:
|
|||||||
- Install Development dependencies in linux
|
- Install Development dependencies in linux
|
||||||
- Debian (>=12/Bookworm)/Ubuntu
|
- Debian (>=12/Bookworm)/Ubuntu
|
||||||
```bash
|
```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 avahi-daemon avahi-discover avahi-utils libnss-mdns mdns-scan
|
$ 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 libwebkit2gtk-4.1-0 libwebkit2gtk-4.1-dev libsoup-3.0-0 libsoup-3.0-dev
|
||||||
```
|
```
|
||||||
- Use `libjsoncpp1` instead of `libjsoncpp25` (for Ubuntu < 22.04)
|
- Use `libjsoncpp1` instead of `libjsoncpp25` (for Ubuntu < 22.04)
|
||||||
- Arch/Manjaro
|
- Arch/Manjaro
|
||||||
```bash
|
```bash
|
||||||
yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify avahi nss-mdns mdns-scan
|
yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify avahi nss-mdns mdns-scan webkit2gtk-4.1 libsoup3
|
||||||
```
|
```
|
||||||
- Fedora
|
- Fedora
|
||||||
```bash
|
```bash
|
||||||
dnf install mpv mpv-devel libappindicator-gtk3 libappindicator-gtk3-devel libsecret libsecret-devel jsoncpp jsoncpp-devel libnotify libnotify-devel avahi mdns-scan nss-mdns
|
dnf install mpv mpv-devel libappindicator-gtk3 libappindicator-gtk3-devel libsecret libsecret-devel jsoncpp jsoncpp-devel libnotify libnotify-devel avahi mdns-scan nss-mdns webkit2gtk4.1 webkit2gtk4.1-devel libsoup3 libsoup3-devel
|
||||||
```
|
```
|
||||||
- Clone the Repo
|
- Clone the Repo
|
||||||
- Create a `.env` in root of the project following the `.env.example` template
|
- Create a `.env` in root of the project following the `.env.example` template
|
||||||
|
@ -31,7 +31,7 @@ if (keystorePropertiesFile.exists()) {
|
|||||||
android {
|
android {
|
||||||
compileSdkVersion 34
|
compileSdkVersion 34
|
||||||
|
|
||||||
ndkVersion "21.4.7075529"
|
ndkVersion "25.1.8937393"
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility JavaVersion.VERSION_1_8
|
sourceCompatibility JavaVersion.VERSION_1_8
|
||||||
|
@ -8,3 +8,10 @@ targets:
|
|||||||
options:
|
options:
|
||||||
any_map: true
|
any_map: true
|
||||||
explicit_to_json: true
|
explicit_to_json: true
|
||||||
|
drift_dev:
|
||||||
|
options:
|
||||||
|
sql:
|
||||||
|
dialect: sqlite
|
||||||
|
options:
|
||||||
|
modules:
|
||||||
|
- json1
|
||||||
|
@ -41,6 +41,25 @@ class WindowsBuildCommand extends Command with BuildCommandCommonSteps {
|
|||||||
await bootstrap();
|
await bootstrap();
|
||||||
await innoDependInstall();
|
await innoDependInstall();
|
||||||
|
|
||||||
|
final runnerRCFile = File(
|
||||||
|
join(cwd.path, "windows", "runner", "Runner.rc"),
|
||||||
|
);
|
||||||
|
|
||||||
|
runnerRCFile.writeAsStringSync(
|
||||||
|
runnerRCFile
|
||||||
|
.readAsStringSync()
|
||||||
|
.replaceAll("%{{SPOTUBE_VERSION}}%", versionWithoutBuildNumber)
|
||||||
|
.replaceAll(
|
||||||
|
"%{{SPOTUBE_VERSION_AS_NUMBER}}%",
|
||||||
|
[
|
||||||
|
pubspec.version!.major,
|
||||||
|
pubspec.version!.minor,
|
||||||
|
pubspec.version!.patch,
|
||||||
|
0
|
||||||
|
].join(","),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
await shell.run(
|
await shell.run(
|
||||||
"flutter_distributor package --platform=windows --targets=exe --skip-clean",
|
"flutter_distributor package --platform=windows --targets=exe --skip-clean",
|
||||||
);
|
);
|
||||||
|
@ -37,7 +37,7 @@ class InstallDependenciesCommand extends Command {
|
|||||||
await shell.run(
|
await shell.run(
|
||||||
"""
|
"""
|
||||||
sudo apt-get update -y
|
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
|
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 libwebkit2gtk-4.1-0 libwebkit2gtk-4.1-dev libsoup-3.0-0 libsoup-3.0-dev
|
||||||
""",
|
""",
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
@ -58,6 +58,11 @@ class InstallDependenciesCommand extends Command {
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case "ios":
|
case "ios":
|
||||||
|
await shell.run(
|
||||||
|
"""
|
||||||
|
rustup target add aarch64-apple-ios
|
||||||
|
""",
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case "android":
|
case "android":
|
||||||
await shell.run(
|
await shell.run(
|
||||||
|
@ -49,6 +49,8 @@ PODS:
|
|||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
- flutter_broadcasts (0.0.1):
|
- flutter_broadcasts (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- flutter_discord_rpc (0.0.1):
|
||||||
|
- Flutter
|
||||||
- flutter_inappwebview_ios (0.0.1):
|
- flutter_inappwebview_ios (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- flutter_inappwebview_ios/Core (= 0.0.1)
|
- flutter_inappwebview_ios/Core (= 0.0.1)
|
||||||
@ -58,17 +60,12 @@ PODS:
|
|||||||
- OrderedSet (~> 5.0)
|
- OrderedSet (~> 5.0)
|
||||||
- flutter_keyboard_visibility (0.0.1):
|
- flutter_keyboard_visibility (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- flutter_mailer (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- flutter_native_splash (0.0.1):
|
- flutter_native_splash (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- flutter_secure_storage (6.0.0):
|
- flutter_secure_storage (6.0.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
- flutter_sharing_intent (0.0.1):
|
- flutter_sharing_intent (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- fluttertoast (0.0.2):
|
|
||||||
- Flutter
|
|
||||||
- Toast
|
|
||||||
- image_picker_ios (0.0.1):
|
- image_picker_ios (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- integration_test (0.0.1):
|
- integration_test (0.0.1):
|
||||||
@ -77,7 +74,8 @@ PODS:
|
|||||||
- Flutter
|
- Flutter
|
||||||
- media_kit_native_event_loop (1.0.0):
|
- media_kit_native_event_loop (1.0.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
- metadata_god (0.0.1)
|
- metadata_god (0.0.1):
|
||||||
|
- Flutter
|
||||||
- OrderedSet (5.0.0)
|
- OrderedSet (5.0.0)
|
||||||
- package_info_plus (0.4.5):
|
- package_info_plus (0.4.5):
|
||||||
- Flutter
|
- Flutter
|
||||||
@ -95,8 +93,22 @@ PODS:
|
|||||||
- sqflite (0.0.3):
|
- sqflite (0.0.3):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- "sqlite3 (3.46.0+1)":
|
||||||
|
- "sqlite3/common (= 3.46.0+1)"
|
||||||
|
- "sqlite3/common (3.46.0+1)"
|
||||||
|
- "sqlite3/fts5 (3.46.0+1)":
|
||||||
|
- sqlite3/common
|
||||||
|
- "sqlite3/perf-threadsafe (3.46.0+1)":
|
||||||
|
- sqlite3/common
|
||||||
|
- "sqlite3/rtree (3.46.0+1)":
|
||||||
|
- sqlite3/common
|
||||||
|
- sqlite3_flutter_libs (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- sqlite3 (~> 3.46.0)
|
||||||
|
- sqlite3/fts5
|
||||||
|
- sqlite3/perf-threadsafe
|
||||||
|
- sqlite3/rtree
|
||||||
- SwiftyGif (5.4.4)
|
- SwiftyGif (5.4.4)
|
||||||
- Toast (4.0.0)
|
|
||||||
- url_launcher_ios (0.0.1):
|
- url_launcher_ios (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
|
||||||
@ -110,13 +122,12 @@ DEPENDENCIES:
|
|||||||
- file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`)
|
- file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`)
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
- flutter_broadcasts (from `.symlinks/plugins/flutter_broadcasts/ios`)
|
- flutter_broadcasts (from `.symlinks/plugins/flutter_broadcasts/ios`)
|
||||||
|
- flutter_discord_rpc (from `.symlinks/plugins/flutter_discord_rpc/ios`)
|
||||||
- flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`)
|
- flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`)
|
||||||
- flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/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`)
|
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
||||||
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
||||||
- flutter_sharing_intent (from `.symlinks/plugins/flutter_sharing_intent/ios`)
|
- flutter_sharing_intent (from `.symlinks/plugins/flutter_sharing_intent/ios`)
|
||||||
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
|
|
||||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||||
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
||||||
- media_kit_libs_ios_audio (from `.symlinks/plugins/media_kit_libs_ios_audio/ios`)
|
- media_kit_libs_ios_audio (from `.symlinks/plugins/media_kit_libs_ios_audio/ios`)
|
||||||
@ -127,6 +138,7 @@ DEPENDENCIES:
|
|||||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||||
- sqflite (from `.symlinks/plugins/sqflite/darwin`)
|
- sqflite (from `.symlinks/plugins/sqflite/darwin`)
|
||||||
|
- sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/ios`)
|
||||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
@ -135,8 +147,8 @@ SPEC REPOS:
|
|||||||
- DKPhotoGallery
|
- DKPhotoGallery
|
||||||
- OrderedSet
|
- OrderedSet
|
||||||
- SDWebImage
|
- SDWebImage
|
||||||
|
- sqlite3
|
||||||
- SwiftyGif
|
- SwiftyGif
|
||||||
- Toast
|
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
app_links:
|
app_links:
|
||||||
@ -157,20 +169,18 @@ EXTERNAL SOURCES:
|
|||||||
:path: Flutter
|
:path: Flutter
|
||||||
flutter_broadcasts:
|
flutter_broadcasts:
|
||||||
:path: ".symlinks/plugins/flutter_broadcasts/ios"
|
:path: ".symlinks/plugins/flutter_broadcasts/ios"
|
||||||
|
flutter_discord_rpc:
|
||||||
|
:path: ".symlinks/plugins/flutter_discord_rpc/ios"
|
||||||
flutter_inappwebview_ios:
|
flutter_inappwebview_ios:
|
||||||
:path: ".symlinks/plugins/flutter_inappwebview_ios/ios"
|
:path: ".symlinks/plugins/flutter_inappwebview_ios/ios"
|
||||||
flutter_keyboard_visibility:
|
flutter_keyboard_visibility:
|
||||||
:path: ".symlinks/plugins/flutter_keyboard_visibility/ios"
|
:path: ".symlinks/plugins/flutter_keyboard_visibility/ios"
|
||||||
flutter_mailer:
|
|
||||||
:path: ".symlinks/plugins/flutter_mailer/ios"
|
|
||||||
flutter_native_splash:
|
flutter_native_splash:
|
||||||
:path: ".symlinks/plugins/flutter_native_splash/ios"
|
:path: ".symlinks/plugins/flutter_native_splash/ios"
|
||||||
flutter_secure_storage:
|
flutter_secure_storage:
|
||||||
:path: ".symlinks/plugins/flutter_secure_storage/ios"
|
:path: ".symlinks/plugins/flutter_secure_storage/ios"
|
||||||
flutter_sharing_intent:
|
flutter_sharing_intent:
|
||||||
:path: ".symlinks/plugins/flutter_sharing_intent/ios"
|
:path: ".symlinks/plugins/flutter_sharing_intent/ios"
|
||||||
fluttertoast:
|
|
||||||
:path: ".symlinks/plugins/fluttertoast/ios"
|
|
||||||
image_picker_ios:
|
image_picker_ios:
|
||||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||||
integration_test:
|
integration_test:
|
||||||
@ -191,6 +201,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||||
sqflite:
|
sqflite:
|
||||||
:path: ".symlinks/plugins/sqflite/darwin"
|
:path: ".symlinks/plugins/sqflite/darwin"
|
||||||
|
sqlite3_flutter_libs:
|
||||||
|
:path: ".symlinks/plugins/sqlite3_flutter_libs/ios"
|
||||||
url_launcher_ios:
|
url_launcher_ios:
|
||||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||||
|
|
||||||
@ -206,18 +218,17 @@ SPEC CHECKSUMS:
|
|||||||
file_selector_ios: 78baf21d03f1e37a7df97bb2494f9cd86de8fa5d
|
file_selector_ios: 78baf21d03f1e37a7df97bb2494f9cd86de8fa5d
|
||||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||||
flutter_broadcasts: 3ece15b27d8ccbe2132c3df303e7c3401feab882
|
flutter_broadcasts: 3ece15b27d8ccbe2132c3df303e7c3401feab882
|
||||||
|
flutter_discord_rpc: e1c342f29ceb9dd76cdc01db59a70c93bb4d9ec5
|
||||||
flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0
|
flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0
|
||||||
flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069
|
flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069
|
||||||
flutter_mailer: 2ef5a67087bc8c6c4cefd04a178bf1ae2c94cd83
|
|
||||||
flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
|
flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
|
||||||
flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be
|
flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be
|
||||||
flutter_sharing_intent: e35380d0e1501d7111dbb7e46d5ac6339da6da98
|
flutter_sharing_intent: e35380d0e1501d7111dbb7e46d5ac6339da6da98
|
||||||
fluttertoast: 9f2f8e81bb5ce18facb9748d7855bf5a756fe3db
|
|
||||||
image_picker_ios: b545a5f16c0fa88e3ecbbce3ed4de45567a8ec18
|
image_picker_ios: b545a5f16c0fa88e3ecbbce3ed4de45567a8ec18
|
||||||
integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4
|
integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4
|
||||||
media_kit_libs_ios_audio: 8f39d96a9c630685dfb844c289bd1d114c486fb3
|
media_kit_libs_ios_audio: 8f39d96a9c630685dfb844c289bd1d114c486fb3
|
||||||
media_kit_native_event_loop: 99111eded5acbdc9c2738021ea6550dd36ca8837
|
media_kit_native_event_loop: 99111eded5acbdc9c2738021ea6550dd36ca8837
|
||||||
metadata_god: eceae399d0020475069a5cebc35943ce8562b5d7
|
metadata_god: 4bbd8523cdb5d42c5e59d2fabad01ff8f4bc53f9
|
||||||
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
|
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
|
||||||
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
|
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
|
||||||
path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c
|
path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c
|
||||||
@ -225,8 +236,9 @@ SPEC CHECKSUMS:
|
|||||||
SDWebImage: a81bbb3ba4ea5f810f4069c68727cb118467a04a
|
SDWebImage: a81bbb3ba4ea5f810f4069c68727cb118467a04a
|
||||||
shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695
|
shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695
|
||||||
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
|
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
|
||||||
|
sqlite3: 292c3e1bfe89f64e51ea7fc7dab9182a017c8630
|
||||||
|
sqlite3_flutter_libs: 0d611efdf6d1c9297d5ab03dab21b75aeebdae31
|
||||||
SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f
|
SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f
|
||||||
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
|
|
||||||
url_launcher_ios: 6116280ddcfe98ab8820085d8d76ae7449447586
|
url_launcher_ios: 6116280ddcfe98ab8820085d8d76ae7449447586
|
||||||
|
|
||||||
PODFILE CHECKSUM: 0659b64ac6e9e96b61d8550decffa8bff51a957e
|
PODFILE CHECKSUM: 0659b64ac6e9e96b61d8550decffa8bff51a957e
|
||||||
|
@ -1,21 +0,0 @@
|
|||||||
abstract class LocalStorageKeys {
|
|
||||||
static String saveTrackLyrics = 'save_track_lyrics';
|
|
||||||
static String recommendationMarket = 'recommendation_market';
|
|
||||||
static String ytSearchFormate = 'youtube_search_format';
|
|
||||||
|
|
||||||
static String clientId = 'clientId';
|
|
||||||
static String clientSecret = 'clientSecret';
|
|
||||||
static String accessToken = 'accessToken';
|
|
||||||
static String refreshToken = 'refreshToken';
|
|
||||||
static String expiration = "expiration";
|
|
||||||
static String geniusAccessToken = "genius_access_token";
|
|
||||||
|
|
||||||
static String themeMode = "theme_mode";
|
|
||||||
static String nextTrackHotKey = "next_track_hot_key";
|
|
||||||
static String prevTrackHotKey = "prev_track_hot_key";
|
|
||||||
static String playPauseHotKey = "play_pause_hot_key";
|
|
||||||
|
|
||||||
static String volume = "volume";
|
|
||||||
|
|
||||||
static String windowSizeInfo = "window_size_info";
|
|
||||||
}
|
|
@ -1,6 +1,8 @@
|
|||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
|
import 'package:spotube/models/database/database.dart';
|
||||||
import 'package:spotube/models/spotify/home_feed.dart';
|
import 'package:spotube/models/spotify/home_feed.dart';
|
||||||
import 'package:spotube/models/spotify_friends.dart';
|
import 'package:spotube/models/spotify_friends.dart';
|
||||||
|
import 'package:spotube/provider/history/summary.dart';
|
||||||
|
|
||||||
abstract class FakeData {
|
abstract class FakeData {
|
||||||
static final Image image = Image()
|
static final Image image = Image()
|
||||||
@ -222,4 +224,36 @@ abstract class FakeData {
|
|||||||
)
|
)
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
static const historySummary = PlaybackHistorySummary(
|
||||||
|
albums: 1,
|
||||||
|
artists: 1,
|
||||||
|
duration: Duration(seconds: 1),
|
||||||
|
playlists: 1,
|
||||||
|
tracks: 1,
|
||||||
|
fees: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
static final historyRecentlyPlayedPlaylist = HistoryTableData(
|
||||||
|
id: 0,
|
||||||
|
type: HistoryEntryType.track,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
itemId: "1",
|
||||||
|
data: playlist.toJson(),
|
||||||
|
);
|
||||||
|
|
||||||
|
static final historyRecentlyPlayedAlbum = HistoryTableData(
|
||||||
|
id: 0,
|
||||||
|
type: HistoryEntryType.track,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
itemId: "1",
|
||||||
|
data: album.toJson(),
|
||||||
|
);
|
||||||
|
|
||||||
|
static final historyRecentlyPlayedItems = List.generate(
|
||||||
|
10,
|
||||||
|
(index) => index % 2 == 0
|
||||||
|
? historyRecentlyPlayedPlaylist
|
||||||
|
: historyRecentlyPlayedAlbum,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -5,13 +5,12 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:spotube/collections/routes.dart';
|
import 'package:spotube/collections/routes.dart';
|
||||||
import 'package:spotube/components/player/player_controls.dart';
|
import 'package:spotube/modules/player/player_controls.dart';
|
||||||
import 'package:spotube/models/logger.dart';
|
|
||||||
import 'package:spotube/pages/home/home.dart';
|
import 'package:spotube/pages/home/home.dart';
|
||||||
import 'package:spotube/pages/library/library.dart';
|
import 'package:spotube/pages/library/library.dart';
|
||||||
import 'package:spotube/pages/lyrics/lyrics.dart';
|
import 'package:spotube/pages/lyrics/lyrics.dart';
|
||||||
import 'package:spotube/pages/search/search.dart';
|
import 'package:spotube/pages/search/search.dart';
|
||||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
import 'package:spotube/provider/audio_player/querying_track_info.dart';
|
||||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||||
import 'package:spotube/utils/platform.dart';
|
import 'package:spotube/utils/platform.dart';
|
||||||
|
|
||||||
@ -21,8 +20,6 @@ class PlayPauseIntent extends Intent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class PlayPauseAction extends Action<PlayPauseIntent> {
|
class PlayPauseAction extends Action<PlayPauseIntent> {
|
||||||
final logger = getLogger(PlayPauseAction);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
invoke(intent) async {
|
invoke(intent) async {
|
||||||
if (PlayerControls.focusNode.canRequestFocus) {
|
if (PlayerControls.focusNode.canRequestFocus) {
|
||||||
@ -96,8 +93,8 @@ class SeekIntent extends Intent {
|
|||||||
class SeekAction extends Action<SeekIntent> {
|
class SeekAction extends Action<SeekIntent> {
|
||||||
@override
|
@override
|
||||||
invoke(intent) async {
|
invoke(intent) async {
|
||||||
final playlist = intent.ref.read(proxyPlaylistProvider);
|
final isFetchingActiveTrack = intent.ref.read(queryingTrackInfoProvider);
|
||||||
if (playlist.isFetching) {
|
if (isFetchingActiveTrack) {
|
||||||
DirectionalFocusAction().invoke(
|
DirectionalFocusAction().invoke(
|
||||||
DirectionalFocusIntent(
|
DirectionalFocusIntent(
|
||||||
intent.forward ? TraversalDirection.right : TraversalDirection.left,
|
intent.forward ? TraversalDirection.right : TraversalDirection.left,
|
||||||
@ -105,7 +102,7 @@ class SeekAction extends Action<SeekIntent> {
|
|||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
final position = (await audioPlayer.position ?? Duration.zero).inSeconds;
|
final position = audioPlayer.position.inSeconds;
|
||||||
await audioPlayer.seek(
|
await audioPlayer.seek(
|
||||||
Duration(
|
Duration(
|
||||||
seconds: intent.forward ? position + 5 : position - 5,
|
seconds: intent.forward ? position + 5 : position - 5,
|
||||||
|
@ -83,7 +83,7 @@ abstract class LanguageLocals {
|
|||||||
// ),
|
// ),
|
||||||
"eu": const ISOLanguageName(
|
"eu": const ISOLanguageName(
|
||||||
name: "Basque",
|
name: "Basque",
|
||||||
nativeName: "euskara",
|
nativeName: "Euskara",
|
||||||
),
|
),
|
||||||
// "be": const ISOLanguageName(
|
// "be": const ISOLanguageName(
|
||||||
// name: "Belarusian",
|
// name: "Belarusian",
|
||||||
@ -354,8 +354,8 @@ abstract class LanguageLocals {
|
|||||||
// nativeName: "KiKongo",
|
// nativeName: "KiKongo",
|
||||||
// ),
|
// ),
|
||||||
"ko": const ISOLanguageName(
|
"ko": const ISOLanguageName(
|
||||||
name: "Korean",
|
name: "Korean",
|
||||||
nativeName: "한국어 (韓國語), 조선말 (朝鮮語)",
|
nativeName: "한국어 (韓國語), 조선말 (朝鮮語)",
|
||||||
),
|
),
|
||||||
// "ku": const ISOLanguageName(
|
// "ku": const ISOLanguageName(
|
||||||
// name: "Kurdish",
|
// name: "Kurdish",
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import 'package:catcher_2/catcher_2.dart';
|
|
||||||
import 'package:flutter/foundation.dart' hide Category;
|
import 'package:flutter/foundation.dart' hide Category;
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
@ -33,20 +32,17 @@ import 'package:spotube/pages/stats/playlists/playlists.dart';
|
|||||||
import 'package:spotube/pages/stats/stats.dart';
|
import 'package:spotube/pages/stats/stats.dart';
|
||||||
import 'package:spotube/pages/stats/streams/streams.dart';
|
import 'package:spotube/pages/stats/streams/streams.dart';
|
||||||
import 'package:spotube/pages/track/track.dart';
|
import 'package:spotube/pages/track/track.dart';
|
||||||
import 'package:spotube/provider/authentication_provider.dart';
|
import 'package:spotube/provider/authentication/authentication.dart';
|
||||||
import 'package:spotube/services/kv_store/kv_store.dart';
|
import 'package:spotube/services/kv_store/kv_store.dart';
|
||||||
import 'package:spotube/utils/platform.dart';
|
import 'package:spotube/components/spotube_page_route.dart';
|
||||||
import 'package:spotube/components/shared/spotube_page_route.dart';
|
|
||||||
import 'package:spotube/pages/artist/artist.dart';
|
import 'package:spotube/pages/artist/artist.dart';
|
||||||
import 'package:spotube/pages/library/library.dart';
|
import 'package:spotube/pages/library/library.dart';
|
||||||
import 'package:spotube/pages/desktop_login/login_tutorial.dart';
|
|
||||||
import 'package:spotube/pages/desktop_login/desktop_login.dart';
|
|
||||||
import 'package:spotube/pages/lyrics/lyrics.dart';
|
import 'package:spotube/pages/lyrics/lyrics.dart';
|
||||||
import 'package:spotube/pages/root/root_app.dart';
|
import 'package:spotube/pages/root/root_app.dart';
|
||||||
import 'package:spotube/pages/settings/settings.dart';
|
import 'package:spotube/pages/settings/settings.dart';
|
||||||
import 'package:spotube/pages/mobile_login/mobile_login.dart';
|
import 'package:spotube/pages/mobile_login/mobile_login.dart';
|
||||||
|
|
||||||
final rootNavigatorKey = Catcher2.navigatorKey;
|
final rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||||
final shellRouteNavigatorKey = GlobalKey<NavigatorState>();
|
final shellRouteNavigatorKey = GlobalKey<NavigatorState>();
|
||||||
final routerProvider = Provider((ref) {
|
final routerProvider = Provider((ref) {
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
@ -60,11 +56,9 @@ final routerProvider = Provider((ref) {
|
|||||||
path: "/",
|
path: "/",
|
||||||
name: HomePage.name,
|
name: HomePage.name,
|
||||||
redirect: (context, state) async {
|
redirect: (context, state) async {
|
||||||
final authNotifier = ref.read(authenticationProvider.notifier);
|
final auth = await ref.read(authenticationProvider.future);
|
||||||
final json = await authNotifier.box.get(authNotifier.cacheKey);
|
|
||||||
|
|
||||||
if (json?["cookie"] == null &&
|
if (auth == null && !KVStoreService.doneGettingStarted) {
|
||||||
!KVStoreService.doneGettingStarted) {
|
|
||||||
return "/getting-started";
|
return "/getting-started";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -316,16 +310,8 @@ final routerProvider = Provider((ref) {
|
|||||||
path: "/login",
|
path: "/login",
|
||||||
name: WebViewLogin.name,
|
name: WebViewLogin.name,
|
||||||
parentNavigatorKey: rootNavigatorKey,
|
parentNavigatorKey: rootNavigatorKey,
|
||||||
pageBuilder: (context, state) => SpotubePage(
|
|
||||||
child: kIsMobile ? const WebViewLogin() : const DesktopLoginPage(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: "/login-tutorial",
|
|
||||||
name: LoginTutorial.name,
|
|
||||||
parentNavigatorKey: rootNavigatorKey,
|
|
||||||
pageBuilder: (context, state) => const SpotubePage(
|
pageBuilder: (context, state) => const SpotubePage(
|
||||||
child: LoginTutorial(),
|
child: WebViewLogin(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
|
@ -187,7 +187,7 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
|
|||||||
icon: icon ?? const Icon(SpotubeIcons.moreVertical),
|
icon: icon ?? const Icon(SpotubeIcons.moreVertical),
|
||||||
tooltip: tooltip,
|
tooltip: tooltip,
|
||||||
style: theme.iconButtonTheme.style?.copyWith(
|
style: theme.iconButtonTheme.style?.copyWith(
|
||||||
shape: MaterialStatePropertyAll(
|
shape: WidgetStatePropertyAll(
|
||||||
RoundedRectangleBorder(
|
RoundedRectangleBorder(
|
||||||
borderRadius: borderRadius,
|
borderRadius: borderRadius,
|
||||||
),
|
),
|
||||||
@ -226,7 +226,10 @@ class _AdaptivePopSheetListItem<T> extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: IgnorePointer(child: item),
|
child: IconTheme.merge(
|
||||||
|
data: const IconThemeData(opacity: 1),
|
||||||
|
child: IgnorePointer(child: item),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -1,72 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:spotube/extensions/context.dart';
|
|
||||||
|
|
||||||
import 'package:spotube/provider/authentication_provider.dart';
|
|
||||||
|
|
||||||
class TokenLoginForm extends HookConsumerWidget {
|
|
||||||
final void Function()? onDone;
|
|
||||||
const TokenLoginForm({
|
|
||||||
super.key,
|
|
||||||
this.onDone,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, ref) {
|
|
||||||
final authenticationNotifier = ref.watch(authenticationProvider.notifier);
|
|
||||||
final directCodeController = useTextEditingController();
|
|
||||||
|
|
||||||
final isLoading = useState(false);
|
|
||||||
|
|
||||||
return ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(
|
|
||||||
maxWidth: 400,
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
TextField(
|
|
||||||
controller: directCodeController,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: context.l10n.spotify_cookie("\"sp_dc\""),
|
|
||||||
labelText: context.l10n.cookie_name_cookie("sp_dc"),
|
|
||||||
),
|
|
||||||
keyboardType: TextInputType.visiblePassword,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
FilledButton(
|
|
||||||
onPressed: isLoading.value
|
|
||||||
? null
|
|
||||||
: () async {
|
|
||||||
try {
|
|
||||||
isLoading.value = true;
|
|
||||||
if (directCodeController.text.isEmpty) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(context.l10n.fill_in_all_fields),
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final cookieHeader =
|
|
||||||
"sp_dc=${directCodeController.text.trim()}";
|
|
||||||
|
|
||||||
authenticationNotifier.setCredentials(
|
|
||||||
await AuthenticationCredentials.fromCookie(
|
|
||||||
cookieHeader),
|
|
||||||
);
|
|
||||||
if (context.mounted) {
|
|
||||||
onDone?.call();
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Text(context.l10n.submit),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
import 'package:spotube/components/image/universal_image.dart';
|
||||||
import 'package:spotube/extensions/constrains.dart';
|
import 'package:spotube/extensions/constrains.dart';
|
||||||
import 'package:spotube/extensions/context.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
|
|
@ -4,8 +4,8 @@ import 'package:gap/gap.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/components/playlist/playlist_create_dialog.dart';
|
import 'package:spotube/modules/playlist/playlist_create_dialog.dart';
|
||||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
import 'package:spotube/components/image/universal_image.dart';
|
||||||
import 'package:spotube/extensions/context.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
import 'package:spotube/extensions/image.dart';
|
import 'package:spotube/extensions/image.dart';
|
||||||
import 'package:spotube/provider/spotify/spotify.dart';
|
import 'package:spotube/provider/spotify/spotify.dart';
|
@ -15,15 +15,12 @@ class SelectDeviceDialog extends HookConsumerWidget {
|
|||||||
final remoteService = connectClients.asData!.value.resolvedService!;
|
final remoteService = connectClients.asData!.value.resolvedService!;
|
||||||
|
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: const Text("Choose the device:"),
|
title: Text(context.l10n.choose_the_device),
|
||||||
insetPadding: const EdgeInsets.all(16),
|
insetPadding: const EdgeInsets.all(16),
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Text(context.l10n.multiple_device_connected),
|
||||||
"There are multiple device connected.\n"
|
|
||||||
"Choose the device you want this action to take place",
|
|
||||||
),
|
|
||||||
RadioListTile.adaptive(
|
RadioListTile.adaptive(
|
||||||
title: Text(remoteService.name),
|
title: Text(remoteService.name),
|
||||||
value: true,
|
value: true,
|
||||||
@ -33,7 +30,7 @@ class SelectDeviceDialog extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
RadioListTile.adaptive(
|
RadioListTile.adaptive(
|
||||||
title: const Text("This Device"),
|
title: Text(context.l10n.this_device),
|
||||||
value: false,
|
value: false,
|
||||||
groupValue: isRemoteService.value,
|
groupValue: isRemoteService.value,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
@ -2,9 +2,9 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/collections/spotube_icons.dart';
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
import 'package:spotube/components/shared/links/artist_link.dart';
|
import 'package:spotube/components/links/artist_link.dart';
|
||||||
import 'package:spotube/components/shared/links/hyper_link.dart';
|
import 'package:spotube/components/links/hyper_link.dart';
|
||||||
import 'package:spotube/components/shared/links/link_text.dart';
|
import 'package:spotube/components/links/link_text.dart';
|
||||||
import 'package:spotube/extensions/constrains.dart';
|
import 'package:spotube/extensions/constrains.dart';
|
||||||
import 'package:spotube/extensions/context.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
import 'package:spotube/services/sourced_track/sourced_track.dart';
|
import 'package:spotube/services/sourced_track/sourced_track.dart';
|
||||||
@ -28,6 +28,7 @@ class TrackDetailsDialog extends HookWidget {
|
|||||||
artists: track.artists ?? <Artist>[],
|
artists: track.artists ?? <Artist>[],
|
||||||
mainAxisAlignment: WrapAlignment.start,
|
mainAxisAlignment: WrapAlignment.start,
|
||||||
textStyle: const TextStyle(color: Colors.blue),
|
textStyle: const TextStyle(color: Colors.blue),
|
||||||
|
hideOverflowArtist: false,
|
||||||
),
|
),
|
||||||
context.l10n.album: LinkText(
|
context.l10n.album: LinkText(
|
||||||
track.album!.name!,
|
track.album!.name!,
|
@ -3,7 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:spotube/extensions/context.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
import 'package:spotube/pages/settings/settings.dart';
|
import 'package:spotube/pages/settings/settings.dart';
|
||||||
|
|
||||||
import 'package:spotube/provider/authentication_provider.dart';
|
import 'package:spotube/provider/authentication/authentication.dart';
|
||||||
import 'package:spotube/utils/service_utils.dart';
|
import 'package:spotube/utils/service_utils.dart';
|
||||||
|
|
||||||
class AnonymousFallback extends ConsumerWidget {
|
class AnonymousFallback extends ConsumerWidget {
|
||||||
@ -15,9 +15,13 @@ class AnonymousFallback extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
final isLoggedIn = ref.watch(authenticationProvider) != null;
|
final isLoggedIn = ref.watch(authenticationProvider);
|
||||||
|
|
||||||
if (isLoggedIn && child != null) return child!;
|
if (isLoggedIn.isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoggedIn.asData?.value != null && child != null) return child!;
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:spotube/collections/assets.gen.dart';
|
import 'package:spotube/collections/assets.gen.dart';
|
||||||
|
import 'package:spotube/extensions/context.dart';
|
||||||
|
|
||||||
class NotFound extends StatelessWidget {
|
class NotFound extends StatelessWidget {
|
||||||
final bool vertical;
|
final bool vertical;
|
||||||
@ -18,9 +19,9 @@ class NotFound extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text("Nothing found", style: theme.textTheme.titleLarge),
|
Text(context.l10n.nothing_found, style: theme.textTheme.titleLarge),
|
||||||
Text(
|
Text(
|
||||||
"The box is empty",
|
context.l10n.the_box_is_empty,
|
||||||
style: theme.textTheme.titleMedium,
|
style: theme.textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
],
|
],
|
@ -1,11 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
|
import 'package:spotube/components/heart_button/use_track_toggle_like.dart';
|
||||||
import 'package:spotube/extensions/context.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
import 'package:spotube/provider/authentication_provider.dart';
|
import 'package:spotube/provider/authentication/authentication.dart';
|
||||||
import 'package:spotube/provider/scrobbler_provider.dart';
|
|
||||||
import 'package:spotube/provider/spotify/spotify.dart';
|
import 'package:spotube/provider/spotify/spotify.dart';
|
||||||
|
|
||||||
class HeartButton extends HookConsumerWidget {
|
class HeartButton extends HookConsumerWidget {
|
||||||
@ -27,7 +26,7 @@ class HeartButton extends HookConsumerWidget {
|
|||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
final auth = ref.watch(authenticationProvider);
|
final auth = ref.watch(authenticationProvider);
|
||||||
|
|
||||||
if (auth == null) return const SizedBox.shrink();
|
if (auth.asData?.value == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
return IconButton(
|
return IconButton(
|
||||||
tooltip: tooltip,
|
tooltip: tooltip,
|
||||||
@ -55,38 +54,6 @@ class HeartButton extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
typedef UseTrackToggleLike = ({
|
|
||||||
bool isLiked,
|
|
||||||
Future<void> Function(Track track) toggleTrackLike,
|
|
||||||
});
|
|
||||||
|
|
||||||
UseTrackToggleLike useTrackToggleLike(Track track, WidgetRef ref) {
|
|
||||||
final savedTracks = ref.watch(likedTracksProvider);
|
|
||||||
final savedTracksNotifier = ref.watch(likedTracksProvider.notifier);
|
|
||||||
|
|
||||||
final isLiked = useMemoized(
|
|
||||||
() =>
|
|
||||||
savedTracks.asData?.value.any((element) => element.id == track.id) ??
|
|
||||||
false,
|
|
||||||
[savedTracks.asData?.value, track.id],
|
|
||||||
);
|
|
||||||
|
|
||||||
final scrobblerNotifier = ref.read(scrobblerProvider.notifier);
|
|
||||||
|
|
||||||
return (
|
|
||||||
isLiked: isLiked,
|
|
||||||
toggleTrackLike: (track) async {
|
|
||||||
await savedTracksNotifier.toggleFavorite(track);
|
|
||||||
|
|
||||||
if (!isLiked) {
|
|
||||||
await scrobblerNotifier.love(track);
|
|
||||||
} else {
|
|
||||||
await scrobblerNotifier.unlove(track);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class TrackHeartButton extends HookConsumerWidget {
|
class TrackHeartButton extends HookConsumerWidget {
|
||||||
final Track track;
|
final Track track;
|
||||||
const TrackHeartButton({
|
const TrackHeartButton({
|
37
lib/components/heart_button/use_track_toggle_like.dart
Normal file
37
lib/components/heart_button/use_track_toggle_like.dart
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:spotify/spotify.dart';
|
||||||
|
import 'package:spotube/provider/scrobbler/scrobbler.dart';
|
||||||
|
import 'package:spotube/provider/spotify/spotify.dart';
|
||||||
|
|
||||||
|
typedef UseTrackToggleLike = ({
|
||||||
|
bool isLiked,
|
||||||
|
Future<void> Function(Track track) toggleTrackLike,
|
||||||
|
});
|
||||||
|
|
||||||
|
UseTrackToggleLike useTrackToggleLike(Track track, WidgetRef ref) {
|
||||||
|
final savedTracks = ref.watch(likedTracksProvider);
|
||||||
|
final savedTracksNotifier = ref.watch(likedTracksProvider.notifier);
|
||||||
|
|
||||||
|
final isLiked = useMemoized(
|
||||||
|
() =>
|
||||||
|
savedTracks.asData?.value.any((element) => element.id == track.id) ??
|
||||||
|
false,
|
||||||
|
[savedTracks.asData?.value, track.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
final scrobblerNotifier = ref.read(scrobblerProvider.notifier);
|
||||||
|
|
||||||
|
return (
|
||||||
|
isLiked: isLiked,
|
||||||
|
toggleTrackLike: (track) async {
|
||||||
|
await savedTracksNotifier.toggleFavorite(track);
|
||||||
|
|
||||||
|
if (!isLiked) {
|
||||||
|
await scrobblerNotifier.love(track);
|
||||||
|
} else {
|
||||||
|
await scrobblerNotifier.unlove(track);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
@ -1,32 +0,0 @@
|
|||||||
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: () {},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -5,9 +5,9 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
|||||||
import 'package:skeletonizer/skeletonizer.dart';
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/collections/fake.dart';
|
import 'package:spotube/collections/fake.dart';
|
||||||
import 'package:spotube/components/album/album_card.dart';
|
import 'package:spotube/modules/album/album_card.dart';
|
||||||
import 'package:spotube/components/artist/artist_card.dart';
|
import 'package:spotube/modules/artist/artist_card.dart';
|
||||||
import 'package:spotube/components/playlist/playlist_card.dart';
|
import 'package:spotube/modules/playlist/playlist_card.dart';
|
||||||
import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
|
import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
|
||||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
|
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
|
||||||
|
|
@ -29,7 +29,7 @@ class AnchorButton<T> extends HookWidget {
|
|||||||
onTapUp: (event) => tap.value = false,
|
onTapUp: (event) => tap.value = false,
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: MouseRegion(
|
child: MouseRegion(
|
||||||
cursor: MaterialStateMouseCursor.clickable,
|
cursor: WidgetStateMouseCursor.clickable,
|
||||||
child: Text(
|
child: Text(
|
||||||
text,
|
text,
|
||||||
style: style.copyWith(
|
style: style.copyWith(
|
81
lib/components/links/artist_link.dart
Normal file
81
lib/components/links/artist_link.dart
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:spotify/spotify.dart';
|
||||||
|
import 'package:spotube/components/links/anchor_button.dart';
|
||||||
|
import 'package:spotube/extensions/context.dart';
|
||||||
|
import 'package:spotube/pages/artist/artist.dart';
|
||||||
|
import 'package:spotube/utils/service_utils.dart';
|
||||||
|
|
||||||
|
class ArtistLink extends StatelessWidget {
|
||||||
|
final List<ArtistSimple> artists;
|
||||||
|
final WrapCrossAlignment crossAxisAlignment;
|
||||||
|
final WrapAlignment mainAxisAlignment;
|
||||||
|
final TextStyle textStyle;
|
||||||
|
final bool hideOverflowArtist;
|
||||||
|
final void Function(String route)? onRouteChange;
|
||||||
|
final VoidCallback? onOverflowArtistClick;
|
||||||
|
|
||||||
|
const ArtistLink({
|
||||||
|
super.key,
|
||||||
|
required this.artists,
|
||||||
|
this.crossAxisAlignment = WrapCrossAlignment.center,
|
||||||
|
this.mainAxisAlignment = WrapAlignment.center,
|
||||||
|
this.textStyle = const TextStyle(),
|
||||||
|
this.onRouteChange,
|
||||||
|
this.hideOverflowArtist = true,
|
||||||
|
this.onOverflowArtistClick,
|
||||||
|
}) : assert(hideOverflowArtist ? onOverflowArtistClick != null : true);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final ThemeData(:colorScheme) = Theme.of(context);
|
||||||
|
|
||||||
|
return Wrap(
|
||||||
|
crossAxisAlignment: crossAxisAlignment,
|
||||||
|
alignment: mainAxisAlignment,
|
||||||
|
children: [
|
||||||
|
...(hideOverflowArtist ? artists.take(3).toList() : artists)
|
||||||
|
.asMap()
|
||||||
|
.entries
|
||||||
|
.map(
|
||||||
|
(artist) => Builder(builder: (context) {
|
||||||
|
if (artist.value.name == null) {
|
||||||
|
return Text("Spotify", style: textStyle);
|
||||||
|
}
|
||||||
|
return AnchorButton(
|
||||||
|
(artist.key != artists.length - 1)
|
||||||
|
? "${artist.value.name}, "
|
||||||
|
: artist.value.name!,
|
||||||
|
onTap: () {
|
||||||
|
if (onRouteChange != null) {
|
||||||
|
onRouteChange?.call("/artist/${artist.value.id}");
|
||||||
|
} else {
|
||||||
|
ServiceUtils.pushNamed(
|
||||||
|
context,
|
||||||
|
ArtistPage.name,
|
||||||
|
pathParameters: {
|
||||||
|
"id": artist.value.id!,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: textStyle,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
if (hideOverflowArtist && artists.length > 3)
|
||||||
|
AnchorButton(
|
||||||
|
context.l10n.and_n_more(artists.length - 3),
|
||||||
|
onTap: () {
|
||||||
|
onOverflowArtistClick?.call();
|
||||||
|
},
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: textStyle.copyWith(
|
||||||
|
color: colorScheme.secondary,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:spotube/components/shared/links/anchor_button.dart';
|
import 'package:spotube/components/links/anchor_button.dart';
|
||||||
import 'package:url_launcher/url_launcher_string.dart';
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
|
|
||||||
class Hyperlink extends StatelessWidget {
|
class Hyperlink extends StatelessWidget {
|
@ -1,5 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:spotube/components/shared/links/anchor_button.dart';
|
import 'package:spotube/components/links/anchor_button.dart';
|
||||||
import 'package:spotube/utils/service_utils.dart';
|
import 'package:spotube/utils/service_utils.dart';
|
||||||
|
|
||||||
class LinkText<T> extends StatelessWidget {
|
class LinkText<T> extends StatelessWidget {
|
@ -1,4 +1,4 @@
|
|||||||
part of './sliding_up_panel.dart';
|
part of 'sliding_up_panel.dart';
|
||||||
|
|
||||||
class PanelController extends ChangeNotifier {
|
class PanelController extends ChangeNotifier {
|
||||||
SlidingUpPanelState? _panelState;
|
SlidingUpPanelState? _panelState;
|
@ -1,4 +1,4 @@
|
|||||||
part of "./sliding_up_panel.dart";
|
part of "sliding_up_panel.dart";
|
||||||
|
|
||||||
/// if you want to prevent the panel from being dragged using the widget,
|
/// if you want to prevent the panel from being dragged using the widget,
|
||||||
/// wrap the widget with this
|
/// wrap the widget with this
|
@ -3,23 +3,15 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:gap/gap.dart';
|
import 'package:gap/gap.dart';
|
||||||
import 'package:skeletonizer/skeletonizer.dart';
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
|
|
||||||
import 'package:spotube/collections/spotube_icons.dart';
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
import 'package:spotube/components/shared/hover_builder.dart';
|
import 'package:spotube/components/hover_builder.dart';
|
||||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
import 'package:spotube/components/image/universal_image.dart';
|
||||||
import 'package:spotube/extensions/constrains.dart';
|
import 'package:spotube/extensions/constrains.dart';
|
||||||
|
import 'package:spotube/extensions/context.dart';
|
||||||
|
import 'package:spotube/extensions/string.dart';
|
||||||
import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
|
import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
|
||||||
import 'package:spotube/hooks/utils/use_brightness_value.dart';
|
import 'package:spotube/hooks/utils/use_brightness_value.dart';
|
||||||
|
|
||||||
final htmlTagRegexp = RegExp(r"<[^>]*>", caseSensitive: true);
|
|
||||||
|
|
||||||
String? useDescription(String? description) {
|
|
||||||
return useMemoized(() {
|
|
||||||
if (description == null) return null;
|
|
||||||
return description.replaceAll(htmlTagRegexp, '');
|
|
||||||
}, [description]);
|
|
||||||
}
|
|
||||||
|
|
||||||
class PlaybuttonCard extends HookWidget {
|
class PlaybuttonCard extends HookWidget {
|
||||||
final void Function()? onTap;
|
final void Function()? onTap;
|
||||||
final void Function()? onPlaybuttonPressed;
|
final void Function()? onPlaybuttonPressed;
|
||||||
@ -66,19 +58,18 @@ class PlaybuttonCard extends HookWidget {
|
|||||||
others: 15,
|
others: 15,
|
||||||
);
|
);
|
||||||
|
|
||||||
final cleanDescription = useDescription(description);
|
var unescapeHtml = description?.unescapeHtml();
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
constraints: BoxConstraints(maxWidth: size),
|
constraints: BoxConstraints(maxWidth: size),
|
||||||
margin: margin,
|
margin: margin,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Color.lerp(
|
color: Color.lerp(
|
||||||
theme.colorScheme.surfaceVariant,
|
theme.colorScheme.surfaceContainerHighest,
|
||||||
theme.colorScheme.surface,
|
theme.colorScheme.surface,
|
||||||
useBrightnessValue(.9, .7),
|
useBrightnessValue(.9, .7),
|
||||||
),
|
),
|
||||||
borderRadius: radius,
|
borderRadius: radius,
|
||||||
shadowColor: theme.colorScheme.background,
|
shadowColor: theme.colorScheme.surface,
|
||||||
elevation: 3,
|
elevation: 3,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
mouseCursor: SystemMouseCursors.click,
|
mouseCursor: SystemMouseCursors.click,
|
||||||
@ -137,7 +128,7 @@ class PlaybuttonCard extends HookWidget {
|
|||||||
),
|
),
|
||||||
if (isHovered)
|
if (isHovered)
|
||||||
Text(
|
Text(
|
||||||
"Owned by you",
|
context.l10n.owned_by_you,
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
@ -158,7 +149,7 @@ class PlaybuttonCard extends HookWidget {
|
|||||||
Skeleton.keep(
|
Skeleton.keep(
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
style: IconButton.styleFrom(
|
style: IconButton.styleFrom(
|
||||||
backgroundColor: theme.colorScheme.background,
|
backgroundColor: theme.colorScheme.surface,
|
||||||
foregroundColor: theme.colorScheme.primary,
|
foregroundColor: theme.colorScheme.primary,
|
||||||
minimumSize: const Size.square(10),
|
minimumSize: const Size.square(10),
|
||||||
),
|
),
|
||||||
@ -205,11 +196,11 @@ class PlaybuttonCard extends HookWidget {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (cleanDescription != null)
|
if (description != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
child: AutoSizeText(
|
child: AutoSizeText(
|
||||||
cleanDescription,
|
unescapeHtml!,
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: theme.colorScheme.onSurface.withOpacity(.5),
|
color: theme.colorScheme.onSurface.withOpacity(.5),
|
@ -1,61 +0,0 @@
|
|||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:spotify/spotify.dart';
|
|
||||||
import 'package:spotube/components/shared/links/anchor_button.dart';
|
|
||||||
import 'package:spotube/pages/artist/artist.dart';
|
|
||||||
import 'package:spotube/utils/service_utils.dart';
|
|
||||||
|
|
||||||
class ArtistLink extends StatelessWidget {
|
|
||||||
final List<ArtistSimple> artists;
|
|
||||||
final WrapCrossAlignment crossAxisAlignment;
|
|
||||||
final WrapAlignment mainAxisAlignment;
|
|
||||||
final TextStyle textStyle;
|
|
||||||
final void Function(String route)? onRouteChange;
|
|
||||||
|
|
||||||
const ArtistLink({
|
|
||||||
super.key,
|
|
||||||
required this.artists,
|
|
||||||
this.crossAxisAlignment = WrapCrossAlignment.center,
|
|
||||||
this.mainAxisAlignment = WrapAlignment.center,
|
|
||||||
this.textStyle = const TextStyle(),
|
|
||||||
this.onRouteChange,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Wrap(
|
|
||||||
crossAxisAlignment: crossAxisAlignment,
|
|
||||||
alignment: mainAxisAlignment,
|
|
||||||
children: artists
|
|
||||||
.asMap()
|
|
||||||
.entries
|
|
||||||
.map(
|
|
||||||
(artist) => Builder(builder: (context) {
|
|
||||||
if (artist.value.name == null) {
|
|
||||||
return Text("Spotify", style: textStyle);
|
|
||||||
}
|
|
||||||
return AnchorButton(
|
|
||||||
(artist.key != artists.length - 1)
|
|
||||||
? "${artist.value.name}, "
|
|
||||||
: artist.value.name!,
|
|
||||||
onTap: () {
|
|
||||||
if (onRouteChange != null) {
|
|
||||||
onRouteChange?.call("/artist/${artist.value.id}");
|
|
||||||
} else {
|
|
||||||
ServiceUtils.pushNamed(
|
|
||||||
context,
|
|
||||||
ArtistPage.name,
|
|
||||||
pathParameters: {
|
|
||||||
"id": artist.value.id!,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: textStyle,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,653 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
|
||||||
import 'package:spotube/utils/platform.dart';
|
|
||||||
import 'package:titlebar_buttons/titlebar_buttons.dart';
|
|
||||||
import 'dart:math';
|
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
||||||
import 'dart:io' show Platform;
|
|
||||||
|
|
||||||
import 'package:window_manager/window_manager.dart';
|
|
||||||
|
|
||||||
class PageWindowTitleBar extends StatefulHookConsumerWidget
|
|
||||||
implements PreferredSizeWidget {
|
|
||||||
final Widget? leading;
|
|
||||||
final bool automaticallyImplyLeading;
|
|
||||||
final List<Widget>? actions;
|
|
||||||
final Color? backgroundColor;
|
|
||||||
final Color? foregroundColor;
|
|
||||||
final IconThemeData? actionsIconTheme;
|
|
||||||
final bool? centerTitle;
|
|
||||||
final double? titleSpacing;
|
|
||||||
final double toolbarOpacity;
|
|
||||||
final double? leadingWidth;
|
|
||||||
final TextStyle? toolbarTextStyle;
|
|
||||||
final TextStyle? titleTextStyle;
|
|
||||||
final double? titleWidth;
|
|
||||||
final Widget? title;
|
|
||||||
|
|
||||||
final bool _sliver;
|
|
||||||
|
|
||||||
const PageWindowTitleBar({
|
|
||||||
super.key,
|
|
||||||
this.actions,
|
|
||||||
this.title,
|
|
||||||
this.toolbarOpacity = 1,
|
|
||||||
this.backgroundColor,
|
|
||||||
this.actionsIconTheme,
|
|
||||||
this.automaticallyImplyLeading = false,
|
|
||||||
this.centerTitle,
|
|
||||||
this.foregroundColor,
|
|
||||||
this.leading,
|
|
||||||
this.leadingWidth,
|
|
||||||
this.titleSpacing,
|
|
||||||
this.titleTextStyle,
|
|
||||||
this.titleWidth,
|
|
||||||
this.toolbarTextStyle,
|
|
||||||
}) : _sliver = false,
|
|
||||||
pinned = false,
|
|
||||||
floating = false,
|
|
||||||
snap = false,
|
|
||||||
stretch = false;
|
|
||||||
|
|
||||||
final bool pinned;
|
|
||||||
final bool floating;
|
|
||||||
final bool snap;
|
|
||||||
final bool stretch;
|
|
||||||
|
|
||||||
const PageWindowTitleBar.sliver({
|
|
||||||
super.key,
|
|
||||||
this.actions,
|
|
||||||
this.title,
|
|
||||||
this.backgroundColor,
|
|
||||||
this.actionsIconTheme,
|
|
||||||
this.automaticallyImplyLeading = false,
|
|
||||||
this.centerTitle,
|
|
||||||
this.foregroundColor,
|
|
||||||
this.leading,
|
|
||||||
this.leadingWidth,
|
|
||||||
this.titleSpacing,
|
|
||||||
this.titleTextStyle,
|
|
||||||
this.titleWidth,
|
|
||||||
this.toolbarTextStyle,
|
|
||||||
this.pinned = false,
|
|
||||||
this.floating = false,
|
|
||||||
this.snap = false,
|
|
||||||
this.stretch = false,
|
|
||||||
}) : _sliver = true,
|
|
||||||
toolbarOpacity = 1;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
|
||||||
|
|
||||||
@override
|
|
||||||
ConsumerState<PageWindowTitleBar> createState() => _PageWindowTitleBarState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _PageWindowTitleBarState extends ConsumerState<PageWindowTitleBar> {
|
|
||||||
void onDrag(details) {
|
|
||||||
final systemTitleBar =
|
|
||||||
ref.read(userPreferencesProvider.select((s) => s.systemTitleBar));
|
|
||||||
if (kIsDesktop && !systemTitleBar) {
|
|
||||||
windowManager.startDragging();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final mediaQuery = MediaQuery.of(context);
|
|
||||||
|
|
||||||
if (widget._sliver) {
|
|
||||||
return SliverLayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final hasFullscreen =
|
|
||||||
mediaQuery.size.width == constraints.crossAxisExtent;
|
|
||||||
final hasLeadingOrCanPop =
|
|
||||||
widget.leading != null || Navigator.canPop(context);
|
|
||||||
|
|
||||||
return SliverPadding(
|
|
||||||
padding: EdgeInsets.only(
|
|
||||||
left: kIsMacOS && hasFullscreen && hasLeadingOrCanPop ? 65 : 0,
|
|
||||||
),
|
|
||||||
sliver: SliverAppBar(
|
|
||||||
leading: widget.leading,
|
|
||||||
automaticallyImplyLeading: widget.automaticallyImplyLeading,
|
|
||||||
actions: [
|
|
||||||
...?widget.actions,
|
|
||||||
WindowTitleBarButtons(foregroundColor: widget.foregroundColor),
|
|
||||||
],
|
|
||||||
backgroundColor: widget.backgroundColor,
|
|
||||||
foregroundColor: widget.foregroundColor,
|
|
||||||
actionsIconTheme: widget.actionsIconTheme,
|
|
||||||
centerTitle: widget.centerTitle,
|
|
||||||
titleSpacing: widget.titleSpacing,
|
|
||||||
leadingWidth: widget.leadingWidth,
|
|
||||||
toolbarTextStyle: widget.toolbarTextStyle,
|
|
||||||
titleTextStyle: widget.titleTextStyle,
|
|
||||||
title: SizedBox(
|
|
||||||
width: double.infinity, // workaround to force dragging
|
|
||||||
child: widget.title ?? const Text(""),
|
|
||||||
),
|
|
||||||
pinned: widget.pinned,
|
|
||||||
floating: widget.floating,
|
|
||||||
snap: widget.snap,
|
|
||||||
stretch: widget.stretch,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return LayoutBuilder(builder: (context, constrains) {
|
|
||||||
final hasFullscreen = mediaQuery.size.width == constrains.maxWidth;
|
|
||||||
final hasLeadingOrCanPop =
|
|
||||||
widget.leading != null || Navigator.canPop(context);
|
|
||||||
|
|
||||||
return GestureDetector(
|
|
||||||
onHorizontalDragStart: onDrag,
|
|
||||||
onVerticalDragStart: onDrag,
|
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.only(
|
|
||||||
left: kIsMacOS && hasFullscreen && hasLeadingOrCanPop ? 65 : 0,
|
|
||||||
),
|
|
||||||
child: AppBar(
|
|
||||||
leading: widget.leading,
|
|
||||||
automaticallyImplyLeading: widget.automaticallyImplyLeading,
|
|
||||||
actions: [
|
|
||||||
...?widget.actions,
|
|
||||||
WindowTitleBarButtons(foregroundColor: widget.foregroundColor),
|
|
||||||
],
|
|
||||||
backgroundColor: widget.backgroundColor,
|
|
||||||
foregroundColor: widget.foregroundColor,
|
|
||||||
actionsIconTheme: widget.actionsIconTheme,
|
|
||||||
centerTitle: widget.centerTitle,
|
|
||||||
titleSpacing: widget.titleSpacing,
|
|
||||||
toolbarOpacity: widget.toolbarOpacity,
|
|
||||||
leadingWidth: widget.leadingWidth,
|
|
||||||
toolbarTextStyle: widget.toolbarTextStyle,
|
|
||||||
titleTextStyle: widget.titleTextStyle,
|
|
||||||
title: SizedBox(
|
|
||||||
width: double.infinity, // workaround to force dragging
|
|
||||||
child: widget.title ?? const Text(""),
|
|
||||||
),
|
|
||||||
scrolledUnderElevation: 0,
|
|
||||||
shadowColor: Colors.transparent,
|
|
||||||
forceMaterialTransparency: true,
|
|
||||||
elevation: 0,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class WindowTitleBarButtons extends HookConsumerWidget {
|
|
||||||
final Color? foregroundColor;
|
|
||||||
const WindowTitleBarButtons({
|
|
||||||
super.key,
|
|
||||||
this.foregroundColor,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, ref) {
|
|
||||||
final preferences = ref.watch(userPreferencesProvider);
|
|
||||||
final isMaximized = useState<bool?>(null);
|
|
||||||
const type = ThemeType.auto;
|
|
||||||
|
|
||||||
Future<void> onClose() async {
|
|
||||||
await windowManager.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() {
|
|
||||||
if (kIsDesktop) {
|
|
||||||
windowManager.isMaximized().then((value) {
|
|
||||||
isMaximized.value = value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!kIsDesktop || kIsMacOS || preferences.systemTitleBar) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (kIsWindows) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final colors = WindowButtonColors(
|
|
||||||
normal: Colors.transparent,
|
|
||||||
iconNormal: foregroundColor ?? theme.colorScheme.onBackground,
|
|
||||||
mouseOver: theme.colorScheme.onBackground.withOpacity(0.1),
|
|
||||||
mouseDown: theme.colorScheme.onBackground.withOpacity(0.2),
|
|
||||||
iconMouseOver: theme.colorScheme.onBackground,
|
|
||||||
iconMouseDown: theme.colorScheme.onBackground,
|
|
||||||
);
|
|
||||||
|
|
||||||
final closeColors = WindowButtonColors(
|
|
||||||
normal: Colors.transparent,
|
|
||||||
iconNormal: foregroundColor ?? theme.colorScheme.onBackground,
|
|
||||||
mouseOver: Colors.red,
|
|
||||||
mouseDown: Colors.red[800]!,
|
|
||||||
iconMouseOver: Colors.white,
|
|
||||||
iconMouseDown: Colors.black,
|
|
||||||
);
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 25),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
MinimizeWindowButton(
|
|
||||||
onPressed: windowManager.minimize,
|
|
||||||
colors: colors,
|
|
||||||
),
|
|
||||||
if (isMaximized.value != true)
|
|
||||||
MaximizeWindowButton(
|
|
||||||
colors: colors,
|
|
||||||
onPressed: () {
|
|
||||||
windowManager.maximize();
|
|
||||||
isMaximized.value = true;
|
|
||||||
},
|
|
||||||
)
|
|
||||||
else
|
|
||||||
RestoreWindowButton(
|
|
||||||
colors: colors,
|
|
||||||
onPressed: () {
|
|
||||||
windowManager.unmaximize();
|
|
||||||
isMaximized.value = false;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
CloseWindowButton(
|
|
||||||
colors: closeColors,
|
|
||||||
onPressed: onClose,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 20, left: 10),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
DecoratedMinimizeButton(
|
|
||||||
type: type,
|
|
||||||
onPressed: windowManager.minimize,
|
|
||||||
),
|
|
||||||
DecoratedMaximizeButton(
|
|
||||||
type: type,
|
|
||||||
onPressed: () async {
|
|
||||||
if (await windowManager.isMaximized()) {
|
|
||||||
await windowManager.unmaximize();
|
|
||||||
isMaximized.value = false;
|
|
||||||
} else {
|
|
||||||
await windowManager.maximize();
|
|
||||||
isMaximized.value = true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
DecoratedCloseButton(
|
|
||||||
type: type,
|
|
||||||
onPressed: onClose,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
typedef WindowButtonIconBuilder = Widget Function(
|
|
||||||
WindowButtonContext buttonContext);
|
|
||||||
typedef WindowButtonBuilder = Widget Function(
|
|
||||||
WindowButtonContext buttonContext, Widget icon);
|
|
||||||
|
|
||||||
class WindowButtonContext {
|
|
||||||
BuildContext context;
|
|
||||||
MouseState mouseState;
|
|
||||||
Color? backgroundColor;
|
|
||||||
Color iconColor;
|
|
||||||
WindowButtonContext(
|
|
||||||
{required this.context,
|
|
||||||
required this.mouseState,
|
|
||||||
this.backgroundColor,
|
|
||||||
required this.iconColor});
|
|
||||||
}
|
|
||||||
|
|
||||||
class WindowButtonColors {
|
|
||||||
late Color normal;
|
|
||||||
late Color mouseOver;
|
|
||||||
late Color mouseDown;
|
|
||||||
late Color iconNormal;
|
|
||||||
late Color iconMouseOver;
|
|
||||||
late Color iconMouseDown;
|
|
||||||
WindowButtonColors(
|
|
||||||
{Color? normal,
|
|
||||||
Color? mouseOver,
|
|
||||||
Color? mouseDown,
|
|
||||||
Color? iconNormal,
|
|
||||||
Color? iconMouseOver,
|
|
||||||
Color? iconMouseDown}) {
|
|
||||||
this.normal = normal ?? _defaultButtonColors.normal;
|
|
||||||
this.mouseOver = mouseOver ?? _defaultButtonColors.mouseOver;
|
|
||||||
this.mouseDown = mouseDown ?? _defaultButtonColors.mouseDown;
|
|
||||||
this.iconNormal = iconNormal ?? _defaultButtonColors.iconNormal;
|
|
||||||
this.iconMouseOver = iconMouseOver ?? _defaultButtonColors.iconMouseOver;
|
|
||||||
this.iconMouseDown = iconMouseDown ?? _defaultButtonColors.iconMouseDown;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final _defaultButtonColors = WindowButtonColors(
|
|
||||||
normal: Colors.transparent,
|
|
||||||
iconNormal: const Color(0xFF805306),
|
|
||||||
mouseOver: const Color(0xFF404040),
|
|
||||||
mouseDown: const Color(0xFF202020),
|
|
||||||
iconMouseOver: const Color(0xFFFFFFFF),
|
|
||||||
iconMouseDown: const Color(0xFFF0F0F0),
|
|
||||||
);
|
|
||||||
|
|
||||||
class WindowButton extends StatelessWidget {
|
|
||||||
final WindowButtonBuilder? builder;
|
|
||||||
final WindowButtonIconBuilder? iconBuilder;
|
|
||||||
late final WindowButtonColors colors;
|
|
||||||
final bool animate;
|
|
||||||
final EdgeInsets? padding;
|
|
||||||
final VoidCallback? onPressed;
|
|
||||||
|
|
||||||
WindowButton(
|
|
||||||
{super.key,
|
|
||||||
WindowButtonColors? colors,
|
|
||||||
this.builder,
|
|
||||||
@required this.iconBuilder,
|
|
||||||
this.padding,
|
|
||||||
this.onPressed,
|
|
||||||
this.animate = false}) {
|
|
||||||
this.colors = colors ?? _defaultButtonColors;
|
|
||||||
}
|
|
||||||
|
|
||||||
Color getBackgroundColor(MouseState mouseState) {
|
|
||||||
if (mouseState.isMouseDown) return colors.mouseDown;
|
|
||||||
if (mouseState.isMouseOver) return colors.mouseOver;
|
|
||||||
return colors.normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
Color getIconColor(MouseState mouseState) {
|
|
||||||
if (mouseState.isMouseDown) return colors.iconMouseDown;
|
|
||||||
if (mouseState.isMouseOver) return colors.iconMouseOver;
|
|
||||||
return colors.iconNormal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
if (kIsWeb) {
|
|
||||||
return Container();
|
|
||||||
} else {
|
|
||||||
// Don't show button on macOS
|
|
||||||
if (Platform.isMacOS) {
|
|
||||||
return Container();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return MouseStateBuilder(
|
|
||||||
builder: (context, mouseState) {
|
|
||||||
WindowButtonContext buttonContext = WindowButtonContext(
|
|
||||||
mouseState: mouseState,
|
|
||||||
context: context,
|
|
||||||
backgroundColor: getBackgroundColor(mouseState),
|
|
||||||
iconColor: getIconColor(mouseState));
|
|
||||||
|
|
||||||
var icon =
|
|
||||||
(iconBuilder != null) ? iconBuilder!(buttonContext) : Container();
|
|
||||||
|
|
||||||
var fadeOutColor =
|
|
||||||
getBackgroundColor(MouseState()..isMouseOver = true).withOpacity(0);
|
|
||||||
var padding = this.padding ?? const EdgeInsets.all(10);
|
|
||||||
var animationMs =
|
|
||||||
mouseState.isMouseOver ? (animate ? 100 : 0) : (animate ? 200 : 0);
|
|
||||||
Widget iconWithPadding = Padding(padding: padding, child: icon);
|
|
||||||
iconWithPadding = AnimatedContainer(
|
|
||||||
curve: Curves.easeOut,
|
|
||||||
duration: Duration(milliseconds: animationMs),
|
|
||||||
color: buttonContext.backgroundColor ?? fadeOutColor,
|
|
||||||
child: iconWithPadding);
|
|
||||||
var button =
|
|
||||||
(builder != null) ? builder!(buttonContext, icon) : iconWithPadding;
|
|
||||||
return SizedBox(
|
|
||||||
width: 45,
|
|
||||||
height: 32,
|
|
||||||
child: button,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onPressed: () {
|
|
||||||
if (onPressed != null) onPressed!();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class MinimizeWindowButton extends WindowButton {
|
|
||||||
MinimizeWindowButton(
|
|
||||||
{super.key, super.colors, super.onPressed, bool? animate})
|
|
||||||
: super(
|
|
||||||
animate: animate ?? false,
|
|
||||||
iconBuilder: (buttonContext) =>
|
|
||||||
MinimizeIcon(color: buttonContext.iconColor),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class MaximizeWindowButton extends WindowButton {
|
|
||||||
MaximizeWindowButton(
|
|
||||||
{super.key, super.colors, super.onPressed, bool? animate})
|
|
||||||
: super(
|
|
||||||
animate: animate ?? false,
|
|
||||||
iconBuilder: (buttonContext) =>
|
|
||||||
MaximizeIcon(color: buttonContext.iconColor),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class RestoreWindowButton extends WindowButton {
|
|
||||||
RestoreWindowButton({super.key, super.colors, super.onPressed, bool? animate})
|
|
||||||
: super(
|
|
||||||
animate: animate ?? false,
|
|
||||||
iconBuilder: (buttonContext) =>
|
|
||||||
RestoreIcon(color: buttonContext.iconColor),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final _defaultCloseButtonColors = WindowButtonColors(
|
|
||||||
mouseOver: const Color(0xFFD32F2F),
|
|
||||||
mouseDown: const Color(0xFFB71C1C),
|
|
||||||
iconNormal: const Color(0xFF805306),
|
|
||||||
iconMouseOver: const Color(0xFFFFFFFF));
|
|
||||||
|
|
||||||
class CloseWindowButton extends WindowButton {
|
|
||||||
CloseWindowButton(
|
|
||||||
{super.key, WindowButtonColors? colors, super.onPressed, bool? animate})
|
|
||||||
: super(
|
|
||||||
colors: colors ?? _defaultCloseButtonColors,
|
|
||||||
animate: animate ?? false,
|
|
||||||
iconBuilder: (buttonContext) =>
|
|
||||||
CloseIcon(color: buttonContext.iconColor),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Switched to CustomPaint icons by https://github.com/esDotDev
|
|
||||||
|
|
||||||
/// Close
|
|
||||||
class CloseIcon extends StatelessWidget {
|
|
||||||
final Color color;
|
|
||||||
const CloseIcon({super.key, required this.color});
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) => Align(
|
|
||||||
alignment: Alignment.topLeft,
|
|
||||||
child: Stack(children: [
|
|
||||||
// Use rotated containers instead of a painter because it renders slightly crisper than a painter for some reason.
|
|
||||||
Transform.rotate(
|
|
||||||
angle: pi * .25,
|
|
||||||
child:
|
|
||||||
Center(child: Container(width: 14, height: 1, color: color))),
|
|
||||||
Transform.rotate(
|
|
||||||
angle: pi * -.25,
|
|
||||||
child:
|
|
||||||
Center(child: Container(width: 14, height: 1, color: color))),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Maximize
|
|
||||||
class MaximizeIcon extends StatelessWidget {
|
|
||||||
final Color color;
|
|
||||||
const MaximizeIcon({super.key, required this.color});
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) => _AlignedPaint(_MaximizePainter(color));
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MaximizePainter extends _IconPainter {
|
|
||||||
_MaximizePainter(super.color);
|
|
||||||
@override
|
|
||||||
void paint(Canvas canvas, Size size) {
|
|
||||||
Paint p = getPaint(color);
|
|
||||||
canvas.drawRect(Rect.fromLTRB(0, 0, size.width - 1, size.height - 1), p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Restore
|
|
||||||
class RestoreIcon extends StatelessWidget {
|
|
||||||
final Color color;
|
|
||||||
const RestoreIcon({
|
|
||||||
super.key,
|
|
||||||
required this.color,
|
|
||||||
});
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) => _AlignedPaint(_RestorePainter(color));
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RestorePainter extends _IconPainter {
|
|
||||||
_RestorePainter(super.color);
|
|
||||||
@override
|
|
||||||
void paint(Canvas canvas, Size size) {
|
|
||||||
Paint p = getPaint(color);
|
|
||||||
canvas.drawRect(Rect.fromLTRB(0, 2, size.width - 2, size.height), p);
|
|
||||||
canvas.drawLine(const Offset(2, 2), const Offset(2, 0), p);
|
|
||||||
canvas.drawLine(const Offset(2, 0), Offset(size.width, 0), p);
|
|
||||||
canvas.drawLine(
|
|
||||||
Offset(size.width, 0), Offset(size.width, size.height - 2), p);
|
|
||||||
canvas.drawLine(Offset(size.width, size.height - 2),
|
|
||||||
Offset(size.width - 2, size.height - 2), p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Minimize
|
|
||||||
class MinimizeIcon extends StatelessWidget {
|
|
||||||
final Color color;
|
|
||||||
const MinimizeIcon({super.key, required this.color});
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) => _AlignedPaint(_MinimizePainter(color));
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MinimizePainter extends _IconPainter {
|
|
||||||
_MinimizePainter(super.color);
|
|
||||||
@override
|
|
||||||
void paint(Canvas canvas, Size size) {
|
|
||||||
Paint p = getPaint(color);
|
|
||||||
canvas.drawLine(
|
|
||||||
Offset(0, size.height / 2), Offset(size.width, size.height / 2), p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helpers
|
|
||||||
abstract class _IconPainter extends CustomPainter {
|
|
||||||
_IconPainter(this.color);
|
|
||||||
final Color color;
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AlignedPaint extends StatelessWidget {
|
|
||||||
const _AlignedPaint(this.painter);
|
|
||||||
final CustomPainter painter;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Align(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: CustomPaint(size: const Size(10, 10), painter: painter));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Paint getPaint(Color color, [bool isAntiAlias = false]) => Paint()
|
|
||||||
..color = color
|
|
||||||
..style = PaintingStyle.stroke
|
|
||||||
..isAntiAlias = isAntiAlias
|
|
||||||
..strokeWidth = 1;
|
|
||||||
|
|
||||||
typedef MouseStateBuilderCB = Widget Function(
|
|
||||||
BuildContext context, MouseState mouseState);
|
|
||||||
|
|
||||||
class MouseState {
|
|
||||||
bool isMouseOver = false;
|
|
||||||
bool isMouseDown = false;
|
|
||||||
MouseState();
|
|
||||||
@override
|
|
||||||
String toString() {
|
|
||||||
return "isMouseDown: $isMouseDown - isMouseOver: $isMouseOver";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
T? _ambiguate<T>(T? value) => value;
|
|
||||||
|
|
||||||
class MouseStateBuilder extends StatefulWidget {
|
|
||||||
final MouseStateBuilderCB builder;
|
|
||||||
final VoidCallback? onPressed;
|
|
||||||
const MouseStateBuilder({super.key, required this.builder, this.onPressed});
|
|
||||||
@override
|
|
||||||
// ignore: library_private_types_in_public_api
|
|
||||||
_MouseStateBuilderState createState() => _MouseStateBuilderState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MouseStateBuilderState extends State<MouseStateBuilder> {
|
|
||||||
late MouseState _mouseState;
|
|
||||||
_MouseStateBuilderState() {
|
|
||||||
_mouseState = MouseState();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return MouseRegion(
|
|
||||||
onEnter: (event) {
|
|
||||||
setState(() {
|
|
||||||
_mouseState.isMouseOver = true;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onExit: (event) {
|
|
||||||
setState(() {
|
|
||||||
_mouseState.isMouseOver = false;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
child: GestureDetector(
|
|
||||||
onTapDown: (_) {
|
|
||||||
setState(() {
|
|
||||||
_mouseState.isMouseDown = true;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onTapCancel: () {
|
|
||||||
setState(() {
|
|
||||||
_mouseState.isMouseDown = false;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onTap: () {
|
|
||||||
setState(() {
|
|
||||||
_mouseState.isMouseDown = false;
|
|
||||||
_mouseState.isMouseOver = false;
|
|
||||||
});
|
|
||||||
_ambiguate(WidgetsBinding.instance)!.addPostFrameCallback((_) {
|
|
||||||
if (widget.onPressed != null) {
|
|
||||||
widget.onPressed!();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onTapUp: (_) {},
|
|
||||||
child: widget.builder(context, _mouseState)));
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,275 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/gestures.dart';
|
|
||||||
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:spotify/spotify.dart';
|
|
||||||
import 'package:spotube/collections/spotube_icons.dart';
|
|
||||||
import 'package:spotube/components/shared/hover_builder.dart';
|
|
||||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
|
||||||
import 'package:spotube/components/shared/links/artist_link.dart';
|
|
||||||
import 'package:spotube/components/shared/links/link_text.dart';
|
|
||||||
import 'package:spotube/components/shared/track_tile/track_options.dart';
|
|
||||||
import 'package:spotube/extensions/artist_simple.dart';
|
|
||||||
import 'package:spotube/extensions/constrains.dart';
|
|
||||||
import 'package:spotube/extensions/duration.dart';
|
|
||||||
import 'package:spotube/extensions/image.dart';
|
|
||||||
import 'package:spotube/models/local_track.dart';
|
|
||||||
import 'package:spotube/provider/blacklist_provider.dart';
|
|
||||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
|
|
||||||
|
|
||||||
class TrackTile extends HookConsumerWidget {
|
|
||||||
/// [index] will not be shown if null
|
|
||||||
final int? index;
|
|
||||||
final Track track;
|
|
||||||
final bool selected;
|
|
||||||
final ValueChanged<bool?>? onChanged;
|
|
||||||
final Future<void> Function()? onTap;
|
|
||||||
final VoidCallback? onLongPress;
|
|
||||||
final bool userPlaylist;
|
|
||||||
final String? playlistId;
|
|
||||||
final ProxyPlaylist playlist;
|
|
||||||
|
|
||||||
final List<Widget>? leadingActions;
|
|
||||||
|
|
||||||
const TrackTile({
|
|
||||||
super.key,
|
|
||||||
this.index,
|
|
||||||
required this.track,
|
|
||||||
this.selected = false,
|
|
||||||
required this.playlist,
|
|
||||||
this.onTap,
|
|
||||||
this.onLongPress,
|
|
||||||
this.onChanged,
|
|
||||||
this.userPlaylist = false,
|
|
||||||
this.playlistId,
|
|
||||||
this.leadingActions,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, ref) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
final blacklist = ref.watch(blacklistProvider);
|
|
||||||
|
|
||||||
final isBlackListed = useMemoized(
|
|
||||||
() => blacklist.contains(
|
|
||||||
BlacklistedElement.track(
|
|
||||||
track.id!,
|
|
||||||
track.name!,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
[blacklist, track],
|
|
||||||
);
|
|
||||||
|
|
||||||
final showOptionCbRef = useRef<ValueChanged<RelativeRect>?>(null);
|
|
||||||
|
|
||||||
final isLoading = useState(false);
|
|
||||||
|
|
||||||
final isPlaying = playlist.activeTrack?.id == track.id;
|
|
||||||
|
|
||||||
final isSelected = isPlaying || isLoading.value;
|
|
||||||
|
|
||||||
return LayoutBuilder(builder: (context, constrains) {
|
|
||||||
return Listener(
|
|
||||||
onPointerDown: (event) {
|
|
||||||
if (event.buttons != kSecondaryMouseButton) return;
|
|
||||||
showOptionCbRef.value?.call(
|
|
||||||
RelativeRect.fromLTRB(
|
|
||||||
event.position.dx,
|
|
||||||
event.position.dy,
|
|
||||||
constrains.maxWidth - event.position.dx,
|
|
||||||
constrains.maxHeight - event.position.dy,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: HoverBuilder(
|
|
||||||
permanentState: isSelected || constrains.smAndDown ? true : null,
|
|
||||||
builder: (context, isHovering) {
|
|
||||||
return ListTile(
|
|
||||||
selected: isSelected,
|
|
||||||
onTap: () async {
|
|
||||||
try {
|
|
||||||
isLoading.value = true;
|
|
||||||
await onTap?.call();
|
|
||||||
} finally {
|
|
||||||
if (context.mounted) {
|
|
||||||
isLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onLongPress: onLongPress,
|
|
||||||
enabled: !isBlackListed,
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
tileColor:
|
|
||||||
isBlackListed ? theme.colorScheme.errorContainer : null,
|
|
||||||
horizontalTitleGap: 12,
|
|
||||||
leadingAndTrailingTextStyle: theme.textTheme.bodyMedium,
|
|
||||||
leading: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
...?leadingActions,
|
|
||||||
if (index != null && onChanged == null && constrains.mdAndUp)
|
|
||||||
SizedBox(
|
|
||||||
width: 50,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
|
||||||
child: Text(
|
|
||||||
'${(index ?? 0) + 1}',
|
|
||||||
maxLines: 1,
|
|
||||||
style: theme.textTheme.bodySmall,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else if (constrains.smAndDown)
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
if (onChanged != null)
|
|
||||||
Checkbox(
|
|
||||||
value: selected,
|
|
||||||
onChanged: onChanged,
|
|
||||||
),
|
|
||||||
Stack(
|
|
||||||
children: [
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
child: AspectRatio(
|
|
||||||
aspectRatio: 1,
|
|
||||||
child: UniversalImage(
|
|
||||||
path: (track.album?.images).asUrlString(
|
|
||||||
placeholder: ImagePlaceholder.albumArt,
|
|
||||||
),
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned.fill(
|
|
||||||
child: AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 300),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
color: isHovering
|
|
||||||
? Colors.black.withOpacity(0.4)
|
|
||||||
: Colors.transparent,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned.fill(
|
|
||||||
child: Center(
|
|
||||||
child: IconTheme(
|
|
||||||
data: theme.iconTheme
|
|
||||||
.copyWith(size: 26, color: Colors.white),
|
|
||||||
child: Skeleton.ignore(
|
|
||||||
child: AnimatedSwitcher(
|
|
||||||
duration: const Duration(milliseconds: 300),
|
|
||||||
child: (isPlaying && playlist.isFetching) ||
|
|
||||||
isLoading.value
|
|
||||||
? const SizedBox(
|
|
||||||
width: 26,
|
|
||||||
height: 26,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 1.5,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: isPlaying
|
|
||||||
? Icon(
|
|
||||||
SpotubeIcons.pause,
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
: !isHovering
|
|
||||||
? const SizedBox.shrink()
|
|
||||||
: const Icon(SpotubeIcons.play),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
title: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
flex: 6,
|
|
||||||
child: switch (track) {
|
|
||||||
LocalTrack() => Text(
|
|
||||||
track.name!,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
_ => LinkText(
|
|
||||||
track.name!,
|
|
||||||
"/track/${track.id}",
|
|
||||||
push: true,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (constrains.mdAndUp) ...[
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
flex: 4,
|
|
||||||
child: switch (track) {
|
|
||||||
LocalTrack() => Text(
|
|
||||||
track.album!.name!,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
_ => Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: LinkText(
|
|
||||||
track.album!.name!,
|
|
||||||
"/album/${track.album?.id}",
|
|
||||||
extra: track.album,
|
|
||||||
push: true,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
subtitle: Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: track is LocalTrack
|
|
||||||
? Text(
|
|
||||||
track.artists?.asString() ?? '',
|
|
||||||
)
|
|
||||||
: ClipRect(
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxHeight: 40),
|
|
||||||
child: ArtistLink(artists: track.artists ?? []),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
Duration(milliseconds: track.durationMs ?? 0)
|
|
||||||
.toHumanReadableString(padZero: false),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
TrackOptions(
|
|
||||||
track: track,
|
|
||||||
playlistId: playlistId,
|
|
||||||
userPlaylist: userPlaylist,
|
|
||||||
showMenuCbRef: showOptionCbRef,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,8 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:spotube/collections/spotube_icons.dart';
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
import 'package:spotube/components/library/user_local_tracks.dart';
|
import 'package:spotube/modules/library/user_local_tracks.dart';
|
||||||
import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart';
|
import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart';
|
||||||
import 'package:spotube/extensions/context.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
|
|
||||||
class SortTracksDropdown extends StatelessWidget {
|
class SortTracksDropdown extends StatelessWidget {
|
@ -1,100 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:spotube/collections/formatters.dart';
|
|
||||||
import 'package:spotube/components/stats/summary/summary_card.dart';
|
|
||||||
import 'package:spotube/extensions/constrains.dart';
|
|
||||||
import 'package:spotube/pages/stats/albums/albums.dart';
|
|
||||||
import 'package:spotube/pages/stats/artists/artists.dart';
|
|
||||||
import 'package:spotube/pages/stats/fees/fees.dart';
|
|
||||||
import 'package:spotube/pages/stats/minutes/minutes.dart';
|
|
||||||
import 'package:spotube/pages/stats/playlists/playlists.dart';
|
|
||||||
import 'package:spotube/pages/stats/streams/streams.dart';
|
|
||||||
import 'package:spotube/provider/history/summary.dart';
|
|
||||||
import 'package:spotube/utils/service_utils.dart';
|
|
||||||
|
|
||||||
class StatsPageSummarySection extends HookConsumerWidget {
|
|
||||||
const StatsPageSummarySection({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, ref) {
|
|
||||||
final summary = ref.watch(playbackHistorySummaryProvider);
|
|
||||||
|
|
||||||
return SliverPadding(
|
|
||||||
padding: const EdgeInsets.all(10),
|
|
||||||
sliver: SliverLayoutBuilder(builder: (context, constrains) {
|
|
||||||
return SliverGrid(
|
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
|
||||||
crossAxisCount: constrains.isXs
|
|
||||||
? 2
|
|
||||||
: constrains.smAndDown
|
|
||||||
? 3
|
|
||||||
: constrains.mdAndDown
|
|
||||||
? 4
|
|
||||||
: constrains.lgAndDown
|
|
||||||
? 5
|
|
||||||
: 6,
|
|
||||||
mainAxisSpacing: 10,
|
|
||||||
crossAxisSpacing: 10,
|
|
||||||
childAspectRatio: constrains.isXs ? 1.3 : 1.5,
|
|
||||||
),
|
|
||||||
delegate: SliverChildListDelegate([
|
|
||||||
SummaryCard(
|
|
||||||
title: summary.duration.inMinutes.toDouble(),
|
|
||||||
unit: "minutes",
|
|
||||||
description: 'Listened to music',
|
|
||||||
color: Colors.purple,
|
|
||||||
onTap: () {
|
|
||||||
ServiceUtils.pushNamed(context, StatsMinutesPage.name);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
SummaryCard(
|
|
||||||
title: summary.tracks.toDouble(),
|
|
||||||
unit: "songs",
|
|
||||||
description: 'Streamed overall',
|
|
||||||
color: Colors.lightBlue,
|
|
||||||
onTap: () {
|
|
||||||
ServiceUtils.pushNamed(context, StatsStreamsPage.name);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
SummaryCard.unformatted(
|
|
||||||
title: usdFormatter.format(summary.fees.toDouble()),
|
|
||||||
unit: "",
|
|
||||||
description: 'Owed to artists\nthis month',
|
|
||||||
color: Colors.green,
|
|
||||||
onTap: () {
|
|
||||||
ServiceUtils.pushNamed(context, StatsStreamFeesPage.name);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
SummaryCard(
|
|
||||||
title: summary.artists.toDouble(),
|
|
||||||
unit: "artist's",
|
|
||||||
description: 'Music reached you',
|
|
||||||
color: Colors.yellow,
|
|
||||||
onTap: () {
|
|
||||||
ServiceUtils.pushNamed(context, StatsArtistsPage.name);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
SummaryCard(
|
|
||||||
title: summary.albums.toDouble(),
|
|
||||||
unit: "full albums",
|
|
||||||
description: 'Got your love',
|
|
||||||
color: Colors.pink,
|
|
||||||
onTap: () {
|
|
||||||
ServiceUtils.pushNamed(context, StatsAlbumsPage.name);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
SummaryCard(
|
|
||||||
title: summary.playlists.toDouble(),
|
|
||||||
unit: "playlists",
|
|
||||||
description: 'Were on repeat',
|
|
||||||
color: Colors.teal,
|
|
||||||
onTap: () {
|
|
||||||
ServiceUtils.pushNamed(context, StatsPlaylistsPage.name);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,29 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:spotube/collections/formatters.dart';
|
|
||||||
import 'package:spotube/components/stats/common/album_item.dart';
|
|
||||||
import 'package:spotube/provider/history/top.dart';
|
|
||||||
|
|
||||||
class TopAlbums extends HookConsumerWidget {
|
|
||||||
const TopAlbums({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, ref) {
|
|
||||||
final historyDuration = ref.watch(playbackHistoryTopDurationProvider);
|
|
||||||
final albums = ref.watch(playbackHistoryTopProvider(historyDuration)
|
|
||||||
.select((value) => value.albums));
|
|
||||||
|
|
||||||
return SliverList.builder(
|
|
||||||
itemCount: albums.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final album = albums[index];
|
|
||||||
return StatsAlbumItem(
|
|
||||||
album: album.album,
|
|
||||||
info: Text(
|
|
||||||
"${compactNumberFormatter.format(album.count)} plays",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,27 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:spotube/collections/formatters.dart';
|
|
||||||
import 'package:spotube/components/stats/common/artist_item.dart';
|
|
||||||
import 'package:spotube/provider/history/top.dart';
|
|
||||||
|
|
||||||
class TopArtists extends HookConsumerWidget {
|
|
||||||
const TopArtists({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, ref) {
|
|
||||||
final historyDuration = ref.watch(playbackHistoryTopDurationProvider);
|
|
||||||
final artists = ref.watch(playbackHistoryTopProvider(historyDuration)
|
|
||||||
.select((value) => value.artists));
|
|
||||||
|
|
||||||
return SliverList.builder(
|
|
||||||
itemCount: artists.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final artist = artists[index];
|
|
||||||
return StatsArtistItem(
|
|
||||||
artist: artist.artist,
|
|
||||||
info: Text("${compactNumberFormatter.format(artist.count)} plays"),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,31 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:spotube/collections/formatters.dart';
|
|
||||||
import 'package:spotube/components/stats/common/track_item.dart';
|
|
||||||
import 'package:spotube/provider/history/top.dart';
|
|
||||||
|
|
||||||
class TopTracks extends HookConsumerWidget {
|
|
||||||
const TopTracks({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, ref) {
|
|
||||||
final historyDuration = ref.watch(playbackHistoryTopDurationProvider);
|
|
||||||
final tracks = ref.watch(
|
|
||||||
playbackHistoryTopProvider(historyDuration)
|
|
||||||
.select((value) => value.tracks),
|
|
||||||
);
|
|
||||||
|
|
||||||
return SliverList.builder(
|
|
||||||
itemCount: tracks.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final track = tracks[index];
|
|
||||||
return StatsTrackItem(
|
|
||||||
track: track.track,
|
|
||||||
info: Text(
|
|
||||||
"${compactNumberFormatter.format(track.count)} plays",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -34,7 +34,7 @@ class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget {
|
|||||||
),
|
),
|
||||||
borderWidth: 0,
|
borderWidth: 0,
|
||||||
unselectedDecoration: BoxDecoration(
|
unselectedDecoration: BoxDecoration(
|
||||||
color: theme.colorScheme.background,
|
color: theme.colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(15),
|
borderRadius: BorderRadius.circular(15),
|
||||||
),
|
),
|
||||||
unselectedLabelStyle: theme.textTheme.labelLarge?.copyWith(
|
unselectedLabelStyle: theme.textTheme.labelLarge?.copyWith(
|
73
lib/components/titlebar/mouse_state.dart
Normal file
73
lib/components/titlebar/mouse_state.dart
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
typedef MouseStateBuilderCB = Widget Function(
|
||||||
|
BuildContext context, MouseState mouseState);
|
||||||
|
|
||||||
|
class MouseState {
|
||||||
|
bool isMouseOver = false;
|
||||||
|
bool isMouseDown = false;
|
||||||
|
MouseState();
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return "isMouseDown: $isMouseDown - isMouseOver: $isMouseOver";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
T? _ambiguate<T>(T? value) => value;
|
||||||
|
|
||||||
|
class MouseStateBuilder extends StatefulWidget {
|
||||||
|
final MouseStateBuilderCB builder;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
const MouseStateBuilder({super.key, required this.builder, this.onPressed});
|
||||||
|
@override
|
||||||
|
// ignore: library_private_types_in_public_api
|
||||||
|
_MouseStateBuilderState createState() => _MouseStateBuilderState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MouseStateBuilderState extends State<MouseStateBuilder> {
|
||||||
|
late MouseState _mouseState;
|
||||||
|
_MouseStateBuilderState() {
|
||||||
|
_mouseState = MouseState();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MouseRegion(
|
||||||
|
onEnter: (event) {
|
||||||
|
setState(() {
|
||||||
|
_mouseState.isMouseOver = true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onExit: (event) {
|
||||||
|
setState(() {
|
||||||
|
_mouseState.isMouseOver = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: GestureDetector(
|
||||||
|
onTapDown: (_) {
|
||||||
|
setState(() {
|
||||||
|
_mouseState.isMouseDown = true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onTapCancel: () {
|
||||||
|
setState(() {
|
||||||
|
_mouseState.isMouseDown = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_mouseState.isMouseDown = false;
|
||||||
|
_mouseState.isMouseOver = false;
|
||||||
|
});
|
||||||
|
_ambiguate(WidgetsBinding.instance)!.addPostFrameCallback((_) {
|
||||||
|
if (widget.onPressed != null) {
|
||||||
|
widget.onPressed!();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onTapUp: (_) {},
|
||||||
|
child: widget.builder(context, _mouseState),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
179
lib/components/titlebar/titlebar.dart
Normal file
179
lib/components/titlebar/titlebar.dart
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:spotube/components/titlebar/titlebar_buttons.dart';
|
||||||
|
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||||
|
import 'package:spotube/utils/platform.dart';
|
||||||
|
|
||||||
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
|
class PageWindowTitleBar extends StatefulHookConsumerWidget
|
||||||
|
implements PreferredSizeWidget {
|
||||||
|
final Widget? leading;
|
||||||
|
final bool automaticallyImplyLeading;
|
||||||
|
final List<Widget>? actions;
|
||||||
|
final Color? backgroundColor;
|
||||||
|
final Color? foregroundColor;
|
||||||
|
final IconThemeData? actionsIconTheme;
|
||||||
|
final bool? centerTitle;
|
||||||
|
final double? titleSpacing;
|
||||||
|
final double toolbarOpacity;
|
||||||
|
final double? leadingWidth;
|
||||||
|
final TextStyle? toolbarTextStyle;
|
||||||
|
final TextStyle? titleTextStyle;
|
||||||
|
final double? titleWidth;
|
||||||
|
final Widget? title;
|
||||||
|
|
||||||
|
final bool _sliver;
|
||||||
|
|
||||||
|
const PageWindowTitleBar({
|
||||||
|
super.key,
|
||||||
|
this.actions,
|
||||||
|
this.title,
|
||||||
|
this.toolbarOpacity = 1,
|
||||||
|
this.backgroundColor,
|
||||||
|
this.actionsIconTheme,
|
||||||
|
this.automaticallyImplyLeading = false,
|
||||||
|
this.centerTitle,
|
||||||
|
this.foregroundColor,
|
||||||
|
this.leading,
|
||||||
|
this.leadingWidth,
|
||||||
|
this.titleSpacing,
|
||||||
|
this.titleTextStyle,
|
||||||
|
this.titleWidth,
|
||||||
|
this.toolbarTextStyle,
|
||||||
|
}) : _sliver = false,
|
||||||
|
pinned = false,
|
||||||
|
floating = false,
|
||||||
|
snap = false,
|
||||||
|
stretch = false;
|
||||||
|
|
||||||
|
final bool pinned;
|
||||||
|
final bool floating;
|
||||||
|
final bool snap;
|
||||||
|
final bool stretch;
|
||||||
|
|
||||||
|
const PageWindowTitleBar.sliver({
|
||||||
|
super.key,
|
||||||
|
this.actions,
|
||||||
|
this.title,
|
||||||
|
this.backgroundColor,
|
||||||
|
this.actionsIconTheme,
|
||||||
|
this.automaticallyImplyLeading = false,
|
||||||
|
this.centerTitle,
|
||||||
|
this.foregroundColor,
|
||||||
|
this.leading,
|
||||||
|
this.leadingWidth,
|
||||||
|
this.titleSpacing,
|
||||||
|
this.titleTextStyle,
|
||||||
|
this.titleWidth,
|
||||||
|
this.toolbarTextStyle,
|
||||||
|
this.pinned = false,
|
||||||
|
this.floating = false,
|
||||||
|
this.snap = false,
|
||||||
|
this.stretch = false,
|
||||||
|
}) : _sliver = true,
|
||||||
|
toolbarOpacity = 1;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<PageWindowTitleBar> createState() => _PageWindowTitleBarState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PageWindowTitleBarState extends ConsumerState<PageWindowTitleBar> {
|
||||||
|
void onDrag(details) {
|
||||||
|
final systemTitleBar =
|
||||||
|
ref.read(userPreferencesProvider.select((s) => s.systemTitleBar));
|
||||||
|
if (kIsDesktop && !systemTitleBar) {
|
||||||
|
windowManager.startDragging();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final mediaQuery = MediaQuery.of(context);
|
||||||
|
|
||||||
|
if (widget._sliver) {
|
||||||
|
return SliverLayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final hasFullscreen =
|
||||||
|
mediaQuery.size.width == constraints.crossAxisExtent;
|
||||||
|
final hasLeadingOrCanPop =
|
||||||
|
widget.leading != null || Navigator.canPop(context);
|
||||||
|
|
||||||
|
return SliverPadding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: kIsMacOS && hasFullscreen && hasLeadingOrCanPop ? 65 : 0,
|
||||||
|
),
|
||||||
|
sliver: SliverAppBar(
|
||||||
|
leading: widget.leading,
|
||||||
|
automaticallyImplyLeading: widget.automaticallyImplyLeading,
|
||||||
|
actions: [
|
||||||
|
...?widget.actions,
|
||||||
|
WindowTitleBarButtons(foregroundColor: widget.foregroundColor),
|
||||||
|
],
|
||||||
|
backgroundColor: widget.backgroundColor,
|
||||||
|
foregroundColor: widget.foregroundColor,
|
||||||
|
actionsIconTheme: widget.actionsIconTheme,
|
||||||
|
centerTitle: widget.centerTitle,
|
||||||
|
titleSpacing: widget.titleSpacing,
|
||||||
|
leadingWidth: widget.leadingWidth,
|
||||||
|
toolbarTextStyle: widget.toolbarTextStyle,
|
||||||
|
titleTextStyle: widget.titleTextStyle,
|
||||||
|
title: SizedBox(
|
||||||
|
width: double.infinity, // workaround to force dragging
|
||||||
|
child: widget.title ?? const Text(""),
|
||||||
|
),
|
||||||
|
pinned: widget.pinned,
|
||||||
|
floating: widget.floating,
|
||||||
|
snap: widget.snap,
|
||||||
|
stretch: widget.stretch,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return LayoutBuilder(builder: (context, constrains) {
|
||||||
|
final hasFullscreen = mediaQuery.size.width == constrains.maxWidth;
|
||||||
|
final hasLeadingOrCanPop =
|
||||||
|
widget.leading != null || Navigator.canPop(context);
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onHorizontalDragStart: onDrag,
|
||||||
|
onVerticalDragStart: onDrag,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: kIsMacOS && hasFullscreen && hasLeadingOrCanPop ? 65 : 0,
|
||||||
|
),
|
||||||
|
child: AppBar(
|
||||||
|
leading: widget.leading,
|
||||||
|
automaticallyImplyLeading: widget.automaticallyImplyLeading,
|
||||||
|
actions: [
|
||||||
|
...?widget.actions,
|
||||||
|
WindowTitleBarButtons(foregroundColor: widget.foregroundColor),
|
||||||
|
],
|
||||||
|
backgroundColor: widget.backgroundColor,
|
||||||
|
foregroundColor: widget.foregroundColor,
|
||||||
|
actionsIconTheme: widget.actionsIconTheme,
|
||||||
|
centerTitle: widget.centerTitle,
|
||||||
|
titleSpacing: widget.titleSpacing,
|
||||||
|
toolbarOpacity: widget.toolbarOpacity,
|
||||||
|
leadingWidth: widget.leadingWidth,
|
||||||
|
toolbarTextStyle: widget.toolbarTextStyle,
|
||||||
|
titleTextStyle: widget.titleTextStyle,
|
||||||
|
title: SizedBox(
|
||||||
|
width: double.infinity, // workaround to force dragging
|
||||||
|
child: widget.title ?? const Text(""),
|
||||||
|
),
|
||||||
|
scrolledUnderElevation: 0,
|
||||||
|
shadowColor: Colors.transparent,
|
||||||
|
forceMaterialTransparency: true,
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
124
lib/components/titlebar/titlebar_buttons.dart
Normal file
124
lib/components/titlebar/titlebar_buttons.dart
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:spotube/components/titlebar/titlebar_icon_buttons.dart';
|
||||||
|
import 'package:spotube/components/titlebar/window_button.dart';
|
||||||
|
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||||
|
import 'package:spotube/utils/platform.dart';
|
||||||
|
import 'package:titlebar_buttons/titlebar_buttons.dart';
|
||||||
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
|
class WindowTitleBarButtons extends HookConsumerWidget {
|
||||||
|
final Color? foregroundColor;
|
||||||
|
const WindowTitleBarButtons({
|
||||||
|
super.key,
|
||||||
|
this.foregroundColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, ref) {
|
||||||
|
final preferences = ref.watch(userPreferencesProvider);
|
||||||
|
final isMaximized = useState<bool?>(null);
|
||||||
|
const type = ThemeType.auto;
|
||||||
|
|
||||||
|
Future<void> onClose() async {
|
||||||
|
await windowManager.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
if (kIsDesktop) {
|
||||||
|
windowManager.isMaximized().then((value) {
|
||||||
|
isMaximized.value = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!kIsDesktop || kIsMacOS || preferences.systemTitleBar) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kIsWindows) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colors = WindowButtonColors(
|
||||||
|
normal: Colors.transparent,
|
||||||
|
iconNormal: foregroundColor ?? theme.colorScheme.onSurface,
|
||||||
|
mouseOver: theme.colorScheme.onSurface.withOpacity(0.1),
|
||||||
|
mouseDown: theme.colorScheme.onSurface.withOpacity(0.2),
|
||||||
|
iconMouseOver: theme.colorScheme.onSurface,
|
||||||
|
iconMouseDown: theme.colorScheme.onSurface,
|
||||||
|
);
|
||||||
|
|
||||||
|
final closeColors = WindowButtonColors(
|
||||||
|
normal: Colors.transparent,
|
||||||
|
iconNormal: foregroundColor ?? theme.colorScheme.onSurface,
|
||||||
|
mouseOver: Colors.red,
|
||||||
|
mouseDown: Colors.red[800]!,
|
||||||
|
iconMouseOver: Colors.white,
|
||||||
|
iconMouseDown: Colors.black,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 25),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MinimizeWindowButton(
|
||||||
|
onPressed: windowManager.minimize,
|
||||||
|
colors: colors,
|
||||||
|
),
|
||||||
|
if (isMaximized.value != true)
|
||||||
|
MaximizeWindowButton(
|
||||||
|
colors: colors,
|
||||||
|
onPressed: () {
|
||||||
|
windowManager.maximize();
|
||||||
|
isMaximized.value = true;
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else
|
||||||
|
RestoreWindowButton(
|
||||||
|
colors: colors,
|
||||||
|
onPressed: () {
|
||||||
|
windowManager.unmaximize();
|
||||||
|
isMaximized.value = false;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
CloseWindowButton(
|
||||||
|
colors: closeColors,
|
||||||
|
onPressed: onClose,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 20, left: 10),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
DecoratedMinimizeButton(
|
||||||
|
type: type,
|
||||||
|
onPressed: windowManager.minimize,
|
||||||
|
),
|
||||||
|
DecoratedMaximizeButton(
|
||||||
|
type: type,
|
||||||
|
onPressed: () async {
|
||||||
|
if (await windowManager.isMaximized()) {
|
||||||
|
await windowManager.unmaximize();
|
||||||
|
isMaximized.value = false;
|
||||||
|
} else {
|
||||||
|
await windowManager.maximize();
|
||||||
|
isMaximized.value = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DecoratedCloseButton(
|
||||||
|
type: type,
|
||||||
|
onPressed: onClose,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
161
lib/components/titlebar/titlebar_icon_buttons.dart
Normal file
161
lib/components/titlebar/titlebar_icon_buttons.dart
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:spotube/components/titlebar/window_button.dart';
|
||||||
|
|
||||||
|
class MinimizeWindowButton extends WindowButton {
|
||||||
|
MinimizeWindowButton(
|
||||||
|
{super.key, super.colors, super.onPressed, bool? animate})
|
||||||
|
: super(
|
||||||
|
animate: animate ?? false,
|
||||||
|
iconBuilder: (buttonContext) =>
|
||||||
|
MinimizeIcon(color: buttonContext.iconColor),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class MaximizeWindowButton extends WindowButton {
|
||||||
|
MaximizeWindowButton(
|
||||||
|
{super.key, super.colors, super.onPressed, bool? animate})
|
||||||
|
: super(
|
||||||
|
animate: animate ?? false,
|
||||||
|
iconBuilder: (buttonContext) =>
|
||||||
|
MaximizeIcon(color: buttonContext.iconColor),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class RestoreWindowButton extends WindowButton {
|
||||||
|
RestoreWindowButton({super.key, super.colors, super.onPressed, bool? animate})
|
||||||
|
: super(
|
||||||
|
animate: animate ?? false,
|
||||||
|
iconBuilder: (buttonContext) =>
|
||||||
|
RestoreIcon(color: buttonContext.iconColor),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final _defaultCloseButtonColors = WindowButtonColors(
|
||||||
|
mouseOver: const Color(0xFFD32F2F),
|
||||||
|
mouseDown: const Color(0xFFB71C1C),
|
||||||
|
iconNormal: const Color(0xFF805306),
|
||||||
|
iconMouseOver: const Color(0xFFFFFFFF));
|
||||||
|
|
||||||
|
class CloseWindowButton extends WindowButton {
|
||||||
|
CloseWindowButton(
|
||||||
|
{super.key, WindowButtonColors? colors, super.onPressed, bool? animate})
|
||||||
|
: super(
|
||||||
|
colors: colors ?? _defaultCloseButtonColors,
|
||||||
|
animate: animate ?? false,
|
||||||
|
iconBuilder: (buttonContext) =>
|
||||||
|
CloseIcon(color: buttonContext.iconColor),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switched to CustomPaint icons by https://github.com/esDotDev
|
||||||
|
|
||||||
|
/// Close
|
||||||
|
class CloseIcon extends StatelessWidget {
|
||||||
|
final Color color;
|
||||||
|
const CloseIcon({super.key, required this.color});
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Align(
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: Stack(children: [
|
||||||
|
// Use rotated containers instead of a painter because it renders slightly crisper than a painter for some reason.
|
||||||
|
Transform.rotate(
|
||||||
|
angle: pi * .25,
|
||||||
|
child:
|
||||||
|
Center(child: Container(width: 14, height: 1, color: color))),
|
||||||
|
Transform.rotate(
|
||||||
|
angle: pi * -.25,
|
||||||
|
child:
|
||||||
|
Center(child: Container(width: 14, height: 1, color: color))),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maximize
|
||||||
|
class MaximizeIcon extends StatelessWidget {
|
||||||
|
final Color color;
|
||||||
|
const MaximizeIcon({super.key, required this.color});
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => _AlignedPaint(_MaximizePainter(color));
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MaximizePainter extends _IconPainter {
|
||||||
|
_MaximizePainter(super.color);
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
Paint p = getPaint(color);
|
||||||
|
canvas.drawRect(Rect.fromLTRB(0, 0, size.width - 1, size.height - 1), p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore
|
||||||
|
class RestoreIcon extends StatelessWidget {
|
||||||
|
final Color color;
|
||||||
|
const RestoreIcon({
|
||||||
|
super.key,
|
||||||
|
required this.color,
|
||||||
|
});
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => _AlignedPaint(_RestorePainter(color));
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RestorePainter extends _IconPainter {
|
||||||
|
_RestorePainter(super.color);
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
Paint p = getPaint(color);
|
||||||
|
canvas.drawRect(Rect.fromLTRB(0, 2, size.width - 2, size.height), p);
|
||||||
|
canvas.drawLine(const Offset(2, 2), const Offset(2, 0), p);
|
||||||
|
canvas.drawLine(const Offset(2, 0), Offset(size.width, 0), p);
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(size.width, 0), Offset(size.width, size.height - 2), p);
|
||||||
|
canvas.drawLine(Offset(size.width, size.height - 2),
|
||||||
|
Offset(size.width - 2, size.height - 2), p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimize
|
||||||
|
class MinimizeIcon extends StatelessWidget {
|
||||||
|
final Color color;
|
||||||
|
const MinimizeIcon({super.key, required this.color});
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => _AlignedPaint(_MinimizePainter(color));
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MinimizePainter extends _IconPainter {
|
||||||
|
_MinimizePainter(super.color);
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
Paint p = getPaint(color);
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(0, size.height / 2), Offset(size.width, size.height / 2), p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helpers
|
||||||
|
abstract class _IconPainter extends CustomPainter {
|
||||||
|
_IconPainter(this.color);
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AlignedPaint extends StatelessWidget {
|
||||||
|
const _AlignedPaint(this.painter);
|
||||||
|
final CustomPainter painter;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: CustomPaint(size: const Size(10, 10), painter: painter));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Paint getPaint(Color color, [bool isAntiAlias = false]) => Paint()
|
||||||
|
..color = color
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..isAntiAlias = isAntiAlias
|
||||||
|
..strokeWidth = 1;
|
133
lib/components/titlebar/window_button.dart
Normal file
133
lib/components/titlebar/window_button.dart
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:spotube/components/titlebar/mouse_state.dart';
|
||||||
|
|
||||||
|
typedef WindowButtonIconBuilder = Widget Function(
|
||||||
|
WindowButtonContext buttonContext);
|
||||||
|
typedef WindowButtonBuilder = Widget Function(
|
||||||
|
WindowButtonContext buttonContext, Widget icon);
|
||||||
|
|
||||||
|
class WindowButtonContext {
|
||||||
|
BuildContext context;
|
||||||
|
MouseState mouseState;
|
||||||
|
Color? backgroundColor;
|
||||||
|
Color iconColor;
|
||||||
|
WindowButtonContext(
|
||||||
|
{required this.context,
|
||||||
|
required this.mouseState,
|
||||||
|
this.backgroundColor,
|
||||||
|
required this.iconColor});
|
||||||
|
}
|
||||||
|
|
||||||
|
class WindowButtonColors {
|
||||||
|
late Color normal;
|
||||||
|
late Color mouseOver;
|
||||||
|
late Color mouseDown;
|
||||||
|
late Color iconNormal;
|
||||||
|
late Color iconMouseOver;
|
||||||
|
late Color iconMouseDown;
|
||||||
|
WindowButtonColors(
|
||||||
|
{Color? normal,
|
||||||
|
Color? mouseOver,
|
||||||
|
Color? mouseDown,
|
||||||
|
Color? iconNormal,
|
||||||
|
Color? iconMouseOver,
|
||||||
|
Color? iconMouseDown}) {
|
||||||
|
this.normal = normal ?? _defaultButtonColors.normal;
|
||||||
|
this.mouseOver = mouseOver ?? _defaultButtonColors.mouseOver;
|
||||||
|
this.mouseDown = mouseDown ?? _defaultButtonColors.mouseDown;
|
||||||
|
this.iconNormal = iconNormal ?? _defaultButtonColors.iconNormal;
|
||||||
|
this.iconMouseOver = iconMouseOver ?? _defaultButtonColors.iconMouseOver;
|
||||||
|
this.iconMouseDown = iconMouseDown ?? _defaultButtonColors.iconMouseDown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final _defaultButtonColors = WindowButtonColors(
|
||||||
|
normal: Colors.transparent,
|
||||||
|
iconNormal: const Color(0xFF805306),
|
||||||
|
mouseOver: const Color(0xFF404040),
|
||||||
|
mouseDown: const Color(0xFF202020),
|
||||||
|
iconMouseOver: const Color(0xFFFFFFFF),
|
||||||
|
iconMouseDown: const Color(0xFFF0F0F0),
|
||||||
|
);
|
||||||
|
|
||||||
|
class WindowButton extends StatelessWidget {
|
||||||
|
final WindowButtonBuilder? builder;
|
||||||
|
final WindowButtonIconBuilder? iconBuilder;
|
||||||
|
late final WindowButtonColors colors;
|
||||||
|
final bool animate;
|
||||||
|
final EdgeInsets? padding;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
|
||||||
|
WindowButton(
|
||||||
|
{super.key,
|
||||||
|
WindowButtonColors? colors,
|
||||||
|
this.builder,
|
||||||
|
@required this.iconBuilder,
|
||||||
|
this.padding,
|
||||||
|
this.onPressed,
|
||||||
|
this.animate = false}) {
|
||||||
|
this.colors = colors ?? _defaultButtonColors;
|
||||||
|
}
|
||||||
|
|
||||||
|
Color getBackgroundColor(MouseState mouseState) {
|
||||||
|
if (mouseState.isMouseDown) return colors.mouseDown;
|
||||||
|
if (mouseState.isMouseOver) return colors.mouseOver;
|
||||||
|
return colors.normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
Color getIconColor(MouseState mouseState) {
|
||||||
|
if (mouseState.isMouseDown) return colors.iconMouseDown;
|
||||||
|
if (mouseState.isMouseOver) return colors.iconMouseOver;
|
||||||
|
return colors.iconNormal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (kIsWeb) {
|
||||||
|
return Container();
|
||||||
|
} else {
|
||||||
|
// Don't show button on macOS
|
||||||
|
if (Platform.isMacOS) {
|
||||||
|
return Container();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return MouseStateBuilder(
|
||||||
|
builder: (context, mouseState) {
|
||||||
|
WindowButtonContext buttonContext = WindowButtonContext(
|
||||||
|
mouseState: mouseState,
|
||||||
|
context: context,
|
||||||
|
backgroundColor: getBackgroundColor(mouseState),
|
||||||
|
iconColor: getIconColor(mouseState));
|
||||||
|
|
||||||
|
var icon =
|
||||||
|
(iconBuilder != null) ? iconBuilder!(buttonContext) : Container();
|
||||||
|
|
||||||
|
var fadeOutColor =
|
||||||
|
getBackgroundColor(MouseState()..isMouseOver = true).withOpacity(0);
|
||||||
|
var padding = this.padding ?? const EdgeInsets.all(10);
|
||||||
|
var animationMs =
|
||||||
|
mouseState.isMouseOver ? (animate ? 100 : 0) : (animate ? 200 : 0);
|
||||||
|
Widget iconWithPadding = Padding(padding: padding, child: icon);
|
||||||
|
iconWithPadding = AnimatedContainer(
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
duration: Duration(milliseconds: animationMs),
|
||||||
|
color: buttonContext.backgroundColor ?? fadeOutColor,
|
||||||
|
child: iconWithPadding);
|
||||||
|
var button =
|
||||||
|
(builder != null) ? builder!(buttonContext, icon) : iconWithPadding;
|
||||||
|
return SizedBox(
|
||||||
|
width: 45,
|
||||||
|
height: 32,
|
||||||
|
child: button,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onPressed: () {
|
||||||
|
if (onPressed != null) onPressed!();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -8,24 +8,27 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/collections/assets.gen.dart';
|
import 'package:spotube/collections/assets.gen.dart';
|
||||||
import 'package:spotube/collections/spotube_icons.dart';
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart';
|
import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart';
|
||||||
import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart';
|
import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart';
|
||||||
import 'package:spotube/components/shared/dialogs/prompt_dialog.dart';
|
import 'package:spotube/components/dialogs/prompt_dialog.dart';
|
||||||
import 'package:spotube/components/shared/dialogs/track_details_dialog.dart';
|
import 'package:spotube/components/dialogs/track_details_dialog.dart';
|
||||||
import 'package:spotube/components/shared/heart_button.dart';
|
import 'package:spotube/components/heart_button/use_track_toggle_like.dart';
|
||||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
import 'package:spotube/components/image/universal_image.dart';
|
||||||
import 'package:spotube/components/shared/links/artist_link.dart';
|
import 'package:spotube/components/links/artist_link.dart';
|
||||||
import 'package:spotube/extensions/constrains.dart';
|
import 'package:spotube/extensions/constrains.dart';
|
||||||
import 'package:spotube/extensions/context.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
import 'package:spotube/extensions/image.dart';
|
import 'package:spotube/extensions/image.dart';
|
||||||
|
import 'package:spotube/models/database/database.dart';
|
||||||
import 'package:spotube/models/local_track.dart';
|
import 'package:spotube/models/local_track.dart';
|
||||||
import 'package:spotube/provider/authentication_provider.dart';
|
import 'package:spotube/pages/track/track.dart';
|
||||||
|
import 'package:spotube/provider/authentication/authentication.dart';
|
||||||
import 'package:spotube/provider/blacklist_provider.dart';
|
import 'package:spotube/provider/blacklist_provider.dart';
|
||||||
import 'package:spotube/provider/download_manager_provider.dart';
|
import 'package:spotube/provider/download_manager_provider.dart';
|
||||||
import 'package:spotube/provider/local_tracks/local_tracks_provider.dart';
|
import 'package:spotube/provider/local_tracks/local_tracks_provider.dart';
|
||||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
import 'package:spotube/provider/audio_player/audio_player.dart';
|
||||||
import 'package:spotube/provider/spotify/spotify.dart';
|
import 'package:spotube/provider/spotify/spotify.dart';
|
||||||
import 'package:spotube/provider/spotify_provider.dart';
|
import 'package:spotube/provider/spotify_provider.dart';
|
||||||
|
import 'package:spotube/utils/service_utils.dart';
|
||||||
|
|
||||||
import 'package:url_launcher/url_launcher_string.dart';
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
|
|
||||||
@ -95,8 +98,8 @@ class TrackOptions extends HookConsumerWidget {
|
|||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
Track track,
|
Track track,
|
||||||
) async {
|
) async {
|
||||||
final playback = ref.read(proxyPlaylistProvider.notifier);
|
final playback = ref.read(audioPlayerProvider.notifier);
|
||||||
final playlist = ref.read(proxyPlaylistProvider);
|
final playlist = ref.read(audioPlayerProvider);
|
||||||
final spotify = ref.read(spotifyProvider);
|
final spotify = ref.read(spotifyProvider);
|
||||||
final query = "${track.name} Radio";
|
final query = "${track.name} Radio";
|
||||||
final pages =
|
final pages =
|
||||||
@ -159,8 +162,8 @@ class TrackOptions extends HookConsumerWidget {
|
|||||||
final router = GoRouter.of(context);
|
final router = GoRouter.of(context);
|
||||||
final ThemeData(:colorScheme) = Theme.of(context);
|
final ThemeData(:colorScheme) = Theme.of(context);
|
||||||
|
|
||||||
final playlist = ref.watch(proxyPlaylistProvider);
|
final playlist = ref.watch(audioPlayerProvider);
|
||||||
final playback = ref.watch(proxyPlaylistProvider.notifier);
|
final playback = ref.watch(audioPlayerProvider.notifier);
|
||||||
final auth = ref.watch(authenticationProvider);
|
final auth = ref.watch(authenticationProvider);
|
||||||
ref.watch(downloadManagerProvider);
|
ref.watch(downloadManagerProvider);
|
||||||
final downloadManager = ref.watch(downloadManagerProvider.notifier);
|
final downloadManager = ref.watch(downloadManagerProvider.notifier);
|
||||||
@ -170,11 +173,8 @@ class TrackOptions extends HookConsumerWidget {
|
|||||||
final favorites = useTrackToggleLike(track, ref);
|
final favorites = useTrackToggleLike(track, ref);
|
||||||
|
|
||||||
final isBlackListed = useMemoized(
|
final isBlackListed = useMemoized(
|
||||||
() => blacklist.contains(
|
() => blacklist.asData?.value.any(
|
||||||
BlacklistedElement.track(
|
(element) => element.elementId == track.id,
|
||||||
track.id!,
|
|
||||||
track.name!,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
[blacklist, track],
|
[blacklist, track],
|
||||||
);
|
);
|
||||||
@ -258,13 +258,16 @@ class TrackOptions extends HookConsumerWidget {
|
|||||||
.removeTracks(playlistId ?? "", [track.id!]);
|
.removeTracks(playlistId ?? "", [track.id!]);
|
||||||
break;
|
break;
|
||||||
case TrackOptionValue.blacklist:
|
case TrackOptionValue.blacklist:
|
||||||
if (isBlackListed) {
|
if (isBlackListed == null) break;
|
||||||
ref.read(blacklistProvider.notifier).remove(
|
if (isBlackListed == true) {
|
||||||
BlacklistedElement.track(track.id!, track.name!),
|
await ref.read(blacklistProvider.notifier).remove(track.id!);
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
ref.read(blacklistProvider.notifier).add(
|
await ref.read(blacklistProvider.notifier).add(
|
||||||
BlacklistedElement.track(track.id!, track.name!),
|
BlacklistTableCompanion.insert(
|
||||||
|
name: track.name!,
|
||||||
|
elementId: track.id!,
|
||||||
|
elementType: BlacklistedType.track,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@ -312,7 +315,16 @@ class TrackOptions extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
subtitle: Align(
|
subtitle: Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: ArtistLink(artists: track.artists!),
|
child: ArtistLink(
|
||||||
|
artists: track.artists!,
|
||||||
|
onOverflowArtistClick: () => ServiceUtils.pushNamed(
|
||||||
|
context,
|
||||||
|
TrackPage.name,
|
||||||
|
pathParameters: {
|
||||||
|
"id": track.id!,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -323,7 +335,7 @@ class TrackOptions extends HookConsumerWidget {
|
|||||||
leading: const Icon(SpotubeIcons.trash),
|
leading: const Icon(SpotubeIcons.trash),
|
||||||
title: Text(context.l10n.delete),
|
title: Text(context.l10n.delete),
|
||||||
),
|
),
|
||||||
if (mediaQuery.smAndDown)
|
if (mediaQuery.smAndDown && !isLocalTrack)
|
||||||
PopSheetEntry(
|
PopSheetEntry(
|
||||||
value: TrackOptionValue.album,
|
value: TrackOptionValue.album,
|
||||||
leading: const Icon(SpotubeIcons.album),
|
leading: const Icon(SpotubeIcons.album),
|
||||||
@ -363,7 +375,7 @@ class TrackOptions extends HookConsumerWidget {
|
|||||||
: context.l10n.save_as_favorite,
|
: context.l10n.save_as_favorite,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (auth != null && !isLocalTrack) ...[
|
if (auth.asData?.value != null && !isLocalTrack) ...[
|
||||||
PopSheetEntry(
|
PopSheetEntry(
|
||||||
value: TrackOptionValue.startRadio,
|
value: TrackOptionValue.startRadio,
|
||||||
leading: const Icon(SpotubeIcons.radio),
|
leading: const Icon(SpotubeIcons.radio),
|
||||||
@ -375,7 +387,7 @@ class TrackOptions extends HookConsumerWidget {
|
|||||||
title: Text(context.l10n.add_to_playlist),
|
title: Text(context.l10n.add_to_playlist),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (userPlaylist && auth != null && !isLocalTrack)
|
if (userPlaylist && auth.asData?.value != null && !isLocalTrack)
|
||||||
PopSheetEntry(
|
PopSheetEntry(
|
||||||
value: TrackOptionValue.removeFromPlaylist,
|
value: TrackOptionValue.removeFromPlaylist,
|
||||||
leading: const Icon(SpotubeIcons.removeFilled),
|
leading: const Icon(SpotubeIcons.removeFilled),
|
||||||
@ -399,10 +411,10 @@ class TrackOptions extends HookConsumerWidget {
|
|||||||
PopSheetEntry(
|
PopSheetEntry(
|
||||||
value: TrackOptionValue.blacklist,
|
value: TrackOptionValue.blacklist,
|
||||||
leading: const Icon(SpotubeIcons.playlistRemove),
|
leading: const Icon(SpotubeIcons.playlistRemove),
|
||||||
iconColor: !isBlackListed ? Colors.red[400] : null,
|
iconColor: isBlackListed != true ? Colors.red[400] : null,
|
||||||
textColor: !isBlackListed ? Colors.red[400] : null,
|
textColor: isBlackListed != true ? Colors.red[400] : null,
|
||||||
title: Text(
|
title: Text(
|
||||||
isBlackListed
|
isBlackListed == true
|
||||||
? context.l10n.remove_from_blacklist
|
? context.l10n.remove_from_blacklist
|
||||||
: context.l10n.add_to_blacklist,
|
: context.l10n.add_to_blacklist,
|
||||||
),
|
),
|
286
lib/components/track_tile/track_tile.dart
Normal file
286
lib/components/track_tile/track_tile.dart
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
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:spotify/spotify.dart';
|
||||||
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
|
import 'package:spotube/components/hover_builder.dart';
|
||||||
|
import 'package:spotube/components/image/universal_image.dart';
|
||||||
|
import 'package:spotube/components/links/artist_link.dart';
|
||||||
|
import 'package:spotube/components/links/link_text.dart';
|
||||||
|
import 'package:spotube/components/track_tile/track_options.dart';
|
||||||
|
import 'package:spotube/extensions/artist_simple.dart';
|
||||||
|
import 'package:spotube/extensions/constrains.dart';
|
||||||
|
import 'package:spotube/extensions/duration.dart';
|
||||||
|
import 'package:spotube/extensions/image.dart';
|
||||||
|
import 'package:spotube/models/local_track.dart';
|
||||||
|
import 'package:spotube/pages/track/track.dart';
|
||||||
|
import 'package:spotube/provider/audio_player/querying_track_info.dart';
|
||||||
|
import 'package:spotube/provider/audio_player/state.dart';
|
||||||
|
import 'package:spotube/provider/blacklist_provider.dart';
|
||||||
|
import 'package:spotube/utils/service_utils.dart';
|
||||||
|
|
||||||
|
class TrackTile extends HookConsumerWidget {
|
||||||
|
/// [index] will not be shown if null
|
||||||
|
final int? index;
|
||||||
|
final Track track;
|
||||||
|
final bool selected;
|
||||||
|
final ValueChanged<bool?>? onChanged;
|
||||||
|
final Future<void> Function()? onTap;
|
||||||
|
final VoidCallback? onLongPress;
|
||||||
|
final bool userPlaylist;
|
||||||
|
final String? playlistId;
|
||||||
|
final AudioPlayerState playlist;
|
||||||
|
|
||||||
|
final List<Widget>? leadingActions;
|
||||||
|
|
||||||
|
const TrackTile({
|
||||||
|
super.key,
|
||||||
|
this.index,
|
||||||
|
required this.track,
|
||||||
|
this.selected = false,
|
||||||
|
required this.playlist,
|
||||||
|
this.onTap,
|
||||||
|
this.onLongPress,
|
||||||
|
this.onChanged,
|
||||||
|
this.userPlaylist = false,
|
||||||
|
this.playlistId,
|
||||||
|
this.leadingActions,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, ref) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
final blacklist = ref.watch(blacklistProvider);
|
||||||
|
final blacklistNotifier = ref.watch(blacklistProvider.notifier);
|
||||||
|
|
||||||
|
final isBlackListed = useMemoized(
|
||||||
|
() => blacklistNotifier.contains(track),
|
||||||
|
[blacklist, track],
|
||||||
|
);
|
||||||
|
|
||||||
|
final showOptionCbRef = useRef<ValueChanged<RelativeRect>?>(null);
|
||||||
|
|
||||||
|
final isLoading = useState(false);
|
||||||
|
|
||||||
|
final isPlaying = playlist.activeTrack?.id == track.id;
|
||||||
|
|
||||||
|
final isSelected = isPlaying || isLoading.value;
|
||||||
|
|
||||||
|
return LayoutBuilder(builder: (context, constrains) {
|
||||||
|
return Listener(
|
||||||
|
onPointerDown: (event) {
|
||||||
|
if (event.buttons != kSecondaryMouseButton) return;
|
||||||
|
showOptionCbRef.value?.call(
|
||||||
|
RelativeRect.fromLTRB(
|
||||||
|
event.position.dx,
|
||||||
|
event.position.dy,
|
||||||
|
constrains.maxWidth - event.position.dx,
|
||||||
|
constrains.maxHeight - event.position.dy,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: HoverBuilder(
|
||||||
|
permanentState: isSelected || constrains.smAndDown ? true : null,
|
||||||
|
builder: (context, isHovering) => ListTile(
|
||||||
|
selected: isSelected,
|
||||||
|
onTap: () async {
|
||||||
|
try {
|
||||||
|
isLoading.value = true;
|
||||||
|
await onTap?.call();
|
||||||
|
} finally {
|
||||||
|
if (context.mounted) {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongPress: onLongPress,
|
||||||
|
enabled: !isBlackListed,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
tileColor: isBlackListed ? theme.colorScheme.errorContainer : null,
|
||||||
|
horizontalTitleGap: 12,
|
||||||
|
leadingAndTrailingTextStyle: theme.textTheme.bodyMedium,
|
||||||
|
leading: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
...?leadingActions,
|
||||||
|
if (index != null && onChanged == null && constrains.mdAndUp)
|
||||||
|
SizedBox(
|
||||||
|
width: 50,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||||
|
child: Text(
|
||||||
|
'${(index ?? 0) + 1}',
|
||||||
|
maxLines: 1,
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (constrains.smAndDown)
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
if (onChanged != null)
|
||||||
|
Checkbox(
|
||||||
|
value: selected,
|
||||||
|
onChanged: onChanged,
|
||||||
|
),
|
||||||
|
Stack(
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: AspectRatio(
|
||||||
|
aspectRatio: 1,
|
||||||
|
child: UniversalImage(
|
||||||
|
path: (track.album?.images).asUrlString(
|
||||||
|
placeholder: ImagePlaceholder.albumArt,
|
||||||
|
),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned.fill(
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
color: isHovering
|
||||||
|
? Colors.black.withOpacity(0.4)
|
||||||
|
: Colors.transparent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned.fill(
|
||||||
|
child: Center(
|
||||||
|
child: IconTheme(
|
||||||
|
data: theme.iconTheme
|
||||||
|
.copyWith(size: 26, color: Colors.white),
|
||||||
|
child: Skeleton.ignore(
|
||||||
|
child: Consumer(
|
||||||
|
builder: (context, ref, _) {
|
||||||
|
final isFetchingActiveTrack =
|
||||||
|
ref.watch(queryingTrackInfoProvider);
|
||||||
|
return AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
child: (isPlaying && isFetchingActiveTrack) ||
|
||||||
|
isLoading.value
|
||||||
|
? const SizedBox(
|
||||||
|
width: 26,
|
||||||
|
height: 26,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 1.5,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: isPlaying
|
||||||
|
? Icon(
|
||||||
|
SpotubeIcons.pause,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
: !isHovering
|
||||||
|
? const SizedBox.shrink()
|
||||||
|
: const Icon(SpotubeIcons.play),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 6,
|
||||||
|
child: switch (track) {
|
||||||
|
LocalTrack() => Text(
|
||||||
|
track.name!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
_ => LinkText(
|
||||||
|
track.name!,
|
||||||
|
"/track/${track.id}",
|
||||||
|
push: true,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (constrains.mdAndUp) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
flex: 4,
|
||||||
|
child: switch (track) {
|
||||||
|
LocalTrack() => Text(
|
||||||
|
track.album!.name!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
_ => Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: LinkText(
|
||||||
|
track.album!.name!,
|
||||||
|
"/album/${track.album?.id}",
|
||||||
|
extra: track.album,
|
||||||
|
push: true,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
subtitle: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: track is LocalTrack
|
||||||
|
? Text(
|
||||||
|
track.artists?.asString() ?? '',
|
||||||
|
)
|
||||||
|
: ClipRect(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 40),
|
||||||
|
child: ArtistLink(
|
||||||
|
artists: track.artists ?? [],
|
||||||
|
onOverflowArtistClick: () => ServiceUtils.pushNamed(
|
||||||
|
context,
|
||||||
|
TrackPage.name,
|
||||||
|
pathParameters: {
|
||||||
|
"id": track.id!,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
Duration(milliseconds: track.durationMs ?? 0)
|
||||||
|
.toHumanReadableString(padZero: false),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
TrackOptions(
|
||||||
|
track: track,
|
||||||
|
playlistId: playlistId,
|
||||||
|
userPlaylist: userPlaylist,
|
||||||
|
showMenuCbRef: showOptionCbRef,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -8,17 +8,17 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:skeletonizer/skeletonizer.dart';
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/collections/fake.dart';
|
import 'package:spotube/collections/fake.dart';
|
||||||
import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
|
import 'package:spotube/components/dialogs/select_device_dialog.dart';
|
||||||
import 'package:spotube/components/shared/expandable_search/expandable_search.dart';
|
import 'package:spotube/components/expandable_search/expandable_search.dart';
|
||||||
import 'package:spotube/components/shared/track_tile/track_tile.dart';
|
import 'package:spotube/components/track_tile/track_tile.dart';
|
||||||
import 'package:spotube/components/shared/tracks_view/sections/body/track_view_body_headers.dart';
|
import 'package:spotube/components/tracks_view/sections/body/track_view_body_headers.dart';
|
||||||
import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_playlist.dart';
|
import 'package:spotube/components/tracks_view/sections/body/use_is_user_playlist.dart';
|
||||||
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
|
import 'package:spotube/components/tracks_view/track_view_props.dart';
|
||||||
import 'package:spotube/components/shared/tracks_view/track_view_provider.dart';
|
import 'package:spotube/components/tracks_view/track_view_provider.dart';
|
||||||
import 'package:spotube/models/connect/connect.dart';
|
import 'package:spotube/models/connect/connect.dart';
|
||||||
import 'package:spotube/provider/connect/connect.dart';
|
import 'package:spotube/provider/connect/connect.dart';
|
||||||
import 'package:spotube/provider/history/history.dart';
|
import 'package:spotube/provider/history/history.dart';
|
||||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
import 'package:spotube/provider/audio_player/audio_player.dart';
|
||||||
import 'package:spotube/utils/service_utils.dart';
|
import 'package:spotube/utils/service_utils.dart';
|
||||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
|
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
|
||||||
|
|
||||||
@ -27,9 +27,9 @@ class TrackViewBodySection extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
final playlist = ref.watch(proxyPlaylistProvider);
|
final playlist = ref.watch(audioPlayerProvider);
|
||||||
final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
|
final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
|
||||||
final historyNotifier = ref.watch(playbackHistoryProvider.notifier);
|
final historyNotifier = ref.watch(playbackHistoryActionsProvider);
|
||||||
final props = InheritedTrackView.of(context);
|
final props = InheritedTrackView.of(context);
|
||||||
final trackViewState = ref.watch(trackViewProvider(props.tracks));
|
final trackViewState = ref.watch(trackViewProvider(props.tracks));
|
||||||
|
|
@ -1,10 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotube/components/shared/expandable_search/expandable_search.dart';
|
import 'package:spotube/components/expandable_search/expandable_search.dart';
|
||||||
import 'package:spotube/components/shared/sort_tracks_dropdown.dart';
|
import 'package:spotube/components/sort_tracks_dropdown.dart';
|
||||||
import 'package:spotube/components/shared/tracks_view/sections/body/track_view_options.dart';
|
import 'package:spotube/components/tracks_view/sections/body/track_view_options.dart';
|
||||||
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
|
import 'package:spotube/components/tracks_view/track_view_props.dart';
|
||||||
import 'package:spotube/components/shared/tracks_view/track_view_provider.dart';
|
import 'package:spotube/components/tracks_view/track_view_provider.dart';
|
||||||
import 'package:spotube/extensions/constrains.dart';
|
import 'package:spotube/extensions/constrains.dart';
|
||||||
import 'package:spotube/extensions/context.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
|
|
@ -2,17 +2,17 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/collections/spotube_icons.dart';
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart';
|
import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart';
|
||||||
import 'package:spotube/components/shared/dialogs/confirm_download_dialog.dart';
|
import 'package:spotube/components/dialogs/confirm_download_dialog.dart';
|
||||||
import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart';
|
import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart';
|
||||||
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
|
import 'package:spotube/components/tracks_view/track_view_props.dart';
|
||||||
import 'package:spotube/components/shared/tracks_view/track_view_provider.dart';
|
import 'package:spotube/components/tracks_view/track_view_provider.dart';
|
||||||
import 'package:spotube/extensions/context.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
|
import 'package:spotube/models/database/database.dart';
|
||||||
import 'package:spotube/provider/download_manager_provider.dart';
|
import 'package:spotube/provider/download_manager_provider.dart';
|
||||||
import 'package:spotube/provider/history/history.dart';
|
import 'package:spotube/provider/history/history.dart';
|
||||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
import 'package:spotube/provider/audio_player/audio_player.dart';
|
||||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||||
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
|
|
||||||
|
|
||||||
class TrackViewBodyOptions extends HookConsumerWidget {
|
class TrackViewBodyOptions extends HookConsumerWidget {
|
||||||
const TrackViewBodyOptions({super.key});
|
const TrackViewBodyOptions({super.key});
|
||||||
@ -24,8 +24,8 @@ class TrackViewBodyOptions extends HookConsumerWidget {
|
|||||||
|
|
||||||
ref.watch(downloadManagerProvider);
|
ref.watch(downloadManagerProvider);
|
||||||
final downloader = ref.watch(downloadManagerProvider.notifier);
|
final downloader = ref.watch(downloadManagerProvider.notifier);
|
||||||
final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
|
final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
|
||||||
final historyNotifier = ref.watch(playbackHistoryProvider.notifier);
|
final historyNotifier = ref.watch(playbackHistoryActionsProvider);
|
||||||
final audioSource =
|
final audioSource =
|
||||||
ref.watch(userPreferencesProvider.select((s) => s.audioSource));
|
ref.watch(userPreferencesProvider.select((s) => s.audioSource));
|
||||||
|
|
@ -4,13 +4,13 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotube/collections/assets.gen.dart';
|
import 'package:spotube/collections/assets.gen.dart';
|
||||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
import 'package:spotube/components/image/universal_image.dart';
|
||||||
import 'package:spotube/components/shared/playbutton_card.dart';
|
import 'package:spotube/components/tracks_view/sections/header/header_actions.dart';
|
||||||
import 'package:spotube/components/shared/tracks_view/sections/header/header_actions.dart';
|
import 'package:spotube/components/tracks_view/sections/header/header_buttons.dart';
|
||||||
import 'package:spotube/components/shared/tracks_view/sections/header/header_buttons.dart';
|
import 'package:spotube/components/tracks_view/track_view_props.dart';
|
||||||
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
|
|
||||||
import 'package:gap/gap.dart';
|
import 'package:gap/gap.dart';
|
||||||
import 'package:spotube/extensions/constrains.dart';
|
import 'package:spotube/extensions/constrains.dart';
|
||||||
|
import 'package:spotube/extensions/string.dart';
|
||||||
import 'package:spotube/hooks/utils/use_palette_color.dart';
|
import 'package:spotube/hooks/utils/use_palette_color.dart';
|
||||||
import 'package:spotube/utils/platform.dart';
|
import 'package:spotube/utils/platform.dart';
|
||||||
|
|
||||||
@ -24,8 +24,6 @@ class TrackViewFlexHeader extends HookConsumerWidget {
|
|||||||
final defaultTextStyle = DefaultTextStyle.of(context);
|
final defaultTextStyle = DefaultTextStyle.of(context);
|
||||||
final mediaQuery = MediaQuery.of(context);
|
final mediaQuery = MediaQuery.of(context);
|
||||||
|
|
||||||
final description = useDescription(props.description);
|
|
||||||
|
|
||||||
final palette = usePaletteColor(props.image, ref);
|
final palette = usePaletteColor(props.image, ref);
|
||||||
|
|
||||||
return IconTheme(
|
return IconTheme(
|
||||||
@ -127,10 +125,10 @@ class TrackViewFlexHeader extends HookConsumerWidget {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
if (description != null &&
|
if (props.description != null &&
|
||||||
description.isNotEmpty)
|
props.description!.isNotEmpty)
|
||||||
Text(
|
Text(
|
||||||
description,
|
props.description!.unescapeHtml(),
|
||||||
style:
|
style:
|
||||||
defaultTextStyle.style.copyWith(
|
defaultTextStyle.style.copyWith(
|
||||||
color: palette.bodyTextColor,
|
color: palette.bodyTextColor,
|
@ -4,14 +4,14 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/collections/spotube_icons.dart';
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
import 'package:spotube/components/playlist/playlist_create_dialog.dart';
|
import 'package:spotube/modules/playlist/playlist_create_dialog.dart';
|
||||||
import 'package:spotube/components/shared/heart_button.dart';
|
import 'package:spotube/components/heart_button/heart_button.dart';
|
||||||
import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_playlist.dart';
|
import 'package:spotube/components/tracks_view/sections/body/use_is_user_playlist.dart';
|
||||||
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
|
import 'package:spotube/components/tracks_view/track_view_props.dart';
|
||||||
import 'package:spotube/extensions/context.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
import 'package:spotube/provider/authentication_provider.dart';
|
import 'package:spotube/provider/authentication/authentication.dart';
|
||||||
import 'package:spotube/provider/history/history.dart';
|
import 'package:spotube/provider/history/history.dart';
|
||||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
import 'package:spotube/provider/audio_player/audio_player.dart';
|
||||||
|
|
||||||
class TrackViewHeaderActions extends HookConsumerWidget {
|
class TrackViewHeaderActions extends HookConsumerWidget {
|
||||||
const TrackViewHeaderActions({super.key});
|
const TrackViewHeaderActions({super.key});
|
||||||
@ -20,9 +20,9 @@ class TrackViewHeaderActions extends HookConsumerWidget {
|
|||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
final props = InheritedTrackView.of(context);
|
final props = InheritedTrackView.of(context);
|
||||||
|
|
||||||
final playlist = ref.watch(proxyPlaylistProvider);
|
final playlist = ref.watch(audioPlayerProvider);
|
||||||
final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
|
final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
|
||||||
final historyNotifier = ref.watch(playbackHistoryProvider.notifier);
|
final historyNotifier = ref.watch(playbackHistoryActionsProvider);
|
||||||
|
|
||||||
final isActive = playlist.collections.contains(props.collectionId);
|
final isActive = playlist.collections.contains(props.collectionId);
|
||||||
|
|
||||||
@ -32,6 +32,9 @@ class TrackViewHeaderActions extends HookConsumerWidget {
|
|||||||
|
|
||||||
final auth = ref.watch(authenticationProvider);
|
final auth = ref.watch(authenticationProvider);
|
||||||
|
|
||||||
|
final copiedText =
|
||||||
|
context.l10n.copied_shareurl_to_clipboard(props.shareUrl);
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@ -48,7 +51,7 @@ class TrackViewHeaderActions extends HookConsumerWidget {
|
|||||||
width: 300,
|
width: 300,
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
content: Text(
|
content: Text(
|
||||||
"Copied ${props.shareUrl} to clipboard",
|
copiedText,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -73,7 +76,7 @@ class TrackViewHeaderActions extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (props.onHeart != null && auth != null)
|
if (props.onHeart != null && auth.asData?.value != null)
|
||||||
HeartButton(
|
HeartButton(
|
||||||
isLiked: props.isLiked,
|
isLiked: props.isLiked,
|
||||||
icon: isUserPlaylist ? SpotubeIcons.trash : null,
|
icon: isUserPlaylist ? SpotubeIcons.trash : null,
|
@ -7,13 +7,13 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:palette_generator/palette_generator.dart';
|
import 'package:palette_generator/palette_generator.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/collections/spotube_icons.dart';
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
|
import 'package:spotube/components/dialogs/select_device_dialog.dart';
|
||||||
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
|
import 'package:spotube/components/tracks_view/track_view_props.dart';
|
||||||
import 'package:spotube/extensions/context.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
import 'package:spotube/models/connect/connect.dart';
|
import 'package:spotube/models/connect/connect.dart';
|
||||||
import 'package:spotube/provider/connect/connect.dart';
|
import 'package:spotube/provider/connect/connect.dart';
|
||||||
import 'package:spotube/provider/history/history.dart';
|
import 'package:spotube/provider/history/history.dart';
|
||||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
import 'package:spotube/provider/audio_player/audio_player.dart';
|
||||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||||
|
|
||||||
class TrackViewHeaderButtons extends HookConsumerWidget {
|
class TrackViewHeaderButtons extends HookConsumerWidget {
|
||||||
@ -28,9 +28,9 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
final props = InheritedTrackView.of(context);
|
final props = InheritedTrackView.of(context);
|
||||||
final playlist = ref.watch(proxyPlaylistProvider);
|
final playlist = ref.watch(audioPlayerProvider);
|
||||||
final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
|
final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
|
||||||
final historyNotifier = ref.watch(playbackHistoryProvider.notifier);
|
final historyNotifier = ref.watch(playbackHistoryActionsProvider);
|
||||||
|
|
||||||
final isActive = playlist.collections.contains(props.collectionId);
|
final isActive = playlist.collections.contains(props.collectionId);
|
||||||
|
|
||||||
@ -131,7 +131,9 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
if (context.mounted) {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3,11 +3,11 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:sliver_tools/sliver_tools.dart';
|
import 'package:sliver_tools/sliver_tools.dart';
|
||||||
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
|
import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart';
|
||||||
import 'package:spotube/components/shared/page_window_title_bar.dart';
|
import 'package:spotube/components/titlebar/titlebar.dart';
|
||||||
import 'package:spotube/components/shared/tracks_view/sections/header/flexible_header.dart';
|
import 'package:spotube/components/tracks_view/sections/header/flexible_header.dart';
|
||||||
import 'package:spotube/components/shared/tracks_view/sections/body/track_view_body.dart';
|
import 'package:spotube/components/tracks_view/sections/body/track_view_body.dart';
|
||||||
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
|
import 'package:spotube/components/tracks_view/track_view_props.dart';
|
||||||
import 'package:spotube/utils/platform.dart';
|
import 'package:spotube/utils/platform.dart';
|
||||||
|
|
||||||
class TrackView extends HookConsumerWidget {
|
class TrackView extends HookConsumerWidget {
|
@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/components/library/user_local_tracks.dart';
|
import 'package:spotube/modules/library/user_local_tracks.dart';
|
||||||
|
|
||||||
class TrackViewNotifier extends ChangeNotifier {
|
class TrackViewNotifier extends ChangeNotifier {
|
||||||
List<Track> tracks;
|
List<Track> tracks;
|
@ -1,6 +1,20 @@
|
|||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
enum Breakpoint {
|
||||||
|
xs,
|
||||||
|
sm,
|
||||||
|
md,
|
||||||
|
lg,
|
||||||
|
xl,
|
||||||
|
xxl;
|
||||||
|
|
||||||
|
bool operator <=(Breakpoint other) => index <= other.index;
|
||||||
|
bool operator <(Breakpoint other) => index < other.index;
|
||||||
|
bool operator >(Breakpoint other) => index > other.index;
|
||||||
|
bool operator >=(Breakpoint other) => index >= other.index;
|
||||||
|
}
|
||||||
|
|
||||||
// ignore: constant_identifier_names
|
// ignore: constant_identifier_names
|
||||||
const Breakpoints = (
|
const Breakpoints = (
|
||||||
xs: 480.0,
|
xs: 480.0,
|
||||||
@ -22,6 +36,15 @@ extension SliverBreakpoints on SliverConstraints {
|
|||||||
crossAxisExtent > Breakpoints.lg && crossAxisExtent <= Breakpoints.xl;
|
crossAxisExtent > Breakpoints.lg && crossAxisExtent <= Breakpoints.xl;
|
||||||
bool get is2Xl => crossAxisExtent > Breakpoints.xl;
|
bool get is2Xl => crossAxisExtent > Breakpoints.xl;
|
||||||
|
|
||||||
|
Breakpoint get breakpoint {
|
||||||
|
if (isXs) return Breakpoint.xs;
|
||||||
|
if (isSm) return Breakpoint.sm;
|
||||||
|
if (isMd) return Breakpoint.md;
|
||||||
|
if (isLg) return Breakpoint.lg;
|
||||||
|
if (isXl) return Breakpoint.xl;
|
||||||
|
return Breakpoint.xxl;
|
||||||
|
}
|
||||||
|
|
||||||
bool get smAndUp => isSm || isMd || isLg || isXl || is2Xl;
|
bool get smAndUp => isSm || isMd || isLg || isXl || is2Xl;
|
||||||
bool get mdAndUp => isMd || isLg || isXl || is2Xl;
|
bool get mdAndUp => isMd || isLg || isXl || is2Xl;
|
||||||
bool get lgAndUp => isLg || isXl || is2Xl;
|
bool get lgAndUp => isLg || isXl || is2Xl;
|
||||||
@ -45,6 +68,15 @@ extension ContainerBreakpoints on BoxConstraints {
|
|||||||
biggest.width > Breakpoints.lg && biggest.width <= Breakpoints.xl;
|
biggest.width > Breakpoints.lg && biggest.width <= Breakpoints.xl;
|
||||||
bool get is2Xl => biggest.width > Breakpoints.xl;
|
bool get is2Xl => biggest.width > Breakpoints.xl;
|
||||||
|
|
||||||
|
Breakpoint get breakpoint {
|
||||||
|
if (isXs) return Breakpoint.xs;
|
||||||
|
if (isSm) return Breakpoint.sm;
|
||||||
|
if (isMd) return Breakpoint.md;
|
||||||
|
if (isLg) return Breakpoint.lg;
|
||||||
|
if (isXl) return Breakpoint.xl;
|
||||||
|
return Breakpoint.xxl;
|
||||||
|
}
|
||||||
|
|
||||||
bool get smAndUp => isSm || isMd || isLg || isXl || is2Xl;
|
bool get smAndUp => isSm || isMd || isLg || isXl || is2Xl;
|
||||||
bool get mdAndUp => isMd || isLg || isXl || is2Xl;
|
bool get mdAndUp => isMd || isLg || isXl || is2Xl;
|
||||||
bool get lgAndUp => isLg || isXl || is2Xl;
|
bool get lgAndUp => isLg || isXl || is2Xl;
|
||||||
|
@ -1,99 +0,0 @@
|
|||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:spotube/models/logger.dart';
|
|
||||||
|
|
||||||
final logger = getLogger("List");
|
|
||||||
|
|
||||||
extension MultiSortListMap on List<Map> {
|
|
||||||
/// [preference] - List of properties in which you want to sort the list
|
|
||||||
/// i.e.
|
|
||||||
/// ```
|
|
||||||
/// List<String> preference = ['property1','property2'];
|
|
||||||
/// ```
|
|
||||||
/// This will first sort the list by property1 then by property2
|
|
||||||
///
|
|
||||||
/// [criteria] - List of booleans that specifies the criteria of sort
|
|
||||||
/// i.e., For ascending order `true` and for descending order `false`.
|
|
||||||
/// ```
|
|
||||||
/// List<bool> criteria = [true. false];
|
|
||||||
/// ```
|
|
||||||
List<Map> sortByProperties(List<bool> criteria, List<String> preference) {
|
|
||||||
if (preference.isEmpty || criteria.isEmpty || isEmpty) {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
if (preference.length != criteria.length) {
|
|
||||||
logger.d('Criteria length is not equal to preference');
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
int compare(int i, Map a, Map b) {
|
|
||||||
if (a[preference[i]] == b[preference[i]]) {
|
|
||||||
return 0;
|
|
||||||
} else if (a[preference[i]] > b[preference[i]]) {
|
|
||||||
return criteria[i] ? 1 : -1;
|
|
||||||
} else {
|
|
||||||
return criteria[i] ? -1 : 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int sortAll(Map a, Map b) {
|
|
||||||
int i = 0;
|
|
||||||
int result = 0;
|
|
||||||
while (i < preference.length) {
|
|
||||||
result = compare(i, a, b);
|
|
||||||
if (result != 0) break;
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
return sorted((a, b) => sortAll(a, b));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MultiSortListTupleMap<V> on List<(Map, V)> {
|
|
||||||
/// [preference] - List of properties in which you want to sort the list
|
|
||||||
/// i.e.
|
|
||||||
/// ```
|
|
||||||
/// List<String> preference = ['property1','property2'];
|
|
||||||
/// ```
|
|
||||||
/// This will first sort the list by property1 then by property2
|
|
||||||
///
|
|
||||||
/// [criteria] - List of booleans that specifies the criteria of sort
|
|
||||||
/// i.e., For ascending order `true` and for descending order `false`.
|
|
||||||
/// ```
|
|
||||||
/// List<bool> criteria = [true. false];
|
|
||||||
/// ```
|
|
||||||
List<(Map, V)> sortByProperties(
|
|
||||||
List<bool> criteria, List<String> preference) {
|
|
||||||
if (preference.isEmpty || criteria.isEmpty || isEmpty) {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
if (preference.length != criteria.length) {
|
|
||||||
logger.d('Criteria length is not equal to preference');
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
int compare(int i, (Map, V) a, (Map, V) b) {
|
|
||||||
if (a.$1[preference[i]] == b.$1[preference[i]]) {
|
|
||||||
return 0;
|
|
||||||
} else if (a.$1[preference[i]] > b.$1[preference[i]]) {
|
|
||||||
return criteria[i] ? 1 : -1;
|
|
||||||
} else {
|
|
||||||
return criteria[i] ? -1 : 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int sortAll((Map, V) a, (Map, V) b) {
|
|
||||||
int i = 0;
|
|
||||||
int result = 0;
|
|
||||||
while (i < preference.length) {
|
|
||||||
result = compare(i, a, b);
|
|
||||||
if (result != 0) break;
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
return sorted((a, b) => sortAll(a, b));
|
|
||||||
}
|
|
||||||
}
|
|
@ -7,7 +7,7 @@ extension UnescapeHtml on String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension NullableUnescapeHtml on String? {
|
extension NullableUnescapeHtml on String? {
|
||||||
String? unescapeHtml() => this == null ? null : htmlEscape.convert(this!);
|
String? unescapeHtml() => this?.unescapeHtml();
|
||||||
}
|
}
|
||||||
|
|
||||||
extension StringExtension on String {
|
extension StringExtension on String {
|
||||||
|
@ -2,8 +2,9 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotube/hooks/configurators/use_window_listener.dart';
|
import 'package:spotube/hooks/configurators/use_window_listener.dart';
|
||||||
|
import 'package:spotube/models/database/database.dart';
|
||||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||||
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
|
|
||||||
import 'package:local_notifier/local_notifier.dart';
|
import 'package:local_notifier/local_notifier.dart';
|
||||||
import 'package:spotube/utils/platform.dart';
|
import 'package:spotube/utils/platform.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
@ -1,28 +1,28 @@
|
|||||||
import 'package:catcher_2/catcher_2.dart';
|
import 'package:spotube/services/logger/logger.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/provider/authentication_provider.dart';
|
import 'package:spotube/provider/authentication/authentication.dart';
|
||||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
import 'package:spotube/provider/audio_player/audio_player.dart';
|
||||||
import 'package:spotube/provider/spotify_provider.dart';
|
import 'package:spotube/provider/spotify_provider.dart';
|
||||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||||
|
|
||||||
void useEndlessPlayback(WidgetRef ref) {
|
void useEndlessPlayback(WidgetRef ref) {
|
||||||
final auth = ref.watch(authenticationProvider);
|
final auth = ref.watch(authenticationProvider);
|
||||||
final playback = ref.watch(proxyPlaylistProvider.notifier);
|
final playback = ref.watch(audioPlayerProvider.notifier);
|
||||||
final playlist = ref.watch(proxyPlaylistProvider);
|
final playlist = ref.watch(audioPlayerProvider.select((s) => s.playlist));
|
||||||
final spotify = ref.watch(spotifyProvider);
|
final spotify = ref.watch(spotifyProvider);
|
||||||
final endlessPlayback =
|
final endlessPlayback =
|
||||||
ref.watch(userPreferencesProvider.select((s) => s.endlessPlayback));
|
ref.watch(userPreferencesProvider.select((s) => s.endlessPlayback));
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() {
|
() {
|
||||||
if (!endlessPlayback || auth == null) return null;
|
if (!endlessPlayback || auth.asData?.value == null) return null;
|
||||||
|
|
||||||
void listener(int index) async {
|
void listener(int index) async {
|
||||||
try {
|
try {
|
||||||
final playlist = ref.read(proxyPlaylistProvider);
|
final playlist = ref.read(audioPlayerProvider);
|
||||||
if (index != playlist.tracks.length - 1) return;
|
if (index != playlist.tracks.length - 1) return;
|
||||||
|
|
||||||
final track = playlist.tracks.last;
|
final track = playlist.tracks.last;
|
||||||
@ -56,22 +56,22 @@ void useEndlessPlayback(WidgetRef ref) {
|
|||||||
await playback.addTracks(
|
await playback.addTracks(
|
||||||
tracks.toList()
|
tracks.toList()
|
||||||
..removeWhere((e) {
|
..removeWhere((e) {
|
||||||
final playlist = ref.read(proxyPlaylistProvider);
|
final playlist = ref.read(audioPlayerProvider);
|
||||||
final isDuplicate = playlist.tracks.any((t) => t.id == e.id);
|
final isDuplicate = playlist.tracks.any((t) => t.id == e.id);
|
||||||
return e.id == track.id || isDuplicate;
|
return e.id == track.id || isDuplicate;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
Catcher2.reportCheckedError(e, stack);
|
AppLogger.reportError(e, stack);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sometimes user can change settings for which the currentIndexChanged
|
// Sometimes user can change settings for which the currentIndexChanged
|
||||||
// might not be called. So we need to check if the current track is the
|
// might not be called. So we need to check if the current track is the
|
||||||
// last track and if it is then we need to call the listener manually.
|
// last track and if it is then we need to call the listener manually.
|
||||||
if (playlist.active == playlist.tracks.length - 1 &&
|
if (playlist.index == playlist.medias.length - 1 &&
|
||||||
audioPlayer.isPlaying) {
|
audioPlayer.isPlaying) {
|
||||||
listener(playlist.active!);
|
listener(playlist.index);
|
||||||
}
|
}
|
||||||
|
|
||||||
final subscription =
|
final subscription =
|
||||||
@ -82,7 +82,7 @@ void useEndlessPlayback(WidgetRef ref) {
|
|||||||
[
|
[
|
||||||
spotify,
|
spotify,
|
||||||
playback,
|
playback,
|
||||||
playlist.tracks,
|
playlist.medias,
|
||||||
endlessPlayback,
|
endlessPlayback,
|
||||||
auth,
|
auth,
|
||||||
],
|
],
|
||||||
|
21
lib/hooks/configurators/use_fix_window_stretching.dart
Normal file
21
lib/hooks/configurators/use_fix_window_stretching.dart
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:spotube/utils/platform.dart';
|
||||||
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
|
void useFixWindowStretching() {
|
||||||
|
useEffect(() {
|
||||||
|
if (!kIsWindows) return;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((Duration timeStamp) async {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100), () {
|
||||||
|
windowManager.getSize().then((Size value) {
|
||||||
|
windowManager.setSize(
|
||||||
|
Size(value.width + 1, value.height + 1),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, []);
|
||||||
|
}
|
@ -12,25 +12,35 @@ void useGetStoragePermissions(WidgetRef ref) {
|
|||||||
|
|
||||||
useAsyncEffect(
|
useAsyncEffect(
|
||||||
() async {
|
() async {
|
||||||
if (!kIsMobile) return;
|
if (kIsAndroid) {
|
||||||
|
final androidInfo = await DeviceInfoPlugin().androidInfo;
|
||||||
|
|
||||||
final androidInfo = await DeviceInfoPlugin().androidInfo;
|
final hasNoStoragePerm = androidInfo.version.sdkInt < 33 &&
|
||||||
|
!await Permission.storage.isGranted &&
|
||||||
|
!await Permission.storage.isLimited;
|
||||||
|
|
||||||
final hasNoStoragePerm = androidInfo.version.sdkInt < 33 &&
|
final hasNoAudioPerm = androidInfo.version.sdkInt >= 33 &&
|
||||||
!await Permission.storage.isGranted &&
|
!await Permission.audio.isGranted &&
|
||||||
!await Permission.storage.isLimited;
|
!await Permission.audio.isLimited;
|
||||||
|
|
||||||
final hasNoAudioPerm = androidInfo.version.sdkInt >= 33 &&
|
if (hasNoStoragePerm) {
|
||||||
!await Permission.audio.isGranted &&
|
await Permission.storage.request();
|
||||||
!await Permission.audio.isLimited;
|
if (context.mounted) ref.invalidate(localTracksProvider);
|
||||||
|
}
|
||||||
if (hasNoStoragePerm) {
|
if (hasNoAudioPerm) {
|
||||||
await Permission.storage.request();
|
await Permission.audio.request();
|
||||||
if (context.mounted) ref.invalidate(localTracksProvider);
|
if (context.mounted) ref.invalidate(localTracksProvider);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (hasNoAudioPerm) {
|
|
||||||
await Permission.audio.request();
|
if (kIsIOS) {
|
||||||
if (context.mounted) ref.invalidate(localTracksProvider);
|
final hasStoragePerm = await Permission.storage.isGranted ||
|
||||||
|
await Permission.storage.isLimited;
|
||||||
|
|
||||||
|
if (!hasStoragePerm) {
|
||||||
|
await Permission.storage.request();
|
||||||
|
if (context.mounted) ref.invalidate(localTracksProvider);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
|
@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:palette_generator/palette_generator.dart';
|
import 'package:palette_generator/palette_generator.dart';
|
||||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
import 'package:spotube/components/image/universal_image.dart';
|
||||||
|
|
||||||
final _paletteColorState = StateProvider<PaletteColor>(
|
final _paletteColorState = StateProvider<PaletteColor>(
|
||||||
(ref) {
|
(ref) {
|
||||||
|
@ -325,5 +325,64 @@
|
|||||||
"add_library_location": "أضف إلى المكتبة",
|
"add_library_location": "أضف إلى المكتبة",
|
||||||
"remove_library_location": "إزالة من المكتبة",
|
"remove_library_location": "إزالة من المكتبة",
|
||||||
"local_tab": "محلي",
|
"local_tab": "محلي",
|
||||||
"stats": "إحصائيات"
|
"stats": "إحصائيات",
|
||||||
|
"and_n_more": "و {count} أكثر",
|
||||||
|
"recently_played": "تم تشغيله مؤخرًا",
|
||||||
|
"browse_more": "تصفح المزيد",
|
||||||
|
"no_title": "بدون عنوان",
|
||||||
|
"not_playing": "غير مشغل",
|
||||||
|
"epic_failure": "فشل كبير!",
|
||||||
|
"added_num_tracks_to_queue": "تمت إضافة {tracks_length} مسارات إلى قائمة الانتظار",
|
||||||
|
"spotube_has_an_update": "يوجد تحديث لسبوتيوب",
|
||||||
|
"download_now": "تحميل الآن",
|
||||||
|
"nightly_version": "تم إصدار سبوتيوب الليلي {nightlyBuildNum}",
|
||||||
|
"release_version": "تم إصدار سبوتيوب v{version}",
|
||||||
|
"read_the_latest": "اقرأ الأحدث",
|
||||||
|
"release_notes": "ملاحظات الإصدار",
|
||||||
|
"pick_color_scheme": "اختر نظام الألوان",
|
||||||
|
"save": "حفظ",
|
||||||
|
"choose_the_device": "اختر الجهاز:",
|
||||||
|
"multiple_device_connected": "تم توصيل أجهزة متعددة.\nاختر الجهاز الذي تريد إجراء هذه العملية عليه",
|
||||||
|
"nothing_found": "لم يتم العثور على شيء",
|
||||||
|
"the_box_is_empty": "الصندوق فارغ",
|
||||||
|
"top_artists": "أفضل الفنانين",
|
||||||
|
"top_albums": "أفضل الألبومات",
|
||||||
|
"this_week": "هذا الأسبوع",
|
||||||
|
"this_month": "هذا الشهر",
|
||||||
|
"last_6_months": "آخر 6 أشهر",
|
||||||
|
"this_year": "هذا العام",
|
||||||
|
"last_2_years": "آخر سنتين",
|
||||||
|
"all_time": "كل الوقت",
|
||||||
|
"powered_by_provider": "مدعوم من {providerName}",
|
||||||
|
"email": "البريد الإلكتروني",
|
||||||
|
"profile_followers": "المتابعين",
|
||||||
|
"birthday": "عيد الميلاد",
|
||||||
|
"subscription": "اشتراك",
|
||||||
|
"not_born": "لم يولد",
|
||||||
|
"hacker": "هاكر",
|
||||||
|
"profile": "الملف الشخصي",
|
||||||
|
"no_name": "بدون اسم",
|
||||||
|
"edit": "تعديل",
|
||||||
|
"user_profile": "ملف المستخدم",
|
||||||
|
"count_plays": "{count} تشغيلات",
|
||||||
|
"streaming_fees_hypothetical": "رسوم البث (افتراضية)",
|
||||||
|
"minutes_listened": "الدقائق المستمعة",
|
||||||
|
"streamed_songs": "الأغاني المذاعة",
|
||||||
|
"count_streams": "{count} بث",
|
||||||
|
"owned_by_you": "مملوك لك",
|
||||||
|
"copied_shareurl_to_clipboard": "تم نسخ {shareUrl} إلى الحافظة",
|
||||||
|
"spotify_hipotetical_calculation": "*هذا محسوب بناءً على الدفع لكل بث من سبوتيفاي\nبقيمة 0.003 إلى 0.005 دولار. هذا حساب افتراضي\nلإعطاء المستخدم فكرة عن المبلغ الذي\nكان سيدفعه للفنانين إذا كانوا قد استمعوا\nإلى أغنيتهم على سبوتيفاي.",
|
||||||
|
"count_mins": "{minutes} دقيقة",
|
||||||
|
"summary_minutes": "الدقائق",
|
||||||
|
"summary_listened_to_music": "استمعت إلى الموسيقى",
|
||||||
|
"summary_songs": "أغاني",
|
||||||
|
"summary_streamed_overall": "بث بشكل عام",
|
||||||
|
"summary_owed_to_artists": "مدين للفنانين\nهذا الشهر",
|
||||||
|
"summary_artists": "الفنانين",
|
||||||
|
"summary_music_reached_you": "وصلت إليك الموسيقى",
|
||||||
|
"summary_full_albums": "ألبومات كاملة",
|
||||||
|
"summary_got_your_love": "حصلت على حبك",
|
||||||
|
"summary_playlists": "قوائم التشغيل",
|
||||||
|
"summary_were_on_repeat": "كانت على التكرار",
|
||||||
|
"total_money": "المجموع {money}"
|
||||||
}
|
}
|
@ -325,5 +325,64 @@
|
|||||||
"add_library_location": "লাইব্রেরিতে যোগ করুন",
|
"add_library_location": "লাইব্রেরিতে যোগ করুন",
|
||||||
"remove_library_location": "লাইব্রেরি থেকে সরান",
|
"remove_library_location": "লাইব্রেরি থেকে সরান",
|
||||||
"local_tab": "স্থানীয়",
|
"local_tab": "স্থানীয়",
|
||||||
"stats": "পরিসংখ্যান"
|
"stats": "পরিসংখ্যান",
|
||||||
|
"and_n_more": "এবং {count} আরও",
|
||||||
|
"recently_played": "সম্প্রতি বাজানো",
|
||||||
|
"browse_more": "আরও ব্রাউজ করুন",
|
||||||
|
"no_title": "কোনো শিরোনাম নেই",
|
||||||
|
"not_playing": "চালানো হচ্ছে না",
|
||||||
|
"epic_failure": "বিরাট ব্যর্থতা!",
|
||||||
|
"added_num_tracks_to_queue": "{tracks_length} ট্র্যাক সারিতে যোগ করা হয়েছে",
|
||||||
|
"spotube_has_an_update": "স্পটিউবে একটি আপডেট আছে",
|
||||||
|
"download_now": "এখনই ডাউনলোড করুন",
|
||||||
|
"nightly_version": "স্পটিউব নাইটলি {nightlyBuildNum} প্রকাশিত হয়েছে",
|
||||||
|
"release_version": "স্পটিউব v{version} প্রকাশিত হয়েছে",
|
||||||
|
"read_the_latest": "সর্বশেষ পড়ুন",
|
||||||
|
"release_notes": "রিলিজ নোট",
|
||||||
|
"pick_color_scheme": "রঙের থিম নির্বাচন করুন",
|
||||||
|
"save": "সংরক্ষণ করুন",
|
||||||
|
"choose_the_device": "ডিভাইস নির্বাচন করুন:",
|
||||||
|
"multiple_device_connected": "একাধিক ডিভাইস সংযুক্ত রয়েছে।\nযে ডিভাইসে আপনি এই ক্রিয়াটি চালাতে চান সেটি নির্বাচন করুন",
|
||||||
|
"nothing_found": "কিছুই পাওয়া যায়নি",
|
||||||
|
"the_box_is_empty": "বাক্সটি খালি",
|
||||||
|
"top_artists": "শীর্ষ শিল্পী",
|
||||||
|
"top_albums": "শীর্ষ অ্যালবাম",
|
||||||
|
"this_week": "এই সপ্তাহ",
|
||||||
|
"this_month": "এই মাস",
|
||||||
|
"last_6_months": "গত ৬ মাস",
|
||||||
|
"this_year": "এই বছর",
|
||||||
|
"last_2_years": "গত ২ বছর",
|
||||||
|
"all_time": "সব সময়",
|
||||||
|
"powered_by_provider": "{providerName} দ্বারা চালিত",
|
||||||
|
"email": "ইমেইল",
|
||||||
|
"profile_followers": "অনুসারী",
|
||||||
|
"birthday": "জন্মদিন",
|
||||||
|
"subscription": "সাবস্ক্রিপশন",
|
||||||
|
"not_born": "জন্মগ্রহণ করেনি",
|
||||||
|
"hacker": "হ্যাকার",
|
||||||
|
"profile": "প্রোফাইল",
|
||||||
|
"no_name": "কোন নাম নেই",
|
||||||
|
"edit": "সম্পাদনা করুন",
|
||||||
|
"user_profile": "ব্যবহারকারীর প্রোফাইল",
|
||||||
|
"count_plays": "{count} বার প্লে হয়েছে",
|
||||||
|
"streaming_fees_hypothetical": "স্ট্রিমিং ফি (ধারণাগত)",
|
||||||
|
"minutes_listened": "শুনেছেন মিনিট",
|
||||||
|
"streamed_songs": "স্ট্রিম করা গান",
|
||||||
|
"count_streams": "{count} বার স্ট্রিম",
|
||||||
|
"owned_by_you": "আপনার মালিকানাধীন",
|
||||||
|
"copied_shareurl_to_clipboard": "{shareUrl} ক্লিপবোর্ডে কপি করা হয়েছে",
|
||||||
|
"spotify_hipotetical_calculation": "*এটি স্পোটিফাইয়ের প্রতি স্ট্রিম\n$0.003 থেকে $0.005 পেআউটের ভিত্তিতে গণনা করা হয়েছে। এটি একটি ধারণাগত\nগণনা ব্যবহারকারীদেরকে জানাতে দেয় যে কত টাকা\nতারা শিল্পীদের দিতো যদি তারা স্পোটিফাইতে\nতাদের গান শুনতেন।",
|
||||||
|
"count_mins": "{minutes} মিনিট",
|
||||||
|
"summary_minutes": "মিনিট",
|
||||||
|
"summary_listened_to_music": "সঙ্গীত শুনেছেন",
|
||||||
|
"summary_songs": "গান",
|
||||||
|
"summary_streamed_overall": "মোট স্ট্রিম",
|
||||||
|
"summary_owed_to_artists": "এই মাসে\nশিল্পীদেরকে ঋণী",
|
||||||
|
"summary_artists": "শিল্পীর",
|
||||||
|
"summary_music_reached_you": "আপনার কাছে পৌঁছেছে সঙ্গীত",
|
||||||
|
"summary_full_albums": "সম্পূর্ণ অ্যালবাম",
|
||||||
|
"summary_got_your_love": "আপনার ভালোবাসা পেয়েছে",
|
||||||
|
"summary_playlists": "প্লেলিস্ট",
|
||||||
|
"summary_were_on_repeat": "পুনরাবৃত্তিতে ছিল",
|
||||||
|
"total_money": "মোট {money}"
|
||||||
}
|
}
|
@ -325,5 +325,64 @@
|
|||||||
"add_library_location": "Afegeix a la biblioteca",
|
"add_library_location": "Afegeix a la biblioteca",
|
||||||
"remove_library_location": "Elimina de la biblioteca",
|
"remove_library_location": "Elimina de la biblioteca",
|
||||||
"local_tab": "Local",
|
"local_tab": "Local",
|
||||||
"stats": "Estadístiques"
|
"stats": "Estadístiques",
|
||||||
|
"and_n_more": "i {count} més",
|
||||||
|
"recently_played": "Reproduït recentment",
|
||||||
|
"browse_more": "Navega més",
|
||||||
|
"no_title": "Sense títol",
|
||||||
|
"not_playing": "No s'està reproduint",
|
||||||
|
"epic_failure": "Fracàs èpic!",
|
||||||
|
"added_num_tracks_to_queue": "Afegit {tracks_length} pistes a la cua",
|
||||||
|
"spotube_has_an_update": "Spotube té una actualització",
|
||||||
|
"download_now": "Descarregar ara",
|
||||||
|
"nightly_version": "Spotube Nightly {nightlyBuildNum} ha estat publicat",
|
||||||
|
"release_version": "Spotube v{version} ha estat publicat",
|
||||||
|
"read_the_latest": "Llegeix el més recent",
|
||||||
|
"release_notes": "notes de la versió",
|
||||||
|
"pick_color_scheme": "Tria l'esquema de colors",
|
||||||
|
"save": "Desar",
|
||||||
|
"choose_the_device": "Tria el dispositiu:",
|
||||||
|
"multiple_device_connected": "Hi ha diversos dispositius connectats.\nTria el dispositiu on vols realitzar aquesta acció",
|
||||||
|
"nothing_found": "No s'ha trobat res",
|
||||||
|
"the_box_is_empty": "La caixa està buida",
|
||||||
|
"top_artists": "Millors artistes",
|
||||||
|
"top_albums": "Millors àlbums",
|
||||||
|
"this_week": "Aquesta setmana",
|
||||||
|
"this_month": "Aquest mes",
|
||||||
|
"last_6_months": "Últims 6 mesos",
|
||||||
|
"this_year": "Aquest any",
|
||||||
|
"last_2_years": "Últims 2 anys",
|
||||||
|
"all_time": "Tots els temps",
|
||||||
|
"powered_by_provider": "Funciona amb {providerName}",
|
||||||
|
"email": "Correu electrònic",
|
||||||
|
"profile_followers": "Seguidors",
|
||||||
|
"birthday": "Aniversari",
|
||||||
|
"subscription": "Subscripció",
|
||||||
|
"not_born": "No ha nascut",
|
||||||
|
"hacker": "Hacker",
|
||||||
|
"profile": "Perfil",
|
||||||
|
"no_name": "Sense nom",
|
||||||
|
"edit": "Editar",
|
||||||
|
"user_profile": "Perfil d'usuari",
|
||||||
|
"count_plays": "{count} reproduccions",
|
||||||
|
"streaming_fees_hypothetical": "Comissions de streaming (hipotètic)",
|
||||||
|
"minutes_listened": "minuts escoltats",
|
||||||
|
"streamed_songs": "cançons reproduïdes",
|
||||||
|
"count_streams": "{count} reproduccions",
|
||||||
|
"owned_by_you": "De la teva propietat",
|
||||||
|
"copied_shareurl_to_clipboard": "S'ha copiat {shareUrl} al porta-retalls",
|
||||||
|
"spotify_hipotetical_calculation": "*Això es calcula basant-se en els\npagaments per reproducció de Spotify de $0.003 a $0.005.\nAquest és un càlcul hipotètic per\ndonar als usuaris una idea de quant\nhaurien pagat als artistes si haguessin escoltat\nla seva cançó a Spotify.",
|
||||||
|
"count_mins": "{minutes} minuts",
|
||||||
|
"summary_minutes": "minuts",
|
||||||
|
"summary_listened_to_music": "has escoltat música",
|
||||||
|
"summary_songs": "cançons",
|
||||||
|
"summary_streamed_overall": "reproduït en general",
|
||||||
|
"summary_owed_to_artists": "degut als artistes\nAquest mes",
|
||||||
|
"summary_artists": "artistes",
|
||||||
|
"summary_music_reached_you": "La música t'ha arribat",
|
||||||
|
"summary_full_albums": "Àlbums complets",
|
||||||
|
"summary_got_your_love": "ha aconseguit el teu amor",
|
||||||
|
"summary_playlists": "llistes de reproducció",
|
||||||
|
"summary_were_on_repeat": "estaven en repetició",
|
||||||
|
"total_money": "total {money}"
|
||||||
}
|
}
|
@ -325,5 +325,64 @@
|
|||||||
"add_library_location": "Přidat do knihovny",
|
"add_library_location": "Přidat do knihovny",
|
||||||
"remove_library_location": "Odebrat z knihovny",
|
"remove_library_location": "Odebrat z knihovny",
|
||||||
"local_tab": "Místní",
|
"local_tab": "Místní",
|
||||||
"stats": "Statistiky"
|
"stats": "Statistiky",
|
||||||
|
"and_n_more": "a dalších {count}",
|
||||||
|
"recently_played": "Nedávno přehráno",
|
||||||
|
"browse_more": "Procházet více",
|
||||||
|
"no_title": "Bez názvu",
|
||||||
|
"not_playing": "Nepřehrává se",
|
||||||
|
"epic_failure": "Epické selhání!",
|
||||||
|
"added_num_tracks_to_queue": "Přidáno {tracks_length} skladeb do fronty",
|
||||||
|
"spotube_has_an_update": "Spotube má aktualizaci",
|
||||||
|
"download_now": "Stáhnout nyní",
|
||||||
|
"nightly_version": "Byla vydána noční verze Spotube {nightlyBuildNum}",
|
||||||
|
"release_version": "Byla vydána verze Spotube v{version}",
|
||||||
|
"read_the_latest": "Přečtěte si nejnovější ",
|
||||||
|
"release_notes": "poznámky k vydání",
|
||||||
|
"pick_color_scheme": "Vyberte barevné schéma",
|
||||||
|
"save": "Uložit",
|
||||||
|
"choose_the_device": "Vyberte zařízení:",
|
||||||
|
"multiple_device_connected": "Je připojeno více zařízení.\nVyberte zařízení, na kterém chcete provést tuto akci",
|
||||||
|
"nothing_found": "Nic nenalezeno",
|
||||||
|
"the_box_is_empty": "Krabice je prázdná",
|
||||||
|
"top_artists": "Nejlepší umělci",
|
||||||
|
"top_albums": "Nejlepší alba",
|
||||||
|
"this_week": "Tento týden",
|
||||||
|
"this_month": "Tento měsíc",
|
||||||
|
"last_6_months": "Posledních 6 měsíců",
|
||||||
|
"this_year": "Tento rok",
|
||||||
|
"last_2_years": "Poslední 2 roky",
|
||||||
|
"all_time": "Všechny časy",
|
||||||
|
"powered_by_provider": "Pohání {providerName}",
|
||||||
|
"email": "Email",
|
||||||
|
"profile_followers": "Sledující",
|
||||||
|
"birthday": "Narozeniny",
|
||||||
|
"subscription": "Předplatné",
|
||||||
|
"not_born": "Nenarozen",
|
||||||
|
"hacker": "Hacker",
|
||||||
|
"profile": "Profil",
|
||||||
|
"no_name": "Bez jména",
|
||||||
|
"edit": "Upravit",
|
||||||
|
"user_profile": "Uživatelský profil",
|
||||||
|
"count_plays": "{count} přehrání",
|
||||||
|
"streaming_fees_hypothetical": "Poplatky za streamování (hypotetické)",
|
||||||
|
"minutes_listened": "Poslouchané minuty",
|
||||||
|
"streamed_songs": "Streamované skladby",
|
||||||
|
"count_streams": "{count} streamů",
|
||||||
|
"owned_by_you": "Vlastněno vámi",
|
||||||
|
"copied_shareurl_to_clipboard": "Zkopírováno {shareUrl} do schránky",
|
||||||
|
"spotify_hipotetical_calculation": "*Toto je vypočítáno na základě výplaty\nza stream Spotify od $0.003 do $0.005.\nToto je hypotetický výpočet,\nabyste měli představu o tom, kolik\nbyste zaplatili umělcům,\npokud byste poslouchali jejich píseň na Spotify.",
|
||||||
|
"count_mins": "{minutes} minut",
|
||||||
|
"summary_minutes": "minuty",
|
||||||
|
"summary_listened_to_music": "Poslouchal(a) hudbu",
|
||||||
|
"summary_songs": "písně",
|
||||||
|
"summary_streamed_overall": "Streamováno celkově",
|
||||||
|
"summary_owed_to_artists": "Dluženo umělcům\nTento měsíc",
|
||||||
|
"summary_artists": "umělců",
|
||||||
|
"summary_music_reached_you": "Hudba vás oslovila",
|
||||||
|
"summary_full_albums": "plná alba",
|
||||||
|
"summary_got_your_love": "Získal vaši lásku",
|
||||||
|
"summary_playlists": "playlisty",
|
||||||
|
"summary_were_on_repeat": "Byly na opakování",
|
||||||
|
"total_money": "Celkem {money}"
|
||||||
}
|
}
|
@ -325,5 +325,64 @@
|
|||||||
"add_library_location": "Zur Bibliothek hinzufügen",
|
"add_library_location": "Zur Bibliothek hinzufügen",
|
||||||
"remove_library_location": "Aus der Bibliothek entfernen",
|
"remove_library_location": "Aus der Bibliothek entfernen",
|
||||||
"local_tab": "Lokal",
|
"local_tab": "Lokal",
|
||||||
"stats": "Statistiken"
|
"stats": "Statistiken",
|
||||||
|
"and_n_more": "und {count} mehr",
|
||||||
|
"recently_played": "Zuletzt gespielt",
|
||||||
|
"browse_more": "Mehr durchsuchen",
|
||||||
|
"no_title": "Kein Titel",
|
||||||
|
"not_playing": "Wird nicht abgespielt",
|
||||||
|
"epic_failure": "Episches Versagen!",
|
||||||
|
"added_num_tracks_to_queue": "{tracks_length} Titel zur Warteschlange hinzugefügt",
|
||||||
|
"spotube_has_an_update": "Spotube hat ein Update",
|
||||||
|
"download_now": "Jetzt herunterladen",
|
||||||
|
"nightly_version": "Spotube Nightly {nightlyBuildNum} wurde veröffentlicht",
|
||||||
|
"release_version": "Spotube v{version} wurde veröffentlicht",
|
||||||
|
"read_the_latest": "Lese die neuesten ",
|
||||||
|
"release_notes": "Versionshinweise",
|
||||||
|
"pick_color_scheme": "Farbschema wählen",
|
||||||
|
"save": "Speichern",
|
||||||
|
"choose_the_device": "Wähle das Gerät:",
|
||||||
|
"multiple_device_connected": "Es sind mehrere Geräte verbunden.\nWähle das Gerät, auf dem diese Aktion ausgeführt werden soll",
|
||||||
|
"nothing_found": "Nichts gefunden",
|
||||||
|
"the_box_is_empty": "Die Box ist leer",
|
||||||
|
"top_artists": "Top-Künstler",
|
||||||
|
"top_albums": "Top-Alben",
|
||||||
|
"this_week": "Diese Woche",
|
||||||
|
"this_month": "Diesen Monat",
|
||||||
|
"last_6_months": "Letzte 6 Monate",
|
||||||
|
"this_year": "Dieses Jahr",
|
||||||
|
"last_2_years": "Letzte 2 Jahre",
|
||||||
|
"all_time": "Alle Zeiten",
|
||||||
|
"powered_by_provider": "Bereitgestellt von {providerName}",
|
||||||
|
"email": "Email",
|
||||||
|
"profile_followers": "Follower",
|
||||||
|
"birthday": "Geburtstag",
|
||||||
|
"subscription": "Abonnement",
|
||||||
|
"not_born": "Nicht geboren",
|
||||||
|
"hacker": "Hacker",
|
||||||
|
"profile": "Profil",
|
||||||
|
"no_name": "Kein Name",
|
||||||
|
"edit": "Bearbeiten",
|
||||||
|
"user_profile": "Benutzerprofil",
|
||||||
|
"count_plays": "{count} Wiedergaben",
|
||||||
|
"streaming_fees_hypothetical": "Streaming-Gebühren (hypothetisch)",
|
||||||
|
"minutes_listened": "Gehörte Minuten",
|
||||||
|
"streamed_songs": "Gestreamte Lieder",
|
||||||
|
"count_streams": "{count} Streams",
|
||||||
|
"owned_by_you": "In Ihrem Besitz",
|
||||||
|
"copied_shareurl_to_clipboard": "{shareUrl} in die Zwischenablage kopiert",
|
||||||
|
"spotify_hipotetical_calculation": "*Dies ist basierend auf Spotifys\npro Stream Auszahlung von $0,003 bis $0,005\nberechnet. Dies ist eine hypothetische Berechnung,\num dem Benutzer Einblick zu geben,\nwieviel sie den Künstlern gezahlt hätten,\nwenn sie ihren Song auf Spotify gehört hätten.",
|
||||||
|
"count_mins": "{minutes} Minuten",
|
||||||
|
"summary_minutes": "Minuten",
|
||||||
|
"summary_listened_to_music": "Hat Musik gehört",
|
||||||
|
"summary_songs": "Lieder",
|
||||||
|
"summary_streamed_overall": "Insgesamt gestreamt",
|
||||||
|
"summary_owed_to_artists": "Den Künstlern geschuldet\nDiesen Monat",
|
||||||
|
"summary_artists": "Künstler",
|
||||||
|
"summary_music_reached_you": "Musik hat Sie erreicht",
|
||||||
|
"summary_full_albums": "volle Alben",
|
||||||
|
"summary_got_your_love": "Hat Ihre Liebe gewonnen",
|
||||||
|
"summary_playlists": "Wiedergabelisten",
|
||||||
|
"summary_were_on_repeat": "Wurden wiederholt",
|
||||||
|
"total_money": "Gesamt {money}"
|
||||||
}
|
}
|
@ -325,5 +325,64 @@
|
|||||||
"connect_client_alert": "You're being controlled by {client}",
|
"connect_client_alert": "You're being controlled by {client}",
|
||||||
"this_device": "This Device",
|
"this_device": "This Device",
|
||||||
"remote": "Remote",
|
"remote": "Remote",
|
||||||
"stats": "Stats"
|
"stats": "Stats",
|
||||||
|
"and_n_more": "and {count} more",
|
||||||
|
"recently_played": "Recently Played",
|
||||||
|
"browse_more": "Browse More",
|
||||||
|
"no_title": "No Title",
|
||||||
|
"not_playing": "Not playing",
|
||||||
|
"epic_failure": "Epic failure!",
|
||||||
|
"added_num_tracks_to_queue": "Added {tracks_length} tracks to queue",
|
||||||
|
"spotube_has_an_update": "Spotube has an update",
|
||||||
|
"download_now": "Download Now",
|
||||||
|
"nightly_version": "Spotube Nightly {nightlyBuildNum} has been released",
|
||||||
|
"release_version": "Spotube v{version} has been released",
|
||||||
|
"read_the_latest": "Read the latest ",
|
||||||
|
"release_notes": "release notes",
|
||||||
|
"pick_color_scheme": "Pick color scheme",
|
||||||
|
"save": "Save",
|
||||||
|
"choose_the_device": "Choose the device:",
|
||||||
|
"multiple_device_connected": "There are multiple device connected.\nChoose the device you want this action to take place",
|
||||||
|
"nothing_found": "Nothing found",
|
||||||
|
"the_box_is_empty": "The box is empty",
|
||||||
|
"top_artists": "Top Artists",
|
||||||
|
"top_albums": "Top Albums",
|
||||||
|
"this_week": "This week",
|
||||||
|
"this_month": "This month",
|
||||||
|
"last_6_months": "Last 6 months",
|
||||||
|
"this_year": "This year",
|
||||||
|
"last_2_years": "Last 2 years",
|
||||||
|
"all_time": "All time",
|
||||||
|
"powered_by_provider": "Powered by {providerName}",
|
||||||
|
"email": "Email",
|
||||||
|
"profile_followers": "Followers",
|
||||||
|
"birthday": "Birthday",
|
||||||
|
"subscription": "Subscription",
|
||||||
|
"not_born": "Not born",
|
||||||
|
"hacker": "Hacker",
|
||||||
|
"profile": "Profile",
|
||||||
|
"no_name": "No Name",
|
||||||
|
"edit": "Edit",
|
||||||
|
"user_profile": "User Profile",
|
||||||
|
"count_plays": "{count} plays",
|
||||||
|
"streaming_fees_hypothetical": "Streaming fees (hypothetical)",
|
||||||
|
"minutes_listened": "Minutes listened",
|
||||||
|
"streamed_songs": "Streamed songs",
|
||||||
|
"count_streams": "{count} streams",
|
||||||
|
"owned_by_you": "Owned by you",
|
||||||
|
"copied_shareurl_to_clipboard": "Copied {shareUrl} to clipboard",
|
||||||
|
"spotify_hipotetical_calculation": "*This is calculated based on Spotify's per stream\npayout of $0.003 to $0.005. This is a hypothetical\ncalculation to give user insight about how much they\nwould have paid to the artists if they were to listen\ntheir song in Spotify.",
|
||||||
|
"count_mins": "{minutes} mins",
|
||||||
|
"summary_minutes": "minutes",
|
||||||
|
"summary_listened_to_music": "Listened to music",
|
||||||
|
"summary_songs": "songs",
|
||||||
|
"summary_streamed_overall": "Streamed overall",
|
||||||
|
"summary_owed_to_artists": "Owed to artists\nthis month",
|
||||||
|
"summary_artists": "artist's",
|
||||||
|
"summary_music_reached_you": "Music reached you",
|
||||||
|
"summary_full_albums": "full albums",
|
||||||
|
"summary_got_your_love": "Got your love",
|
||||||
|
"summary_playlists": "playlists",
|
||||||
|
"summary_were_on_repeat": "Were on repeat",
|
||||||
|
"total_money": "Total {money}"
|
||||||
}
|
}
|
@ -325,5 +325,64 @@
|
|||||||
"add_library_location": "Añadir a la biblioteca",
|
"add_library_location": "Añadir a la biblioteca",
|
||||||
"remove_library_location": "Eliminar de la biblioteca",
|
"remove_library_location": "Eliminar de la biblioteca",
|
||||||
"local_tab": "Local",
|
"local_tab": "Local",
|
||||||
"stats": "Estadísticas"
|
"stats": "Estadísticas",
|
||||||
|
"and_n_more": "y {count} más",
|
||||||
|
"recently_played": "Recién reproducido",
|
||||||
|
"browse_more": "Explorar más",
|
||||||
|
"no_title": "Sin título",
|
||||||
|
"not_playing": "No reproduciendo",
|
||||||
|
"epic_failure": "¡Fallo épico!",
|
||||||
|
"added_num_tracks_to_queue": "Se añadieron {tracks_length} canciones a la cola",
|
||||||
|
"spotube_has_an_update": "Spotube tiene una actualización",
|
||||||
|
"download_now": "Descargar ahora",
|
||||||
|
"nightly_version": "Spotube Nightly {nightlyBuildNum} ha sido lanzado",
|
||||||
|
"release_version": "Spotube v{version} ha sido lanzado",
|
||||||
|
"read_the_latest": "Lee las últimas ",
|
||||||
|
"release_notes": "notas de la versión",
|
||||||
|
"pick_color_scheme": "Elige esquema de color",
|
||||||
|
"save": "Guardar",
|
||||||
|
"choose_the_device": "Elige el dispositivo:",
|
||||||
|
"multiple_device_connected": "Hay múltiples dispositivos conectados.\nElige el dispositivo en el que deseas realizar esta acción",
|
||||||
|
"nothing_found": "Nada encontrado",
|
||||||
|
"the_box_is_empty": "La caja está vacía",
|
||||||
|
"top_artists": "Artistas principales",
|
||||||
|
"top_albums": "Álbumes principales",
|
||||||
|
"this_week": "Esta semana",
|
||||||
|
"this_month": "Este mes",
|
||||||
|
"last_6_months": "Últimos 6 meses",
|
||||||
|
"this_year": "Este año",
|
||||||
|
"last_2_years": "Últimos 2 años",
|
||||||
|
"all_time": "Todos los tiempos",
|
||||||
|
"powered_by_provider": "Impulsado por {providerName}",
|
||||||
|
"email": "Correo electrónico",
|
||||||
|
"profile_followers": "Seguidores",
|
||||||
|
"birthday": "Cumpleaños",
|
||||||
|
"subscription": "Suscripción",
|
||||||
|
"not_born": "No nacido",
|
||||||
|
"hacker": "Hacker",
|
||||||
|
"profile": "Perfil",
|
||||||
|
"no_name": "Sin nombre",
|
||||||
|
"edit": "Editar",
|
||||||
|
"user_profile": "Perfil de usuario",
|
||||||
|
"count_plays": "{count} reproducciones",
|
||||||
|
"streaming_fees_hypothetical": "Tarifas de streaming (hipotéticas)",
|
||||||
|
"minutes_listened": "Minutos escuchados",
|
||||||
|
"streamed_songs": "Canciones reproducidas",
|
||||||
|
"count_streams": "{count} streams",
|
||||||
|
"owned_by_you": "En tu posesión",
|
||||||
|
"copied_shareurl_to_clipboard": "Copiado {shareUrl} al portapapeles",
|
||||||
|
"spotify_hipotetical_calculation": "*Esto se calcula en base al\npago por stream de Spotify de $0.003 a $0.005.\nEs un cálculo hipotético para dar\nuna idea de cuánto habría\npagado a los artistas si hubieras escuchado\nsu canción en Spotify.",
|
||||||
|
"count_mins": "{minutes} minutos",
|
||||||
|
"summary_minutes": "minutos",
|
||||||
|
"summary_listened_to_music": "Escuchó música",
|
||||||
|
"summary_songs": "canciones",
|
||||||
|
"summary_streamed_overall": "Transmitido en general",
|
||||||
|
"summary_owed_to_artists": "Debido a los artistas\nEste mes",
|
||||||
|
"summary_artists": "artistas",
|
||||||
|
"summary_music_reached_you": "La música te alcanzó",
|
||||||
|
"summary_full_albums": "álbumes completos",
|
||||||
|
"summary_got_your_love": "Obtuvo tu amor",
|
||||||
|
"summary_playlists": "listas de reproducción",
|
||||||
|
"summary_were_on_repeat": "Estaban en repetición",
|
||||||
|
"total_money": "Total {money}"
|
||||||
}
|
}
|
@ -107,6 +107,9 @@
|
|||||||
"always_on_top": "Beti ikusgai",
|
"always_on_top": "Beti ikusgai",
|
||||||
"exit_mini_player": "Irten mini erreproduzitzailetik",
|
"exit_mini_player": "Irten mini erreproduzitzailetik",
|
||||||
"download_location": "Deskargen kokapena",
|
"download_location": "Deskargen kokapena",
|
||||||
|
"local_library": "Liburutegi lokala",
|
||||||
|
"add_library_location": "Gehitu liburutegira",
|
||||||
|
"remove_library_location": "Kendu liburutegitik",
|
||||||
"account": "Kontua",
|
"account": "Kontua",
|
||||||
"login_with_spotify": "Hasi saioa zure Spotify kontuarekin",
|
"login_with_spotify": "Hasi saioa zure Spotify kontuarekin",
|
||||||
"connect_with_spotify": "Spotify-rekin konektatu",
|
"connect_with_spotify": "Spotify-rekin konektatu",
|
||||||
@ -118,8 +121,8 @@
|
|||||||
"market_place_region": "Dendaren herrialdea",
|
"market_place_region": "Dendaren herrialdea",
|
||||||
"recommendation_country": "Gomendio herrialdea",
|
"recommendation_country": "Gomendio herrialdea",
|
||||||
"appearance": "Itxura",
|
"appearance": "Itxura",
|
||||||
"layout_mode": "Diseinu modua",
|
"layout_mode": "Diseinua",
|
||||||
"override_layout_settings": "Responsive diseinu moduaren ezarpenak ezeztatu",
|
"override_layout_settings": "Responsive diseinuaren ezarpenak ezeztatu",
|
||||||
"adaptive": "Moldagarria",
|
"adaptive": "Moldagarria",
|
||||||
"compact": "Trinkoa",
|
"compact": "Trinkoa",
|
||||||
"extended": "Hedatua",
|
"extended": "Hedatua",
|
||||||
@ -287,7 +290,7 @@
|
|||||||
"genres": "Generoak",
|
"genres": "Generoak",
|
||||||
"explore_genres": "Esploratu generoak",
|
"explore_genres": "Esploratu generoak",
|
||||||
"friends": "Lagunak",
|
"friends": "Lagunak",
|
||||||
"no_lyrics_available": "Sentitzen dut, ezin dira kanta honen hitzak aurkitu",
|
"no_lyrics_available": "Sentitzen dugu, ezin dira kanta honen hitzak aurkitu",
|
||||||
"start_a_radio": "Hasi Irrati bat",
|
"start_a_radio": "Hasi Irrati bat",
|
||||||
"how_to_start_radio": "Nola hasi nahi duzu irratia?",
|
"how_to_start_radio": "Nola hasi nahi duzu irratia?",
|
||||||
"replace_queue_question": "Uneko zerrenda ordezkatu nahi duzu edo bertan gehitu?",
|
"replace_queue_question": "Uneko zerrenda ordezkatu nahi duzu edo bertan gehitu?",
|
||||||
@ -295,6 +298,7 @@
|
|||||||
"delete_playlist": "Ezabatu zerrenda",
|
"delete_playlist": "Ezabatu zerrenda",
|
||||||
"delete_playlist_confirmation": "Ziur zaude zerrenda ezabatu nahi duzula?",
|
"delete_playlist_confirmation": "Ziur zaude zerrenda ezabatu nahi duzula?",
|
||||||
"local_tracks": "Kanta lokalak",
|
"local_tracks": "Kanta lokalak",
|
||||||
|
"local_tab": "Lokalean",
|
||||||
"song_link": "Kantaren lotura",
|
"song_link": "Kantaren lotura",
|
||||||
"skip_this_nonsense": "Utzi txorakeria hau",
|
"skip_this_nonsense": "Utzi txorakeria hau",
|
||||||
"freedom_of_music": "“Musika Askatasuna”",
|
"freedom_of_music": "“Musika Askatasuna”",
|
||||||
@ -321,9 +325,64 @@
|
|||||||
"connect_client_alert": "{client} gailuak kontrolatzen zaitu",
|
"connect_client_alert": "{client} gailuak kontrolatzen zaitu",
|
||||||
"this_device": "Gailu hau",
|
"this_device": "Gailu hau",
|
||||||
"remote": "Urrunekoa",
|
"remote": "Urrunekoa",
|
||||||
"local_library": "Liburutegi lokala",
|
"stats": "Estatistikak",
|
||||||
"add_library_location": "Gehitu liburutegira",
|
"and_n_more": "eta {count} gehiago",
|
||||||
"remove_library_location": "Kendu liburutegitik",
|
"recently_played": "Berriki entzunak",
|
||||||
"local_tab": "Tokiko",
|
"browse_more": "Gehiago Bilatu",
|
||||||
"stats": "Estatistikak"
|
"no_title": "Titulurik ez",
|
||||||
|
"not_playing": "Erreprodukziorik ez",
|
||||||
|
"epic_failure": "Sekulako errorea!",
|
||||||
|
"added_num_tracks_to_queue": "{tracks_length} kanta gehitu dira zerrendara",
|
||||||
|
"spotube_has_an_update": "Spotube-ren eguneraketa bat dago",
|
||||||
|
"download_now": "Orain deskargatu",
|
||||||
|
"nightly_version": "Spotube {nightlyBuildNum} Nightly-a argitaratu da",
|
||||||
|
"release_version": "Spotube v{version} argitaratu da",
|
||||||
|
"read_the_latest": "Irakurri azken ",
|
||||||
|
"release_notes": "argitatratze oharrak",
|
||||||
|
"pick_color_scheme": "Aukeratu kolore eskema",
|
||||||
|
"save": "Gorde",
|
||||||
|
"choose_the_device": "Aukeratu gailua:",
|
||||||
|
"multiple_device_connected": "Hainbat gailu daude konektatuta.\nAukeratu zein gailutan aplikatu nahi duzun ekintza hau",
|
||||||
|
"nothing_found": "Ezer ez da aurkitu",
|
||||||
|
"the_box_is_empty": "Kaxa hutsik dago",
|
||||||
|
"top_artists": "Top Artistak",
|
||||||
|
"top_albums": "Top Albumak",
|
||||||
|
"this_week": "Aste honetan",
|
||||||
|
"this_month": "Hilabete honetan",
|
||||||
|
"last_6_months": "Azken 6 hilabeteetan",
|
||||||
|
"this_year": "Aurten",
|
||||||
|
"last_2_years": "Azken 2 urtetan",
|
||||||
|
"all_time": "Betidanik",
|
||||||
|
"powered_by_provider": "{providerName}-ren eskutik",
|
||||||
|
"email": "Email",
|
||||||
|
"profile_followers": "Jarraitzaileak",
|
||||||
|
"birthday": "Jaiotze-data",
|
||||||
|
"subscription": "Harpidetzak",
|
||||||
|
"not_born": "Jaio gabe",
|
||||||
|
"hacker": "Hacker",
|
||||||
|
"profile": "Profila",
|
||||||
|
"no_name": "Izenik Ez",
|
||||||
|
"edit": "Editatu",
|
||||||
|
"user_profile": "Erabiltzaile Profila",
|
||||||
|
"count_plays": "{count} erreprodukzio",
|
||||||
|
"streaming_fees_hypothetical": "Streaming ordainketa (hipotetikoa)",
|
||||||
|
"minutes_listened": "Entzundako minutuak",
|
||||||
|
"streamed_songs": "Stream-eatutako kantak",
|
||||||
|
"count_streams": "{count} stream",
|
||||||
|
"owned_by_you": "Zure jabetzakoa",
|
||||||
|
"copied_shareurl_to_clipboard": "{shareUrl} arbelera kopiatua",
|
||||||
|
"spotify_hipotetical_calculation": "*Sportify-k stream bakoitzeko duen $0.003 eta $0.005\nordainsarian oinarritua da. Kalkulu hipotetiko bat,\nkanta hauek Spotify-n entzun bazenitu,\nberaiek artistari zenbat ordaiduko lioketen jakin dezazun.",
|
||||||
|
"count_mins": "{minutes} minutu",
|
||||||
|
"summary_minutes": "minutu",
|
||||||
|
"summary_listened_to_music": "Musika entzuten",
|
||||||
|
"summary_songs": "kanta",
|
||||||
|
"summary_streamed_overall": "Stream-eatuta oro har",
|
||||||
|
"summary_owed_to_artists": "Hilabete honetan\nartistei zor zaiena",
|
||||||
|
"summary_artists": "artisten",
|
||||||
|
"summary_music_reached_you": "Musika ailegatu zaizu",
|
||||||
|
"summary_full_albums": "album osok",
|
||||||
|
"summary_got_your_love": "Izan dute zure maitasuna",
|
||||||
|
"summary_playlists": "zerrenda",
|
||||||
|
"summary_were_on_repeat": "Dituzu errepikatze moduan",
|
||||||
|
"total_money": "Guztira {money}"
|
||||||
}
|
}
|
@ -325,5 +325,64 @@
|
|||||||
"add_library_location": "اضافه کردن به کتابخانه",
|
"add_library_location": "اضافه کردن به کتابخانه",
|
||||||
"remove_library_location": "حذف از کتابخانه",
|
"remove_library_location": "حذف از کتابخانه",
|
||||||
"local_tab": "محلی",
|
"local_tab": "محلی",
|
||||||
"stats": "آمار"
|
"stats": "آمار",
|
||||||
|
"and_n_more": "و {count} بیشتر",
|
||||||
|
"recently_played": "اخیراً پخش شده",
|
||||||
|
"browse_more": "بیشتر مرور کنید",
|
||||||
|
"no_title": "بدون عنوان",
|
||||||
|
"not_playing": "در حال پخش نیست",
|
||||||
|
"epic_failure": "شکست حماسی!",
|
||||||
|
"added_num_tracks_to_queue": "{tracks_length} ترک به صف اضافه شد",
|
||||||
|
"spotube_has_an_update": "Spotube یک بروزرسانی دارد",
|
||||||
|
"download_now": "اکنون دانلود کنید",
|
||||||
|
"nightly_version": "نسخه شبانه Spotube {nightlyBuildNum} منتشر شد",
|
||||||
|
"release_version": "نسخه Spotube v{version} منتشر شد",
|
||||||
|
"read_the_latest": "آخرینها را بخوانید",
|
||||||
|
"release_notes": "یادداشتهای انتشار",
|
||||||
|
"pick_color_scheme": "طرح رنگ را انتخاب کنید",
|
||||||
|
"save": "ذخیره",
|
||||||
|
"choose_the_device": "دستگاه را انتخاب کنید:",
|
||||||
|
"multiple_device_connected": "چندین دستگاه متصل هستند.\nدستگاهی را انتخاب کنید که میخواهید این عملیات بر روی آن انجام شود",
|
||||||
|
"nothing_found": "چیزی پیدا نشد",
|
||||||
|
"the_box_is_empty": "جعبه خالی است",
|
||||||
|
"top_artists": "بهترین هنرمندان",
|
||||||
|
"top_albums": "بهترین آلبومها",
|
||||||
|
"this_week": "این هفته",
|
||||||
|
"this_month": "این ماه",
|
||||||
|
"last_6_months": "۶ ماه گذشته",
|
||||||
|
"this_year": "امسال",
|
||||||
|
"last_2_years": "۲ سال گذشته",
|
||||||
|
"all_time": "همیشه",
|
||||||
|
"powered_by_provider": "توسط {providerName} پشتیبانی شده است",
|
||||||
|
"email": "ایمیل",
|
||||||
|
"profile_followers": "دنبالکنندگان",
|
||||||
|
"birthday": "تولد",
|
||||||
|
"subscription": "اشتراک",
|
||||||
|
"not_born": "متولد نشده",
|
||||||
|
"hacker": "هکر",
|
||||||
|
"profile": "پروفایل",
|
||||||
|
"no_name": "بدون نام",
|
||||||
|
"edit": "ویرایش",
|
||||||
|
"user_profile": "پروفایل کاربر",
|
||||||
|
"count_plays": "{count} پخش",
|
||||||
|
"streaming_fees_hypothetical": "هزینههای پخش (فرضی)",
|
||||||
|
"minutes_listened": "دقایق گوش داده شده",
|
||||||
|
"streamed_songs": "ترانههای پخش شده",
|
||||||
|
"count_streams": "{count} پخش",
|
||||||
|
"owned_by_you": "توسط شما مالکیت شده",
|
||||||
|
"copied_shareurl_to_clipboard": "{shareUrl} به کلیپبورد کپی شد",
|
||||||
|
"spotify_hipotetical_calculation": "*این بر اساس پرداخت هر پخش اسپاتیفای\nبه مبلغ 0.003 تا 0.005 دلار محاسبه شده است.\nاین یک محاسبه فرضی است که به کاربران نشان دهد چقدر ممکن است\nبه هنرمندان پرداخت میکردند اگر ترانه آنها را در اسپاتیفای گوش میدادند.",
|
||||||
|
"count_mins": "{minutes} دقیقه",
|
||||||
|
"summary_minutes": "دقیقهها",
|
||||||
|
"summary_listened_to_music": "به موسیقی گوش داده شده",
|
||||||
|
"summary_songs": "ترانهها",
|
||||||
|
"summary_streamed_overall": "پخش شده به طور کلی",
|
||||||
|
"summary_owed_to_artists": "به هنرمندان بدهکار است\nاین ماه",
|
||||||
|
"summary_artists": "هنرمندان",
|
||||||
|
"summary_music_reached_you": "موسیقی به شما رسیده است",
|
||||||
|
"summary_full_albums": "آلبومهای کامل",
|
||||||
|
"summary_got_your_love": "عشق شما را به دست آورد",
|
||||||
|
"summary_playlists": "لیستهای پخش",
|
||||||
|
"summary_were_on_repeat": "در تکرار بودند",
|
||||||
|
"total_money": "مجموع {money}"
|
||||||
}
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user