Merge pull request #1768 from KRTirtho/dev

Release 3.8.0
This commit is contained in:
Kingkor Roy Tirtho 2024-08-11 15:06:37 +06:00 committed by GitHub
commit 2be84ec4ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
366 changed files with 15775 additions and 7104 deletions

View File

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

View File

@ -1,4 +1,4 @@
{
"flutterSdkVersion": "3.19.6",
"flutterSdkVersion": "3.22.3",
"flavors": {}
}

4
.github/Dockerfile vendored
View File

@ -1,6 +1,6 @@
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
@ -10,6 +10,8 @@ COPY . .
RUN chown -R $(whoami) /app
RUN rustup target add aarch64-unknown-linux-gnu
RUN flutter pub get
RUN alias dpkg-deb="dpkg-deb --Zxz" &&\

View File

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

View File

@ -53,7 +53,7 @@ body:
description: Where did you install Spotube from?
multiple: true
options:
- "Website (spotube.netlify.app) or (spotube.krtirtho.dev)"
- "Website (spotube.krtirtho.dev)"
- "GitHub Releases (Binary)"
- "GitHub Actions (Nightly Binary)"
- "Play Store (Android)"
@ -77,4 +77,4 @@ body:
description: If you are a developer and want to work on this issue yourself, you can check this box and wait for maintainer response. We welcome contributions!
options:
- label: I'm ready to work on this issue!
required: false
required: false

View File

@ -4,7 +4,7 @@ on:
pull_request:
env:
FLUTTER_VERSION: '3.19.6'
FLUTTER_VERSION: 3.22.2
jobs:
lint:

View File

@ -4,7 +4,7 @@ on:
inputs:
version:
description: Version to publish (x.x.x)
default: 3.7.1
default: 3.8.0
required: true
dry_run:
description: Dry run
@ -12,10 +12,10 @@ on:
type: boolean
default: true
jobs:
description: Jobs to run (flathub,aur,winget,chocolatey)
description: Jobs to run (flathub,aur,winget,chocolatey,playstore)
required: true
type: string
default: "flathub,aur,winget,chocolatey"
default: "flathub,aur,winget,chocolatey,playstore"
jobs:
flathub:
@ -104,3 +104,34 @@ jobs:
- name: Publish to Chocolatey Repository
if: ${{ !inputs.dry_run }}
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 }}

View File

@ -20,7 +20,7 @@ on:
description: Dry run without uploading to release
env:
FLUTTER_VERSION: 3.19.6
FLUTTER_VERSION: 3.22.3
permissions:
contents: write
@ -82,6 +82,11 @@ jobs:
- name: Set up Docker Buildx
if: ${{matrix.platform == 'linux_arm'}}
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
run: |
@ -94,6 +99,11 @@ jobs:
echo '${{ secrets.KEYSTORE }}' | base64 --decode > android/app/upload-keystore.jks
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
run: dart cli/cli.dart build ${{matrix.platform}}
env:

View File

@ -17,6 +17,7 @@
"songlink",
"speechiness",
"Spotube",
"titlebar",
"winget"
],
"editor.formatOnSave": true,

View File

@ -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.
## [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

View File

@ -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
[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
to <>.
to krtirtho@gmail.com.
## I Have a Question
@ -123,16 +123,16 @@ Do the following:
- Install Development dependencies in linux
- Debian (>=12/Bookworm)/Ubuntu
```bash
$ apt-get install mpv libmpv-dev libappindicator3-1 gir1.2-appindicator3-0.1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev 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)
- Arch/Manjaro
```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
```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
- Create a `.env` in root of the project following the `.env.example` template

View File

@ -31,7 +31,7 @@ if (keystorePropertiesFile.exists()) {
android {
compileSdkVersion 34
ndkVersion "21.4.7075529"
ndkVersion "25.1.8937393"
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8

View File

@ -8,3 +8,10 @@ targets:
options:
any_map: true
explicit_to_json: true
drift_dev:
options:
sql:
dialect: sqlite
options:
modules:
- json1

View File

@ -41,6 +41,25 @@ class WindowsBuildCommand extends Command with BuildCommandCommonSteps {
await bootstrap();
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(
"flutter_distributor package --platform=windows --targets=exe --skip-clean",
);

View File

@ -37,7 +37,7 @@ class InstallDependenciesCommand extends Command {
await shell.run(
"""
sudo apt-get update -y
sudo apt-get install -y tar clang cmake ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse libunwind-dev locate patchelf gir1.2-appindicator3-0.1 libappindicator3-1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev mpv libmpv-dev
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;
@ -58,6 +58,11 @@ class InstallDependenciesCommand extends Command {
);
break;
case "ios":
await shell.run(
"""
rustup target add aarch64-apple-ios
""",
);
break;
case "android":
await shell.run(

View File

@ -49,6 +49,8 @@ PODS:
- Flutter (1.0.0)
- flutter_broadcasts (0.0.1):
- Flutter
- flutter_discord_rpc (0.0.1):
- Flutter
- flutter_inappwebview_ios (0.0.1):
- Flutter
- flutter_inappwebview_ios/Core (= 0.0.1)
@ -58,17 +60,12 @@ PODS:
- OrderedSet (~> 5.0)
- flutter_keyboard_visibility (0.0.1):
- Flutter
- flutter_mailer (0.0.1):
- Flutter
- flutter_native_splash (0.0.1):
- Flutter
- flutter_secure_storage (6.0.0):
- Flutter
- flutter_sharing_intent (0.0.1):
- Flutter
- fluttertoast (0.0.2):
- Flutter
- Toast
- image_picker_ios (0.0.1):
- Flutter
- integration_test (0.0.1):
@ -77,7 +74,8 @@ PODS:
- Flutter
- media_kit_native_event_loop (1.0.0):
- Flutter
- metadata_god (0.0.1)
- metadata_god (0.0.1):
- Flutter
- OrderedSet (5.0.0)
- package_info_plus (0.4.5):
- Flutter
@ -95,8 +93,22 @@ PODS:
- sqflite (0.0.3):
- Flutter
- 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)
- Toast (4.0.0)
- url_launcher_ios (0.0.1):
- Flutter
@ -110,13 +122,12 @@ DEPENDENCIES:
- file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`)
- Flutter (from `Flutter`)
- 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_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_secure_storage (from `.symlinks/plugins/flutter_secure_storage/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`)
- integration_test (from `.symlinks/plugins/integration_test/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`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/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`)
SPEC REPOS:
@ -135,8 +147,8 @@ SPEC REPOS:
- DKPhotoGallery
- OrderedSet
- SDWebImage
- sqlite3
- SwiftyGif
- Toast
EXTERNAL SOURCES:
app_links:
@ -157,20 +169,18 @@ EXTERNAL SOURCES:
:path: Flutter
flutter_broadcasts:
:path: ".symlinks/plugins/flutter_broadcasts/ios"
flutter_discord_rpc:
:path: ".symlinks/plugins/flutter_discord_rpc/ios"
flutter_inappwebview_ios:
:path: ".symlinks/plugins/flutter_inappwebview_ios/ios"
flutter_keyboard_visibility:
:path: ".symlinks/plugins/flutter_keyboard_visibility/ios"
flutter_mailer:
:path: ".symlinks/plugins/flutter_mailer/ios"
flutter_native_splash:
:path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_secure_storage:
:path: ".symlinks/plugins/flutter_secure_storage/ios"
flutter_sharing_intent:
:path: ".symlinks/plugins/flutter_sharing_intent/ios"
fluttertoast:
:path: ".symlinks/plugins/fluttertoast/ios"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
integration_test:
@ -191,6 +201,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite:
:path: ".symlinks/plugins/sqflite/darwin"
sqlite3_flutter_libs:
:path: ".symlinks/plugins/sqlite3_flutter_libs/ios"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
@ -206,18 +218,17 @@ SPEC CHECKSUMS:
file_selector_ios: 78baf21d03f1e37a7df97bb2494f9cd86de8fa5d
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_broadcasts: 3ece15b27d8ccbe2132c3df303e7c3401feab882
flutter_discord_rpc: e1c342f29ceb9dd76cdc01db59a70c93bb4d9ec5
flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0
flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069
flutter_mailer: 2ef5a67087bc8c6c4cefd04a178bf1ae2c94cd83
flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be
flutter_sharing_intent: e35380d0e1501d7111dbb7e46d5ac6339da6da98
fluttertoast: 9f2f8e81bb5ce18facb9748d7855bf5a756fe3db
image_picker_ios: b545a5f16c0fa88e3ecbbce3ed4de45567a8ec18
integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4
media_kit_libs_ios_audio: 8f39d96a9c630685dfb844c289bd1d114c486fb3
media_kit_native_event_loop: 99111eded5acbdc9c2738021ea6550dd36ca8837
metadata_god: eceae399d0020475069a5cebc35943ce8562b5d7
metadata_god: 4bbd8523cdb5d42c5e59d2fabad01ff8f4bc53f9
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c
@ -225,8 +236,9 @@ SPEC CHECKSUMS:
SDWebImage: a81bbb3ba4ea5f810f4069c68727cb118467a04a
shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
sqlite3: 292c3e1bfe89f64e51ea7fc7dab9182a017c8630
sqlite3_flutter_libs: 0d611efdf6d1c9297d5ab03dab21b75aeebdae31
SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: 6116280ddcfe98ab8820085d8d76ae7449447586
PODFILE CHECKSUM: 0659b64ac6e9e96b61d8550decffa8bff51a957e

View File

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

View File

@ -1,6 +1,8 @@
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_friends.dart';
import 'package:spotube/provider/history/summary.dart';
abstract class FakeData {
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,
);
}

View File

@ -5,13 +5,12 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:spotube/collections/routes.dart';
import 'package:spotube/components/player/player_controls.dart';
import 'package:spotube/models/logger.dart';
import 'package:spotube/modules/player/player_controls.dart';
import 'package:spotube/pages/home/home.dart';
import 'package:spotube/pages/library/library.dart';
import 'package:spotube/pages/lyrics/lyrics.dart';
import 'package:spotube/pages/search/search.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/audio_player/querying_track_info.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/utils/platform.dart';
@ -21,8 +20,6 @@ class PlayPauseIntent extends Intent {
}
class PlayPauseAction extends Action<PlayPauseIntent> {
final logger = getLogger(PlayPauseAction);
@override
invoke(intent) async {
if (PlayerControls.focusNode.canRequestFocus) {
@ -96,8 +93,8 @@ class SeekIntent extends Intent {
class SeekAction extends Action<SeekIntent> {
@override
invoke(intent) async {
final playlist = intent.ref.read(proxyPlaylistProvider);
if (playlist.isFetching) {
final isFetchingActiveTrack = intent.ref.read(queryingTrackInfoProvider);
if (isFetchingActiveTrack) {
DirectionalFocusAction().invoke(
DirectionalFocusIntent(
intent.forward ? TraversalDirection.right : TraversalDirection.left,
@ -105,7 +102,7 @@ class SeekAction extends Action<SeekIntent> {
);
return null;
}
final position = (await audioPlayer.position ?? Duration.zero).inSeconds;
final position = audioPlayer.position.inSeconds;
await audioPlayer.seek(
Duration(
seconds: intent.forward ? position + 5 : position - 5,

View File

@ -83,7 +83,7 @@ abstract class LanguageLocals {
// ),
"eu": const ISOLanguageName(
name: "Basque",
nativeName: "euskara",
nativeName: "Euskara",
),
// "be": const ISOLanguageName(
// name: "Belarusian",
@ -354,8 +354,8 @@ abstract class LanguageLocals {
// nativeName: "KiKongo",
// ),
"ko": const ISOLanguageName(
name: "Korean",
nativeName: "한국어 (韓國語), 조선말 (朝鮮語)",
name: "Korean",
nativeName: "한국어 (韓國語), 조선말 (朝鮮語)",
),
// "ku": const ISOLanguageName(
// name: "Kurdish",

View File

@ -1,4 +1,3 @@
import 'package:catcher_2/catcher_2.dart';
import 'package:flutter/foundation.dart' hide Category;
import 'package:flutter/widgets.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/streams/streams.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/utils/platform.dart';
import 'package:spotube/components/shared/spotube_page_route.dart';
import 'package:spotube/components/spotube_page_route.dart';
import 'package:spotube/pages/artist/artist.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/root/root_app.dart';
import 'package:spotube/pages/settings/settings.dart';
import 'package:spotube/pages/mobile_login/mobile_login.dart';
final rootNavigatorKey = Catcher2.navigatorKey;
final rootNavigatorKey = GlobalKey<NavigatorState>();
final shellRouteNavigatorKey = GlobalKey<NavigatorState>();
final routerProvider = Provider((ref) {
return GoRouter(
@ -60,11 +56,9 @@ final routerProvider = Provider((ref) {
path: "/",
name: HomePage.name,
redirect: (context, state) async {
final authNotifier = ref.read(authenticationProvider.notifier);
final json = await authNotifier.box.get(authNotifier.cacheKey);
final auth = await ref.read(authenticationProvider.future);
if (json?["cookie"] == null &&
!KVStoreService.doneGettingStarted) {
if (auth == null && !KVStoreService.doneGettingStarted) {
return "/getting-started";
}
@ -316,16 +310,8 @@ final routerProvider = Provider((ref) {
path: "/login",
name: WebViewLogin.name,
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(
child: LoginTutorial(),
child: WebViewLogin(),
),
),
GoRoute(

View File

@ -187,7 +187,7 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
icon: icon ?? const Icon(SpotubeIcons.moreVertical),
tooltip: tooltip,
style: theme.iconButtonTheme.style?.copyWith(
shape: MaterialStatePropertyAll(
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(
borderRadius: borderRadius,
),
@ -226,7 +226,10 @@ class _AdaptivePopSheetListItem<T> extends StatelessWidget {
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: IgnorePointer(child: item),
child: IconTheme.merge(
data: const IconThemeData(opacity: 1),
child: IgnorePointer(child: item),
),
),
);
}

View File

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

View File

@ -1,6 +1,6 @@
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/context.dart';

View File

@ -4,8 +4,8 @@ import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/playlist/playlist_create_dialog.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/modules/playlist/playlist_create_dialog.dart';
import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/provider/spotify/spotify.dart';

View File

@ -15,15 +15,12 @@ class SelectDeviceDialog extends HookConsumerWidget {
final remoteService = connectClients.asData!.value.resolvedService!;
return AlertDialog(
title: const Text("Choose the device:"),
title: Text(context.l10n.choose_the_device),
insetPadding: const EdgeInsets.all(16),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
"There are multiple device connected.\n"
"Choose the device you want this action to take place",
),
Text(context.l10n.multiple_device_connected),
RadioListTile.adaptive(
title: Text(remoteService.name),
value: true,
@ -33,7 +30,7 @@ class SelectDeviceDialog extends HookConsumerWidget {
},
),
RadioListTile.adaptive(
title: const Text("This Device"),
title: Text(context.l10n.this_device),
value: false,
groupValue: isRemoteService.value,
onChanged: (value) {

View File

@ -2,9 +2,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/links/artist_link.dart';
import 'package:spotube/components/shared/links/hyper_link.dart';
import 'package:spotube/components/shared/links/link_text.dart';
import 'package:spotube/components/links/artist_link.dart';
import 'package:spotube/components/links/hyper_link.dart';
import 'package:spotube/components/links/link_text.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
@ -28,6 +28,7 @@ class TrackDetailsDialog extends HookWidget {
artists: track.artists ?? <Artist>[],
mainAxisAlignment: WrapAlignment.start,
textStyle: const TextStyle(color: Colors.blue),
hideOverflowArtist: false,
),
context.l10n.album: LinkText(
track.album!.name!,

View File

@ -3,7 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/pages/settings/settings.dart';
import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/authentication/authentication.dart';
import 'package:spotube/utils/service_utils.dart';
class AnonymousFallback extends ConsumerWidget {
@ -15,9 +15,13 @@ class AnonymousFallback extends ConsumerWidget {
@override
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(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/extensions/context.dart';
class NotFound extends StatelessWidget {
final bool vertical;
@ -18,9 +19,9 @@ class NotFound extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text("Nothing found", style: theme.textTheme.titleLarge),
Text(context.l10n.nothing_found, style: theme.textTheme.titleLarge),
Text(
"The box is empty",
context.l10n.the_box_is_empty,
style: theme.textTheme.titleMedium,
),
],

View File

@ -1,11 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/heart_button/use_track_toggle_like.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/scrobbler_provider.dart';
import 'package:spotube/provider/authentication/authentication.dart';
import 'package:spotube/provider/spotify/spotify.dart';
class HeartButton extends HookConsumerWidget {
@ -27,7 +26,7 @@ class HeartButton extends HookConsumerWidget {
Widget build(BuildContext context, ref) {
final auth = ref.watch(authenticationProvider);
if (auth == null) return const SizedBox.shrink();
if (auth.asData?.value == null) return const SizedBox.shrink();
return IconButton(
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 {
final Track track;
const TrackHeartButton({

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

View File

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

View File

@ -5,9 +5,9 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/components/album/album_card.dart';
import 'package:spotube/components/artist/artist_card.dart';
import 'package:spotube/components/playlist/playlist_card.dart';
import 'package:spotube/modules/album/album_card.dart';
import 'package:spotube/modules/artist/artist_card.dart';
import 'package:spotube/modules/playlist/playlist_card.dart';
import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';

View File

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

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

View File

@ -1,5 +1,5 @@
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';
class Hyperlink extends StatelessWidget {

View File

@ -1,5 +1,5 @@
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';
class LinkText<T> extends StatelessWidget {

View File

@ -1,4 +1,4 @@
part of './sliding_up_panel.dart';
part of 'sliding_up_panel.dart';
class PanelController extends ChangeNotifier {
SlidingUpPanelState? _panelState;

View File

@ -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,
/// wrap the widget with this

View File

@ -3,23 +3,15 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:skeletonizer/skeletonizer.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/hover_builder.dart';
import 'package:spotube/components/image/universal_image.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_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 {
final void Function()? onTap;
final void Function()? onPlaybuttonPressed;
@ -66,19 +58,18 @@ class PlaybuttonCard extends HookWidget {
others: 15,
);
final cleanDescription = useDescription(description);
var unescapeHtml = description?.unescapeHtml();
return Container(
constraints: BoxConstraints(maxWidth: size),
margin: margin,
child: Material(
color: Color.lerp(
theme.colorScheme.surfaceVariant,
theme.colorScheme.surfaceContainerHighest,
theme.colorScheme.surface,
useBrightnessValue(.9, .7),
),
borderRadius: radius,
shadowColor: theme.colorScheme.background,
shadowColor: theme.colorScheme.surface,
elevation: 3,
child: InkWell(
mouseCursor: SystemMouseCursors.click,
@ -137,7 +128,7 @@ class PlaybuttonCard extends HookWidget {
),
if (isHovered)
Text(
"Owned by you",
context.l10n.owned_by_you,
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.white,
),
@ -158,7 +149,7 @@ class PlaybuttonCard extends HookWidget {
Skeleton.keep(
child: IconButton(
style: IconButton.styleFrom(
backgroundColor: theme.colorScheme.background,
backgroundColor: theme.colorScheme.surface,
foregroundColor: theme.colorScheme.primary,
minimumSize: const Size.square(10),
),
@ -205,11 +196,11 @@ class PlaybuttonCard extends HookWidget {
overflow: TextOverflow.ellipsis,
),
),
if (cleanDescription != null)
if (description != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: AutoSizeText(
cleanDescription,
unescapeHtml!,
maxLines: 2,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(.5),

View File

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

View File

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

View File

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

View File

@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/library/user_local_tracks.dart';
import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart';
import 'package:spotube/modules/library/user_local_tracks.dart';
import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart';
import 'package:spotube/extensions/context.dart';
class SortTracksDropdown extends StatelessWidget {

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

View File

@ -8,24 +8,27 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart';
import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart';
import 'package:spotube/components/shared/dialogs/prompt_dialog.dart';
import 'package:spotube/components/shared/dialogs/track_details_dialog.dart';
import 'package:spotube/components/shared/heart_button.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/components/shared/links/artist_link.dart';
import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart';
import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart';
import 'package:spotube/components/dialogs/prompt_dialog.dart';
import 'package:spotube/components/dialogs/track_details_dialog.dart';
import 'package:spotube/components/heart_button/use_track_toggle_like.dart';
import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/components/links/artist_link.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/models/database/database.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/download_manager_provider.dart';
import 'package:spotube/provider/local_tracks/local_tracks_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/utils/service_utils.dart';
import 'package:url_launcher/url_launcher_string.dart';
@ -95,8 +98,8 @@ class TrackOptions extends HookConsumerWidget {
WidgetRef ref,
Track track,
) async {
final playback = ref.read(proxyPlaylistProvider.notifier);
final playlist = ref.read(proxyPlaylistProvider);
final playback = ref.read(audioPlayerProvider.notifier);
final playlist = ref.read(audioPlayerProvider);
final spotify = ref.read(spotifyProvider);
final query = "${track.name} Radio";
final pages =
@ -159,8 +162,8 @@ class TrackOptions extends HookConsumerWidget {
final router = GoRouter.of(context);
final ThemeData(:colorScheme) = Theme.of(context);
final playlist = ref.watch(proxyPlaylistProvider);
final playback = ref.watch(proxyPlaylistProvider.notifier);
final playlist = ref.watch(audioPlayerProvider);
final playback = ref.watch(audioPlayerProvider.notifier);
final auth = ref.watch(authenticationProvider);
ref.watch(downloadManagerProvider);
final downloadManager = ref.watch(downloadManagerProvider.notifier);
@ -170,11 +173,8 @@ class TrackOptions extends HookConsumerWidget {
final favorites = useTrackToggleLike(track, ref);
final isBlackListed = useMemoized(
() => blacklist.contains(
BlacklistedElement.track(
track.id!,
track.name!,
),
() => blacklist.asData?.value.any(
(element) => element.elementId == track.id,
),
[blacklist, track],
);
@ -258,13 +258,16 @@ class TrackOptions extends HookConsumerWidget {
.removeTracks(playlistId ?? "", [track.id!]);
break;
case TrackOptionValue.blacklist:
if (isBlackListed) {
ref.read(blacklistProvider.notifier).remove(
BlacklistedElement.track(track.id!, track.name!),
);
if (isBlackListed == null) break;
if (isBlackListed == true) {
await ref.read(blacklistProvider.notifier).remove(track.id!);
} else {
ref.read(blacklistProvider.notifier).add(
BlacklistedElement.track(track.id!, track.name!),
await ref.read(blacklistProvider.notifier).add(
BlacklistTableCompanion.insert(
name: track.name!,
elementId: track.id!,
elementType: BlacklistedType.track,
),
);
}
break;
@ -312,7 +315,16 @@ class TrackOptions extends HookConsumerWidget {
),
subtitle: Align(
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),
title: Text(context.l10n.delete),
),
if (mediaQuery.smAndDown)
if (mediaQuery.smAndDown && !isLocalTrack)
PopSheetEntry(
value: TrackOptionValue.album,
leading: const Icon(SpotubeIcons.album),
@ -363,7 +375,7 @@ class TrackOptions extends HookConsumerWidget {
: context.l10n.save_as_favorite,
),
),
if (auth != null && !isLocalTrack) ...[
if (auth.asData?.value != null && !isLocalTrack) ...[
PopSheetEntry(
value: TrackOptionValue.startRadio,
leading: const Icon(SpotubeIcons.radio),
@ -375,7 +387,7 @@ class TrackOptions extends HookConsumerWidget {
title: Text(context.l10n.add_to_playlist),
),
],
if (userPlaylist && auth != null && !isLocalTrack)
if (userPlaylist && auth.asData?.value != null && !isLocalTrack)
PopSheetEntry(
value: TrackOptionValue.removeFromPlaylist,
leading: const Icon(SpotubeIcons.removeFilled),
@ -399,10 +411,10 @@ class TrackOptions extends HookConsumerWidget {
PopSheetEntry(
value: TrackOptionValue.blacklist,
leading: const Icon(SpotubeIcons.playlistRemove),
iconColor: !isBlackListed ? Colors.red[400] : null,
textColor: !isBlackListed ? Colors.red[400] : null,
iconColor: isBlackListed != true ? Colors.red[400] : null,
textColor: isBlackListed != true ? Colors.red[400] : null,
title: Text(
isBlackListed
isBlackListed == true
? context.l10n.remove_from_blacklist
: context.l10n.add_to_blacklist,
),

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

View File

@ -8,17 +8,17 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
import 'package:spotube/components/shared/expandable_search/expandable_search.dart';
import 'package:spotube/components/shared/track_tile/track_tile.dart';
import 'package:spotube/components/shared/tracks_view/sections/body/track_view_body_headers.dart';
import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_playlist.dart';
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/components/shared/tracks_view/track_view_provider.dart';
import 'package:spotube/components/dialogs/select_device_dialog.dart';
import 'package:spotube/components/expandable_search/expandable_search.dart';
import 'package:spotube/components/track_tile/track_tile.dart';
import 'package:spotube/components/tracks_view/sections/body/track_view_body_headers.dart';
import 'package:spotube/components/tracks_view/sections/body/use_is_user_playlist.dart';
import 'package:spotube/components/tracks_view/track_view_props.dart';
import 'package:spotube/components/tracks_view/track_view_provider.dart';
import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/provider/connect/connect.dart';
import 'package:spotube/provider/history/history.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/utils/service_utils.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
@ -27,9 +27,9 @@ class TrackViewBodySection extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
final playlist = ref.watch(proxyPlaylistProvider);
final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryProvider.notifier);
final playlist = ref.watch(audioPlayerProvider);
final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryActionsProvider);
final props = InheritedTrackView.of(context);
final trackViewState = ref.watch(trackViewProvider(props.tracks));

View File

@ -1,10 +1,10 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/components/shared/expandable_search/expandable_search.dart';
import 'package:spotube/components/shared/sort_tracks_dropdown.dart';
import 'package:spotube/components/shared/tracks_view/sections/body/track_view_options.dart';
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/components/shared/tracks_view/track_view_provider.dart';
import 'package:spotube/components/expandable_search/expandable_search.dart';
import 'package:spotube/components/sort_tracks_dropdown.dart';
import 'package:spotube/components/tracks_view/sections/body/track_view_options.dart';
import 'package:spotube/components/tracks_view/track_view_props.dart';
import 'package:spotube/components/tracks_view/track_view_provider.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';

View File

@ -2,17 +2,17 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart';
import 'package:spotube/components/shared/dialogs/confirm_download_dialog.dart';
import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart';
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/components/shared/tracks_view/track_view_provider.dart';
import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart';
import 'package:spotube/components/dialogs/confirm_download_dialog.dart';
import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart';
import 'package:spotube/components/tracks_view/track_view_props.dart';
import 'package:spotube/components/tracks_view/track_view_provider.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/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_state.dart';
class TrackViewBodyOptions extends HookConsumerWidget {
const TrackViewBodyOptions({super.key});
@ -24,8 +24,8 @@ class TrackViewBodyOptions extends HookConsumerWidget {
ref.watch(downloadManagerProvider);
final downloader = ref.watch(downloadManagerProvider.notifier);
final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryProvider.notifier);
final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryActionsProvider);
final audioSource =
ref.watch(userPreferencesProvider.select((s) => s.audioSource));

View File

@ -4,13 +4,13 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/components/shared/playbutton_card.dart';
import 'package:spotube/components/shared/tracks_view/sections/header/header_actions.dart';
import 'package:spotube/components/shared/tracks_view/sections/header/header_buttons.dart';
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/components/tracks_view/sections/header/header_actions.dart';
import 'package:spotube/components/tracks_view/sections/header/header_buttons.dart';
import 'package:spotube/components/tracks_view/track_view_props.dart';
import 'package:gap/gap.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/utils/platform.dart';
@ -24,8 +24,6 @@ class TrackViewFlexHeader extends HookConsumerWidget {
final defaultTextStyle = DefaultTextStyle.of(context);
final mediaQuery = MediaQuery.of(context);
final description = useDescription(props.description);
final palette = usePaletteColor(props.image, ref);
return IconTheme(
@ -127,10 +125,10 @@ class TrackViewFlexHeader extends HookConsumerWidget {
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 10),
if (description != null &&
description.isNotEmpty)
if (props.description != null &&
props.description!.isNotEmpty)
Text(
description,
props.description!.unescapeHtml(),
style:
defaultTextStyle.style.copyWith(
color: palette.bodyTextColor,

View File

@ -4,14 +4,14 @@ import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/playlist/playlist_create_dialog.dart';
import 'package:spotube/components/shared/heart_button.dart';
import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_playlist.dart';
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/modules/playlist/playlist_create_dialog.dart';
import 'package:spotube/components/heart_button/heart_button.dart';
import 'package:spotube/components/tracks_view/sections/body/use_is_user_playlist.dart';
import 'package:spotube/components/tracks_view/track_view_props.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/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
class TrackViewHeaderActions extends HookConsumerWidget {
const TrackViewHeaderActions({super.key});
@ -20,9 +20,9 @@ class TrackViewHeaderActions extends HookConsumerWidget {
Widget build(BuildContext context, ref) {
final props = InheritedTrackView.of(context);
final playlist = ref.watch(proxyPlaylistProvider);
final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryProvider.notifier);
final playlist = ref.watch(audioPlayerProvider);
final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryActionsProvider);
final isActive = playlist.collections.contains(props.collectionId);
@ -32,6 +32,9 @@ class TrackViewHeaderActions extends HookConsumerWidget {
final auth = ref.watch(authenticationProvider);
final copiedText =
context.l10n.copied_shareurl_to_clipboard(props.shareUrl);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
@ -48,7 +51,7 @@ class TrackViewHeaderActions extends HookConsumerWidget {
width: 300,
behavior: SnackBarBehavior.floating,
content: Text(
"Copied ${props.shareUrl} to clipboard",
copiedText,
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(
isLiked: props.isLiked,
icon: isUserPlaylist ? SpotubeIcons.trash : null,

View File

@ -7,13 +7,13 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/components/dialogs/select_device_dialog.dart';
import 'package:spotube/components/tracks_view/track_view_props.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/provider/connect/connect.dart';
import 'package:spotube/provider/history/history.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
class TrackViewHeaderButtons extends HookConsumerWidget {
@ -28,9 +28,9 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
final props = InheritedTrackView.of(context);
final playlist = ref.watch(proxyPlaylistProvider);
final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryProvider.notifier);
final playlist = ref.watch(audioPlayerProvider);
final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryActionsProvider);
final isActive = playlist.collections.contains(props.collectionId);
@ -131,7 +131,9 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
);
}
} finally {
isLoading.value = false;
if (context.mounted) {
isLoading.value = false;
}
}
}

View File

@ -3,11 +3,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sliver_tools/sliver_tools.dart';
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/components/shared/tracks_view/sections/header/flexible_header.dart';
import 'package:spotube/components/shared/tracks_view/sections/body/track_view_body.dart';
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/titlebar/titlebar.dart';
import 'package:spotube/components/tracks_view/sections/header/flexible_header.dart';
import 'package:spotube/components/tracks_view/sections/body/track_view_body.dart';
import 'package:spotube/components/tracks_view/track_view_props.dart';
import 'package:spotube/utils/platform.dart';
class TrackView extends HookConsumerWidget {

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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 {
List<Track> tracks;

View File

@ -1,6 +1,20 @@
import 'package:flutter/rendering.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
const Breakpoints = (
xs: 480.0,
@ -22,6 +36,15 @@ extension SliverBreakpoints on SliverConstraints {
crossAxisExtent > Breakpoints.lg && 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 mdAndUp => isMd || 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;
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 mdAndUp => isMd || isLg || isXl || is2Xl;
bool get lgAndUp => isLg || isXl || is2Xl;

View File

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

View File

@ -7,7 +7,7 @@ extension UnescapeHtml on String {
}
extension NullableUnescapeHtml on String? {
String? unescapeHtml() => this == null ? null : htmlEscape.convert(this!);
String? unescapeHtml() => this?.unescapeHtml();
}
extension StringExtension on String {

View File

@ -2,8 +2,9 @@ import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.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_state.dart';
import 'package:local_notifier/local_notifier.dart';
import 'package:spotube/utils/platform.dart';
import 'package:window_manager/window_manager.dart';

View File

@ -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_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/authentication/authentication.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
void useEndlessPlayback(WidgetRef ref) {
final auth = ref.watch(authenticationProvider);
final playback = ref.watch(proxyPlaylistProvider.notifier);
final playlist = ref.watch(proxyPlaylistProvider);
final playback = ref.watch(audioPlayerProvider.notifier);
final playlist = ref.watch(audioPlayerProvider.select((s) => s.playlist));
final spotify = ref.watch(spotifyProvider);
final endlessPlayback =
ref.watch(userPreferencesProvider.select((s) => s.endlessPlayback));
useEffect(
() {
if (!endlessPlayback || auth == null) return null;
if (!endlessPlayback || auth.asData?.value == null) return null;
void listener(int index) async {
try {
final playlist = ref.read(proxyPlaylistProvider);
final playlist = ref.read(audioPlayerProvider);
if (index != playlist.tracks.length - 1) return;
final track = playlist.tracks.last;
@ -56,22 +56,22 @@ void useEndlessPlayback(WidgetRef ref) {
await playback.addTracks(
tracks.toList()
..removeWhere((e) {
final playlist = ref.read(proxyPlaylistProvider);
final playlist = ref.read(audioPlayerProvider);
final isDuplicate = playlist.tracks.any((t) => t.id == e.id);
return e.id == track.id || isDuplicate;
}),
);
} catch (e, stack) {
Catcher2.reportCheckedError(e, stack);
AppLogger.reportError(e, stack);
}
}
// Sometimes user can change settings for which the currentIndexChanged
// 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.
if (playlist.active == playlist.tracks.length - 1 &&
if (playlist.index == playlist.medias.length - 1 &&
audioPlayer.isPlaying) {
listener(playlist.active!);
listener(playlist.index);
}
final subscription =
@ -82,7 +82,7 @@ void useEndlessPlayback(WidgetRef ref) {
[
spotify,
playback,
playlist.tracks,
playlist.medias,
endlessPlayback,
auth,
],

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

View File

@ -12,25 +12,35 @@ void useGetStoragePermissions(WidgetRef ref) {
useAsyncEffect(
() 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 &&
!await Permission.storage.isGranted &&
!await Permission.storage.isLimited;
final hasNoAudioPerm = androidInfo.version.sdkInt >= 33 &&
!await Permission.audio.isGranted &&
!await Permission.audio.isLimited;
final hasNoAudioPerm = androidInfo.version.sdkInt >= 33 &&
!await Permission.audio.isGranted &&
!await Permission.audio.isLimited;
if (hasNoStoragePerm) {
await Permission.storage.request();
if (context.mounted) ref.invalidate(localTracksProvider);
if (hasNoStoragePerm) {
await Permission.storage.request();
if (context.mounted) ref.invalidate(localTracksProvider);
}
if (hasNoAudioPerm) {
await Permission.audio.request();
if (context.mounted) ref.invalidate(localTracksProvider);
}
}
if (hasNoAudioPerm) {
await Permission.audio.request();
if (context.mounted) ref.invalidate(localTracksProvider);
if (kIsIOS) {
final hasStoragePerm = await Permission.storage.isGranted ||
await Permission.storage.isLimited;
if (!hasStoragePerm) {
await Permission.storage.request();
if (context.mounted) ref.invalidate(localTracksProvider);
}
}
},
null,

View File

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_riverpod/flutter_riverpod.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>(
(ref) {

View File

@ -325,5 +325,64 @@
"add_library_location": "أضف إلى المكتبة",
"remove_library_location": "إزالة من المكتبة",
"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}"
}

View File

@ -325,5 +325,64 @@
"add_library_location": "লাইব্রেরিতে যোগ করুন",
"remove_library_location": "লাইব্রেরি থেকে সরান",
"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}"
}

View File

@ -325,5 +325,64 @@
"add_library_location": "Afegeix a la biblioteca",
"remove_library_location": "Elimina de la biblioteca",
"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}"
}

View File

@ -325,5 +325,64 @@
"add_library_location": "Přidat do knihovny",
"remove_library_location": "Odebrat z knihovny",
"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}"
}

View File

@ -325,5 +325,64 @@
"add_library_location": "Zur Bibliothek hinzufügen",
"remove_library_location": "Aus der Bibliothek entfernen",
"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}"
}

View File

@ -325,5 +325,64 @@
"connect_client_alert": "You're being controlled by {client}",
"this_device": "This Device",
"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}"
}

View File

@ -325,5 +325,64 @@
"add_library_location": "Añadir a la biblioteca",
"remove_library_location": "Eliminar de la biblioteca",
"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}"
}

View File

@ -107,6 +107,9 @@
"always_on_top": "Beti ikusgai",
"exit_mini_player": "Irten mini erreproduzitzailetik",
"download_location": "Deskargen kokapena",
"local_library": "Liburutegi lokala",
"add_library_location": "Gehitu liburutegira",
"remove_library_location": "Kendu liburutegitik",
"account": "Kontua",
"login_with_spotify": "Hasi saioa zure Spotify kontuarekin",
"connect_with_spotify": "Spotify-rekin konektatu",
@ -118,8 +121,8 @@
"market_place_region": "Dendaren herrialdea",
"recommendation_country": "Gomendio herrialdea",
"appearance": "Itxura",
"layout_mode": "Diseinu modua",
"override_layout_settings": "Responsive diseinu moduaren ezarpenak ezeztatu",
"layout_mode": "Diseinua",
"override_layout_settings": "Responsive diseinuaren ezarpenak ezeztatu",
"adaptive": "Moldagarria",
"compact": "Trinkoa",
"extended": "Hedatua",
@ -287,7 +290,7 @@
"genres": "Generoak",
"explore_genres": "Esploratu generoak",
"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",
"how_to_start_radio": "Nola hasi nahi duzu irratia?",
"replace_queue_question": "Uneko zerrenda ordezkatu nahi duzu edo bertan gehitu?",
@ -295,6 +298,7 @@
"delete_playlist": "Ezabatu zerrenda",
"delete_playlist_confirmation": "Ziur zaude zerrenda ezabatu nahi duzula?",
"local_tracks": "Kanta lokalak",
"local_tab": "Lokalean",
"song_link": "Kantaren lotura",
"skip_this_nonsense": "Utzi txorakeria hau",
"freedom_of_music": "“Musika Askatasuna”",
@ -321,9 +325,64 @@
"connect_client_alert": "{client} gailuak kontrolatzen zaitu",
"this_device": "Gailu hau",
"remote": "Urrunekoa",
"local_library": "Liburutegi lokala",
"add_library_location": "Gehitu liburutegira",
"remove_library_location": "Kendu liburutegitik",
"local_tab": "Tokiko",
"stats": "Estatistikak"
"stats": "Estatistikak",
"and_n_more": "eta {count} gehiago",
"recently_played": "Berriki entzunak",
"browse_more": "Gehiago Bilatu",
"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}"
}

View File

@ -325,5 +325,64 @@
"add_library_location": "اضافه کردن به کتابخانه",
"remove_library_location": "حذف از کتابخانه",
"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